@terreno/api 0.0.11-beta.1 → 0.0.12

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,106 +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 qs from "qs";
5
3
  import supertest from "supertest";
6
4
  import type TestAgent from "supertest/lib/agent";
7
5
 
8
6
  import {addPopulateToQuery, modelRouter} from "./api";
9
7
  import {addAuthRoutes, setupAuth} from "./auth";
10
- import {APIError} from "./errors";
11
- import {logRequests} from "./expressServer";
12
8
  import {Permissions} from "./permissions";
13
- import {authAsUser, type Food, FoodModel, getBaseServer, setupDb, UserModel} from "./tests";
9
+ import {
10
+ authAsUser,
11
+ type Food,
12
+ FoodModel,
13
+ getBaseServer,
14
+ RequiredModel,
15
+ setupDb,
16
+ UserModel,
17
+ } from "./tests";
14
18
  import {AdminOwnerTransformer} from "./transformers";
15
19
 
16
20
  describe("@terreno/api", () => {
17
21
  let server: TestAgent;
18
22
  let app: express.Application;
19
23
 
20
- describe("pre and post hooks", () => {
24
+ describe("populate", () => {
25
+ let admin: any;
26
+ let notAdmin: any;
21
27
  let agent: TestAgent;
28
+ let spinach: Food;
22
29
 
23
30
  beforeEach(async () => {
24
- await setupDb();
25
- app = getBaseServer();
26
- setupAuth(app, UserModel as any);
27
- addAuthRoutes(app, UserModel as any);
28
- agent = await authAsUser(app, "notAdmin");
29
- });
31
+ [admin, notAdmin] = await setupDb();
30
32
 
31
- it("pre hooks change data", async () => {
32
- let deleteCalled = false;
33
- app.use(
34
- "/food",
35
- modelRouter(FoodModel, {
36
- allowAnonymous: true,
37
- permissions: {
38
- create: [Permissions.IsAny],
39
- delete: [Permissions.IsAny],
40
- list: [Permissions.IsAny],
41
- read: [Permissions.IsAny],
42
- update: [Permissions.IsAny],
43
- },
44
- preCreate: (data: any) => {
45
- data.calories = 14;
46
- return data;
47
- },
48
- preDelete: (data: any) => {
49
- deleteCalled = true;
50
- return data;
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",
51
42
  },
52
- preUpdate: (data: any) => {
53
- data.calories = 15;
54
- return data;
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",
55
52
  },
56
- })
57
- );
58
- server = supertest(app);
59
-
60
- let res = await server
61
- .post("/food")
62
- .send({
63
- calories: 15,
64
- name: "Broccoli",
65
- })
66
- .expect(201);
67
- const broccoli = await FoodModel.findById(res.body.data._id);
68
- if (!broccoli) {
69
- throw new Error("Broccoli was not created");
70
- }
71
- expect(broccoli.name).toBe("Broccoli");
72
- // Overwritten by the pre create hook
73
- expect(broccoli.calories).toBe(14);
74
-
75
- res = await server
76
- .patch(`/food/${broccoli._id}`)
77
- .send({
78
- name: "Broccoli2",
79
- })
80
- .expect(200);
81
- expect(res.body.data.name).toBe("Broccoli2");
82
- // Updated by the pre update hook
83
- expect(res.body.data.calories).toBe(15);
84
-
85
- await agent.delete(`/food/${broccoli._id}`).expect(204);
86
- expect(deleteCalled).toBe(true);
87
- });
88
-
89
- it("pre hooks return null", async () => {
90
- const notAdmin = await UserModel.findOne({
91
- email: "notAdmin@example.com",
92
- });
93
- const spinach = await FoodModel.create({
94
- calories: 1,
95
- created: new Date("2021-12-03T00:00:20.000Z"),
96
- hidden: false,
97
- name: "Spinach",
98
- ownerId: (notAdmin as any)._id,
99
- source: {
100
- name: "Brand",
101
- },
102
- });
103
-
53
+ }),
54
+ ]);
55
+ app = getBaseServer();
56
+ setupAuth(app, UserModel as any);
57
+ addAuthRoutes(app, UserModel as any);
104
58
  app.use(
105
59
  "/food",
106
60
  modelRouter(FoodModel, {
@@ -112,312 +66,69 @@ describe("@terreno/api", () => {
112
66
  read: [Permissions.IsAny],
113
67
  update: [Permissions.IsAny],
114
68
  },
115
- preCreate: () => null,
116
- preDelete: () => null,
117
- preUpdate: () => null,
69
+ populatePaths: [{fields: ["email"], path: "ownerId"}],
70
+ sort: "-created",
118
71
  })
119
72
  );
120
73
  server = supertest(app);
121
-
122
- const res = await server
123
- .post("/food")
124
- .send({
125
- calories: 15,
126
- name: "Broccoli",
127
- })
128
- .expect(403);
129
- const broccoli = await FoodModel.findById(res.body._id);
130
- expect(broccoli).toBeNull();
131
-
132
- await server
133
- .patch(`/food/${spinach._id}`)
134
- .send({
135
- name: "Broccoli",
136
- })
137
- .expect(403);
138
- await server.delete(`/food/${spinach._id}`).expect(403);
74
+ agent = await authAsUser(app, "notAdmin");
139
75
  });
140
76
 
141
- it("post hooks succeed", async () => {
142
- let deleteCalled = false;
143
- app.use(
144
- "/food",
145
- modelRouter(FoodModel as any, {
146
- allowAnonymous: true,
147
- permissions: {
148
- create: [Permissions.IsAny],
149
- delete: [Permissions.IsAny],
150
- list: [Permissions.IsAny],
151
- read: [Permissions.IsAny],
152
- update: [Permissions.IsAny],
153
- },
154
- postCreate: async (data: any) => {
155
- data.calories = 14;
156
- await data.save();
157
- return data;
158
- },
159
- postDelete: (data: any) => {
160
- deleteCalled = true;
161
- return data;
162
- },
163
- postUpdate: async (data: any) => {
164
- data.calories = 15;
165
- await data.save();
166
- return data;
167
- },
168
- })
169
- );
170
- server = supertest(app);
171
-
172
- let res = await server
173
- .post("/food")
174
- .send({
175
- calories: 15,
176
- name: "Broccoli",
177
- })
178
- .expect(201);
179
- let broccoli = await FoodModel.findById(res.body.data._id);
180
- if (!broccoli) {
181
- throw new Error("Broccoli was not created");
182
- }
183
- expect(broccoli.name).toBe("Broccoli");
184
- // Overwritten by the pre create hook
185
- expect(broccoli.calories).toBe(14);
186
-
187
- res = await server
188
- .patch(`/food/${broccoli._id}`)
189
- .send({
190
- name: "Broccoli2",
191
- })
192
- .expect(200);
193
- broccoli = await FoodModel.findById(res.body.data._id);
194
- if (!broccoli) {
195
- throw new Error("Broccoli was not update");
196
- }
197
- expect(broccoli.name).toBe("Broccoli2");
198
- // Updated by the post update hook
199
- expect(broccoli.calories).toBe(15);
200
-
201
- await agent.delete(`/food/${broccoli._id}`).expect(204);
202
- expect(deleteCalled).toBe(true);
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();
203
87
  });
204
88
 
205
- it("preCreate hook preserves disableExternalErrorTracking on APIError", async () => {
206
- app.use(
207
- "/food",
208
- modelRouter(FoodModel, {
209
- allowAnonymous: true,
210
- permissions: {
211
- create: [Permissions.IsAny],
212
- delete: [Permissions.IsAny],
213
- list: [Permissions.IsAny],
214
- read: [Permissions.IsAny],
215
- update: [Permissions.IsAny],
216
- },
217
- preCreate: () => {
218
- throw new APIError({
219
- disableExternalErrorTracking: true,
220
- status: 400,
221
- title: "Custom preCreate error",
222
- });
223
- },
224
- })
225
- );
226
- server = supertest(app);
227
-
228
- const res = await server
229
- .post("/food")
230
- .send({
231
- calories: 15,
232
- name: "Broccoli",
233
- })
234
- .expect(400);
235
-
236
- expect(res.body.title).toBe("Custom preCreate error");
237
- expect(res.body.disableExternalErrorTracking).toBe(true);
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();
238
94
  });
239
95
 
240
- it("preCreate hook preserves disableExternalErrorTracking on non-APIError", async () => {
241
- app.use(
242
- "/food",
243
- modelRouter(FoodModel, {
244
- allowAnonymous: true,
245
- permissions: {
246
- create: [Permissions.IsAny],
247
- delete: [Permissions.IsAny],
248
- list: [Permissions.IsAny],
249
- read: [Permissions.IsAny],
250
- update: [Permissions.IsAny],
251
- },
252
- preCreate: () => {
253
- const error: any = new Error("Some custom error");
254
- error.disableExternalErrorTracking = true;
255
- throw error;
256
- },
257
- })
258
- );
259
- server = supertest(app);
260
-
96
+ it("creates with populate", async () => {
261
97
  const res = await server
262
98
  .post("/food")
263
99
  .send({
264
100
  calories: 15,
265
101
  name: "Broccoli",
102
+ ownerId: admin._id,
266
103
  })
267
- .expect(400);
268
-
269
- expect(res.body.title).toContain("preCreate hook error");
270
- expect(res.body.disableExternalErrorTracking).toBe(true);
104
+ .expect(201);
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();
271
108
  });
272
109
 
273
- it("preUpdate hook preserves disableExternalErrorTracking on APIError", async () => {
274
- const notAdmin = await UserModel.findOne({
275
- email: "notAdmin@example.com",
276
- });
277
- const spinach = await FoodModel.create({
278
- calories: 1,
279
- created: new Date("2021-12-03T00:00:20.000Z"),
280
- hidden: false,
281
- name: "Spinach",
282
- ownerId: (notAdmin as any)._id,
283
- source: {
284
- name: "Brand",
285
- },
286
- });
287
-
288
- app.use(
289
- "/food",
290
- modelRouter(FoodModel, {
291
- allowAnonymous: true,
292
- permissions: {
293
- create: [Permissions.IsAny],
294
- delete: [Permissions.IsAny],
295
- list: [Permissions.IsAny],
296
- read: [Permissions.IsAny],
297
- update: [Permissions.IsAny],
298
- },
299
- preUpdate: () => {
300
- throw new APIError({
301
- disableExternalErrorTracking: true,
302
- status: 400,
303
- title: "Custom preUpdate error",
304
- });
305
- },
306
- })
307
- );
308
- server = supertest(app);
309
-
110
+ it("updates with populate", async () => {
310
111
  const res = await server
311
112
  .patch(`/food/${spinach._id}`)
312
113
  .send({
313
- name: "Broccoli",
114
+ name: "NotSpinach",
314
115
  })
315
- .expect(400);
316
-
317
- expect(res.body.title).toBe("Custom preUpdate error");
318
- expect(res.body.disableExternalErrorTracking).toBe(true);
116
+ .expect(200);
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();
319
120
  });
121
+ });
320
122
 
321
- it("preUpdate hook preserves disableExternalErrorTracking on non-APIError", async () => {
322
- const notAdmin = await UserModel.findOne({
323
- email: "notAdmin@example.com",
324
- });
325
- const spinach = await FoodModel.create({
326
- calories: 1,
327
- created: new Date("2021-12-03T00:00:20.000Z"),
328
- hidden: false,
329
- name: "Spinach",
330
- ownerId: (notAdmin as any)._id,
331
- source: {
332
- name: "Brand",
333
- },
334
- });
123
+ describe("responseHandler", () => {
124
+ let admin: any;
125
+ let agent: TestAgent;
126
+ let spinach: Food;
335
127
 
336
- app.use(
337
- "/food",
338
- modelRouter(FoodModel, {
339
- allowAnonymous: true,
340
- permissions: {
341
- create: [Permissions.IsAny],
342
- delete: [Permissions.IsAny],
343
- list: [Permissions.IsAny],
344
- read: [Permissions.IsAny],
345
- update: [Permissions.IsAny],
346
- },
347
- preUpdate: () => {
348
- const error: any = new Error("Some custom error");
349
- error.disableExternalErrorTracking = true;
350
- throw error;
351
- },
352
- })
353
- );
354
- server = supertest(app);
128
+ beforeEach(async () => {
129
+ [admin] = await setupDb();
355
130
 
356
- const res = await server
357
- .patch(`/food/${spinach._id}`)
358
- .send({
359
- name: "Broccoli",
360
- })
361
- .expect(400);
362
-
363
- expect(res.body.title).toContain("preUpdate hook error");
364
- expect(res.body.disableExternalErrorTracking).toBe(true);
365
- });
366
-
367
- it("preDelete hook preserves disableExternalErrorTracking on non-APIError", async () => {
368
- const notAdmin = await UserModel.findOne({
369
- email: "notAdmin@example.com",
370
- });
371
- const spinach = await FoodModel.create({
372
- calories: 1,
373
- created: new Date("2021-12-03T00:00:20.000Z"),
374
- hidden: false,
375
- name: "Spinach",
376
- ownerId: (notAdmin as any)._id,
377
- source: {
378
- name: "Brand",
379
- },
380
- });
381
-
382
- app.use(
383
- "/food",
384
- modelRouter(FoodModel, {
385
- allowAnonymous: true,
386
- permissions: {
387
- create: [Permissions.IsAny],
388
- delete: [Permissions.IsAny],
389
- list: [Permissions.IsAny],
390
- read: [Permissions.IsAny],
391
- update: [Permissions.IsAny],
392
- },
393
- preDelete: () => {
394
- const error: any = new Error("Some custom error");
395
- error.disableExternalErrorTracking = true;
396
- throw error;
397
- },
398
- })
399
- );
400
- server = supertest(app);
401
-
402
- const res = await agent.delete(`/food/${spinach._id}`).expect(403);
403
-
404
- expect(res.body.title).toContain("preDelete hook error");
405
- expect(res.body.disableExternalErrorTracking).toBe(true);
406
- });
407
- });
408
-
409
- describe("model array operations", () => {
410
- let admin: any;
411
- let spinach: Food;
412
- let apple: Food;
413
- let agent: TestAgent;
414
-
415
- beforeEach(async () => {
416
- process.env.REFRESH_TOKEN_SECRET = "testsecret1234";
417
-
418
- [admin] = await setupDb();
419
-
420
- [spinach, apple] = await Promise.all([
131
+ [spinach] = await Promise.all([
421
132
  FoodModel.create({
422
133
  calories: 1,
423
134
  created: new Date("2021-12-03T00:00:20.000Z"),
@@ -430,1478 +141,15 @@ describe("@terreno/api", () => {
430
141
  }),
431
142
  FoodModel.create({
432
143
  calories: 100,
433
- categories: [
434
- {
435
- name: "Fruit",
436
- show: true,
437
- },
438
- {
439
- name: "Popular",
440
- show: false,
441
- },
442
- ],
443
- created: new Date("2021-12-03T00:00:30.000Z"),
444
- hidden: false,
445
- name: "Apple",
446
- ownerId: admin._id,
447
- tags: ["healthy", "cheap"],
448
- }),
449
- ]);
450
-
451
- app = getBaseServer();
452
- setupAuth(app, UserModel as any);
453
- addAuthRoutes(app, UserModel as any);
454
- app.use(
455
- "/food",
456
- modelRouter(FoodModel, {
457
- allowAnonymous: true,
458
- permissions: {
459
- create: [Permissions.IsAdmin],
460
- delete: [Permissions.IsAdmin],
461
- list: [Permissions.IsAdmin],
462
- read: [Permissions.IsAdmin],
463
- update: [Permissions.IsAdmin],
464
- },
465
- queryFields: ["hidden", "calories", "created", "source.name"],
466
- sort: {created: "descending"},
467
- })
468
- );
469
- server = supertest(app);
470
- agent = await authAsUser(app, "admin");
471
- });
472
-
473
- it("add array sub-schema item", async () => {
474
- // Incorrect way, should have "categories" as a top level key.
475
- let res = await agent
476
- .post(`/food/${apple._id}/categories`)
477
- .send({name: "Good Seller", show: false})
478
- .expect(400);
479
- expect(res.body.title).toBe(
480
- "Malformed body, array operations should have a single, top level key, got: name,show"
481
- );
482
-
483
- res = await agent
484
- .post(`/food/${apple._id}/categories`)
485
- .send({categories: {name: "Good Seller", show: false}})
486
- .expect(200);
487
- expect(res.body.data.categories).toHaveLength(3);
488
- expect(res.body.data.categories[2].name).toBe("Good Seller");
489
-
490
- res = await agent
491
- .post(`/food/${spinach._id}/categories`)
492
- .send({categories: {name: "Good Seller", show: false}})
493
- .expect(200);
494
- expect(res.body.data.categories).toHaveLength(1);
495
- });
496
-
497
- it("update array sub-schema item", async () => {
498
- let res = await agent
499
- .patch(`/food/${apple._id}/categories/xyz`)
500
- .send({categories: {name: "Good Seller", show: false}})
501
- .expect(404);
502
- expect(res.body.title).toBe("Could not find categories/xyz");
503
- res = await agent
504
- .patch(`/food/${apple._id}/categories/${apple.categories[1]._id}`)
505
- .send({categories: {name: "Good Seller", show: false}})
506
- .expect(200);
507
- expect(res.body.data.categories).toHaveLength(2);
508
- expect(res.body.data.categories[1].name).toBe("Good Seller");
509
- });
510
-
511
- it("delete array sub-schema item", async () => {
512
- let res = await agent.delete(`/food/${apple._id}/categories/xyz`).expect(404);
513
- expect(res.body.title).toBe("Could not find categories/xyz");
514
- res = await agent
515
- .delete(`/food/${apple._id}/categories/${apple.categories[0]._id}`)
516
- .expect(200);
517
- expect(res.body.data.categories).toHaveLength(1);
518
- expect(res.body.data.categories[0].name).toBe("Popular");
519
- });
520
-
521
- it("add array item", async () => {
522
- let res = await agent.post(`/food/${apple._id}/tags`).send({tags: "popular"}).expect(200);
523
- expect(res.body.data.tags).toHaveLength(3);
524
- expect(res.body.data.tags).toEqual(["healthy", "cheap", "popular"]);
525
-
526
- res = await agent.post(`/food/${spinach._id}/tags`).send({tags: "popular"}).expect(200);
527
- expect(res.body.data.tags).toEqual(["popular"]);
528
- });
529
-
530
- it("update array item", async () => {
531
- let res = await agent
532
- .patch(`/food/${apple._id}/tags/xyz`)
533
- .send({tags: "unhealthy"})
534
- .expect(404);
535
- expect(res.body.title).toBe("Could not find tags/xyz");
536
- res = await agent
537
- .patch(`/food/${apple._id}/tags/healthy`)
538
- .send({tags: "unhealthy"})
539
- .expect(200);
540
- expect(res.body.data.tags).toEqual(["unhealthy", "cheap"]);
541
- });
542
-
543
- it("delete array item", async () => {
544
- let res = await agent.delete(`/food/${apple._id}/tags/xyz`).expect(404);
545
- expect(res.body.title).toBe("Could not find tags/xyz");
546
- res = await agent.delete(`/food/${apple._id}/tags/healthy`).expect(200);
547
- expect(res.body.data.tags).toEqual(["cheap"]);
548
- });
549
-
550
- it("updates timestamps on array subdocuments", async () => {
551
- // Create a food with categories that have timestamps
552
- const foodWithTimestamps = await FoodModel.create({
553
- calories: 100,
554
- categories: [
555
- {
556
- name: "Category 1",
557
- show: true,
558
- updated: new Date("2024-01-01T00:00:00.000Z"),
559
- },
560
- {
561
- name: "Category 2",
562
- show: true,
563
- updated: new Date("2024-01-01T00:00:00.000Z"),
564
- },
565
- ],
566
- created: new Date(),
567
- name: "Food with Timestamps",
568
- ownerId: admin._id,
569
- });
570
-
571
- const firstCategoryId = foodWithTimestamps.categories?.[0]?._id?.toString();
572
- const secondCategoryId = foodWithTimestamps.categories?.[1]?._id?.toString();
573
-
574
- if (!firstCategoryId || !secondCategoryId) {
575
- throw new Error("Failed to create food with categories");
576
- }
577
-
578
- // Wait a moment to ensure timestamp difference
579
- await new Promise((resolve) => setTimeout(resolve, 100));
580
-
581
- // Update one of the categories
582
- const res = await agent
583
- .patch(`/food/${foodWithTimestamps._id}/categories/${firstCategoryId}`)
584
- .send({categories: {name: "Updated Category"}})
585
- .expect(200);
586
-
587
- // Verify the updated category has a newer timestamp
588
- const updatedCategory = res.body.data.categories.find((c: any) => c._id === firstCategoryId);
589
- const unchangedCategory = res.body.data.categories.find(
590
- (c: any) => c._id === secondCategoryId
591
- );
592
-
593
- if (!updatedCategory || !unchangedCategory) {
594
- throw new Error("Failed to find categories in response");
595
- }
596
-
597
- expect(updatedCategory.updated).not.toBe(updatedCategory.created);
598
- expect(unchangedCategory.updated).toBe(unchangedCategory.created);
599
- expect(updatedCategory.name).toBe("Updated Category");
600
- // Unchanged.
601
- expect(updatedCategory.show).toBe(true);
602
- expect(unchangedCategory.show).toBe(true);
603
- });
604
-
605
- it("array operations call postUpdate with different copy of document", async () => {
606
- let postUpdateDoc: any;
607
- let postUpdatePrevDoc: any;
608
- let postUpdateCalled = false;
609
-
610
- app = getBaseServer();
611
- setupAuth(app, UserModel as any);
612
- addAuthRoutes(app, UserModel as any);
613
- app.use(
614
- "/food",
615
- modelRouter(FoodModel, {
616
- allowAnonymous: true,
617
- permissions: {
618
- create: [Permissions.IsAdmin],
619
- delete: [Permissions.IsAdmin],
620
- list: [Permissions.IsAdmin],
621
- read: [Permissions.IsAdmin],
622
- update: [Permissions.IsAdmin],
623
- },
624
- postUpdate: async (doc: any, _cleanedBody: any, _request: any, prevValue: any) => {
625
- postUpdateDoc = doc;
626
- postUpdatePrevDoc = prevValue;
627
- postUpdateCalled = true;
628
- },
629
- })
630
- );
631
- server = supertest(app);
632
- agent = await authAsUser(app, "admin");
633
-
634
- // Test POST operation (add to array)
635
- await agent
636
- .post(`/food/${apple._id}/categories`)
637
- .send({categories: {name: "New Category", show: true}})
638
- .expect(200);
639
-
640
- expect(postUpdateCalled).toBe(true);
641
- expect(postUpdateDoc).toBeDefined();
642
- expect(postUpdatePrevDoc).toBeDefined();
643
-
644
- // Verify they are different object references
645
- expect(postUpdateDoc).not.toBe(postUpdatePrevDoc);
646
-
647
- // Verify the content is different (new category added)
648
- expect(postUpdateDoc.categories).toHaveLength(3);
649
- expect(postUpdatePrevDoc.categories).toHaveLength(2);
650
-
651
- // Reset for next test
652
- postUpdateCalled = false;
653
- postUpdateDoc = undefined;
654
- postUpdatePrevDoc = undefined;
655
-
656
- // Test PATCH operation (update array item)
657
- const categoryId = apple.categories[0]._id;
658
- if (!categoryId) {
659
- throw new Error("Category ID is undefined");
660
- }
661
- await agent
662
- .patch(`/food/${apple._id}/categories/${categoryId}`)
663
- .send({categories: {name: "Updated Category", show: false}})
664
- .expect(200);
665
-
666
- expect(postUpdateCalled).toBe(true);
667
- expect(postUpdateDoc).toBeDefined();
668
- expect(postUpdatePrevDoc).toBeDefined();
669
-
670
- // Verify they are different object references
671
- expect(postUpdateDoc).not.toBe(postUpdatePrevDoc);
672
-
673
- // Verify the content is different (category updated)
674
- const updatedCategory = postUpdateDoc.categories.find(
675
- (c: any) => c._id.toString() === categoryId.toString()
676
- );
677
- const prevCategory = postUpdatePrevDoc.categories.find(
678
- (c: any) => c._id.toString() === categoryId.toString()
679
- );
680
-
681
- expect(updatedCategory.name).toBe("Updated Category");
682
- expect(prevCategory.name).toBe("Fruit");
683
-
684
- // Reset for next test
685
- postUpdateCalled = false;
686
- postUpdateDoc = undefined;
687
- postUpdatePrevDoc = undefined;
688
-
689
- // Test DELETE operation (remove from array)
690
- await agent.delete(`/food/${apple._id}/categories/${categoryId}`).expect(200);
691
-
692
- expect(postUpdateCalled).toBe(true);
693
- expect(postUpdateDoc).toBeDefined();
694
- expect(postUpdatePrevDoc).toBeDefined();
695
-
696
- // Verify they are different object references
697
- expect(postUpdateDoc).not.toBe(postUpdatePrevDoc);
698
-
699
- // Verify the content is different (category removed)
700
- const remainingCategories = postUpdateDoc.categories.filter(
701
- (c: any) => c._id.toString() === categoryId.toString()
702
- );
703
- const prevCategories = postUpdatePrevDoc.categories.filter(
704
- (c: any) => c._id.toString() === categoryId.toString()
705
- );
706
-
707
- expect(remainingCategories).toHaveLength(0);
708
- expect(prevCategories).toHaveLength(1);
709
- });
710
-
711
- it("array operations with string arrays call postUpdate with different copy", async () => {
712
- let postUpdateDoc: any;
713
- let postUpdatePrevDoc: any;
714
- let postUpdateCalled = false;
715
-
716
- app = getBaseServer();
717
- setupAuth(app, UserModel as any);
718
- addAuthRoutes(app, UserModel as any);
719
- app.use(
720
- "/food",
721
- modelRouter(FoodModel, {
722
- allowAnonymous: true,
723
- permissions: {
724
- create: [Permissions.IsAdmin],
725
- delete: [Permissions.IsAdmin],
726
- list: [Permissions.IsAdmin],
727
- read: [Permissions.IsAdmin],
728
- update: [Permissions.IsAdmin],
729
- },
730
- postUpdate: async (doc: any, _cleanedBody: any, _request: any, prevValue: any) => {
731
- postUpdateDoc = doc;
732
- postUpdatePrevDoc = prevValue;
733
- postUpdateCalled = true;
734
- },
735
- })
736
- );
737
- server = supertest(app);
738
- agent = await authAsUser(app, "admin");
739
-
740
- // Test POST operation with string array (add tag)
741
- await agent.post(`/food/${apple._id}/tags`).send({tags: "organic"}).expect(200);
742
-
743
- expect(postUpdateCalled).toBe(true);
744
- expect(postUpdateDoc).toBeDefined();
745
- expect(postUpdatePrevDoc).toBeDefined();
746
-
747
- // Verify they are different object references
748
- expect(postUpdateDoc).not.toBe(postUpdatePrevDoc);
749
-
750
- // Verify the content is different (new tag added)
751
- expect(postUpdateDoc.tags).toHaveLength(3);
752
- expect(postUpdatePrevDoc.tags).toHaveLength(2);
753
- expect(postUpdateDoc.tags).toContain("organic");
754
- expect(postUpdatePrevDoc.tags).not.toContain("organic");
755
-
756
- // Reset for next test
757
- postUpdateCalled = false;
758
- postUpdateDoc = undefined;
759
- postUpdatePrevDoc = undefined;
760
-
761
- // Test PATCH operation with string array (update tag)
762
- await agent
763
- .patch(`/food/${apple._id}/tags/healthy`)
764
- .send({tags: "super-healthy"})
765
- .expect(200);
766
-
767
- expect(postUpdateCalled).toBe(true);
768
- expect(postUpdateDoc).not.toBe(postUpdatePrevDoc);
769
-
770
- // Verify the content is different (tag updated)
771
- expect(postUpdateDoc.tags).toContain("super-healthy");
772
- expect(postUpdatePrevDoc.tags).toContain("healthy");
773
- expect(postUpdateDoc.tags).not.toContain("healthy");
774
- expect(postUpdatePrevDoc.tags).not.toContain("super-healthy");
775
- });
776
- });
777
-
778
- describe("standard methods", () => {
779
- let notAdmin: any;
780
- let admin: any;
781
- let adminOther: any;
782
- let agent: TestAgent;
783
-
784
- let spinach: Food;
785
- let apple: Food;
786
- let carrots: Food;
787
- let pizza: Food;
788
-
789
- beforeEach(async () => {
790
- [admin, notAdmin, adminOther] = await setupDb();
791
-
792
- const results = (await Promise.all([
793
- FoodModel.create({
794
- calories: 1,
795
- created: new Date("2021-12-03T00:00:20.000Z"),
796
- eatenBy: [admin._id],
797
- hidden: false,
798
- lastEatenWith: {
799
- dressing: new Date("2021-12-03T19:00:30.000Z"),
800
- },
801
- name: "Spinach",
802
- ownerId: notAdmin._id,
803
- source: {
804
- dateAdded: "2023-12-13T12:30:00.000Z",
805
- href: "https://www.google.com",
806
- name: "Brand",
807
- },
808
- }),
809
- FoodModel.create({
810
- calories: 100,
811
- created: new Date("2021-12-03T00:00:30.000Z"),
144
+ created: Date.now() - 10,
812
145
  hidden: true,
813
146
  name: "Apple",
814
- ownerId: admin._id,
815
- tags: ["healthy"],
816
- }),
817
- FoodModel.create({
818
- calories: 100,
819
- created: new Date("2021-12-03T00:00:00.000Z"),
820
- eatenBy: [admin._id, notAdmin._id],
821
- hidden: false,
822
- name: "Carrots",
823
- ownerId: admin._id,
824
- source: {
825
- name: "USDA",
826
- },
827
- tags: ["healthy", "cheap"],
828
- }),
829
- FoodModel.create({
830
- calories: 400,
831
- created: new Date("2021-12-03T00:00:10.000Z"),
832
- eatenBy: [adminOther._id],
833
- hidden: false,
834
- name: "Pizza",
835
- ownerId: admin._id,
836
- tags: ["cheap"],
147
+ ownerId: admin?._id,
837
148
  }),
838
- ])) as [Food, Food, Food, Food];
839
- [spinach, apple, carrots, pizza] = results;
149
+ ]);
840
150
  app = getBaseServer();
841
151
  setupAuth(app, UserModel as any);
842
- addAuthRoutes(app, UserModel as any);
843
- app.use(logRequests);
844
- app.use(
845
- "/food",
846
- modelRouter(FoodModel, {
847
- allowAnonymous: true,
848
- defaultLimit: 2,
849
- defaultQueryParams: {hidden: false},
850
- maxLimit: 3,
851
- permissions: {
852
- create: [Permissions.IsAuthenticated],
853
- delete: [Permissions.IsAdmin],
854
- list: [Permissions.IsAny],
855
- read: [Permissions.IsAny],
856
- update: [Permissions.IsOwner],
857
- },
858
- populatePaths: [{path: "ownerId"}],
859
- queryFields: ["hidden", "name", "calories", "created", "source.name", "tags", "eatenBy"],
860
- sort: {created: "descending"},
861
- })
862
- );
863
- server = supertest(app);
864
- agent = await authAsUser(app, "notAdmin");
865
- });
866
-
867
- it("read default", async () => {
868
- const res = await agent.get(`/food/${spinach._id}`).expect(200);
869
- expect(res.body.data._id).toBe(spinach._id.toString());
870
- // Ensure populate works
871
- expect(res.body.data.ownerId._id).toBe(notAdmin.id);
872
- // Ensure maps are properly transformed
873
- expect(res.body.data.lastEatenWith).toEqual({
874
- dressing: "2021-12-03T19:00:30.000Z",
875
- });
876
- });
877
-
878
- it("list default", async () => {
879
- const res = await agent.get("/food").expect(200);
880
- expect(res.body.data).toHaveLength(2);
881
- expect(res.body.data[0].id).toBe((spinach as any).id);
882
- expect(res.body.data[0].ownerId._id).toBe(notAdmin.id);
883
- expect(res.body.data[1].id).toBe((pizza as any).id);
884
- expect(res.body.data[1].ownerId._id).toBe(admin.id);
885
- // Check that mongoose Map is handled correctly.
886
- expect(res.body.data[0].lastEatenWith).toEqual({
887
- dressing: "2021-12-03T19:00:30.000Z",
888
- });
889
- expect(res.body.data[1].lastEatenWith).toEqual(undefined);
890
-
891
- expect(res.body.more).toBe(true);
892
- expect(res.body.total).toBe(3);
893
- });
894
-
895
- it("list limit", async () => {
896
- const res = await agent.get("/food?limit=1").expect(200);
897
- expect(res.body.data).toHaveLength(1);
898
- expect(res.body.data[0].id).toBe((spinach as any).id);
899
- expect(res.body.data[0].ownerId._id).toBe(notAdmin.id);
900
- expect(res.body.more).toBe(true);
901
- expect(res.body.total).toBe(3);
902
- });
903
-
904
- it("list limit over", async () => {
905
- // This shouldn't be seen, it's the end of the list.
906
- await FoodModel.create({
907
- calories: 400,
908
- created: new Date("2021-12-02T00:00:10.000Z"),
909
- hidden: false,
910
- name: "Pizza",
911
- ownerId: admin._id,
912
- });
913
- const res = await agent.get("/food?limit=4").expect(200);
914
- expect(res.body.data).toHaveLength(3);
915
- expect(res.body.more).toBe(true);
916
- expect(res.body.total).toBe(4);
917
- expect(res.body.data[0].id).toBe((spinach as any).id);
918
- expect(res.body.data[1].id).toBe((pizza as any).id);
919
- expect(res.body.data[2].id).toBe((carrots as any).id);
920
-
921
- expect(Sentry.captureMessage).toHaveBeenCalledWith(
922
- 'More than 3 results returned for foods without pagination, data may be silently truncated. req.query: {"limit":"4"}'
923
- );
924
- });
925
-
926
- it("list page", async () => {
927
- // Should skip to carrots since apples are hidden
928
- const res = await agent.get("/food?limit=1&page=2").expect(200);
929
- expect(res.body.data).toHaveLength(1);
930
- expect(res.body.more).toBe(true);
931
- expect(res.body.total).toBe(3);
932
- expect(res.body.data[0].id).toBe((pizza as any).id);
933
- });
934
-
935
- it("list page 0 ", async () => {
936
- const res = await agent.get("/food?limit=1&page=0").expect(400);
937
- expect(res.body.title).toBe("Invalid page: 0");
938
- });
939
-
940
- it("list page with garbage ", async () => {
941
- const res = await agent.get("/food?limit=1&page=abc").expect(400);
942
- expect(res.body.title).toBe("Invalid page: abc");
943
- });
944
-
945
- it("list page over", async () => {
946
- // Should skip to carrots since apples are hidden
947
- const res = await agent.get("/food?limit=1&page=5").expect(200);
948
- expect(res.body.data).toHaveLength(0);
949
- expect(res.body.more).toBe(false);
950
- expect(res.body.total).toBe(3);
951
- });
952
-
953
- it("list query params", async () => {
954
- // Should skip to carrots since apples are hidden
955
- const res = await agent.get("/food?hidden=true").expect(200);
956
- expect(res.body.data).toHaveLength(1);
957
- expect(res.body.more).toBe(false);
958
- expect(res.body.total).toBe(1);
959
- expect(res.body.data[0].id).toBe((apple as any).id);
960
- });
961
-
962
- it("list query params not in list", async () => {
963
- // Should skip to carrots since apples are hidden
964
- const res = await agent.get(`/food?ownerId=${admin._id}`).expect(400);
965
- expect(res.body.title).toBe("ownerId is not allowed as a query param.");
966
- });
967
-
968
- it("list query by nested param", async () => {
969
- // Should skip to carrots since apples are hidden
970
- const res = await agent.get("/food?source.name=USDA").expect(200);
971
- expect(res.body.data).toHaveLength(1);
972
- expect(res.body.total).toBe(1);
973
- expect(res.body.data[0].id).toBe((carrots as any).id);
974
- });
975
-
976
- it("query by date", async () => {
977
- const authRes = await server
978
- .post("/auth/login")
979
- .send({email: "admin@example.com", password: "securePassword"})
980
- .expect(200);
981
- const token = authRes.body.data.token;
982
-
983
- // Inclusive
984
- let res = await server
985
- .get(
986
- `/food?limit=3&${qs.stringify({
987
- created: {
988
- $gte: "2021-12-03T00:00:00.000Z",
989
- $lte: "2021-12-03T00:00:20.000Z",
990
- },
991
- })}`
992
- )
993
- .set("authorization", `Bearer ${token}`)
994
- .expect(200);
995
- expect(res.body.data.map((d: any) => d.created)).toEqual(
996
- expect.arrayContaining([
997
- "2021-12-03T00:00:20.000Z",
998
- "2021-12-03T00:00:10.000Z",
999
- "2021-12-03T00:00:00.000Z",
1000
- ])
1001
- );
1002
- expect(res.body.data.map((d: any) => d.created)).toHaveLength(3);
1003
-
1004
- // Inclusive one side
1005
- res = await server
1006
- .get(
1007
- `/food?limit=3&${qs.stringify({
1008
- created: {
1009
- $gte: "2021-12-03T00:00:00.000Z",
1010
- $lt: "2021-12-03T00:00:20.000Z",
1011
- },
1012
- })}`
1013
- )
1014
- .set("authorization", `Bearer ${token}`)
1015
- .expect(200);
1016
- expect(res.body.data.map((d: any) => d.created)).toEqual(
1017
- expect.arrayContaining(["2021-12-03T00:00:10.000Z", "2021-12-03T00:00:00.000Z"])
1018
- );
1019
- expect(res.body.data.map((d: any) => d.created)).toHaveLength(2);
1020
-
1021
- // Inclusive both sides
1022
- res = await server
1023
- .get(
1024
- `/food?limit=3&${qs.stringify({
1025
- created: {
1026
- $gt: "2021-12-03T00:00:00.000Z",
1027
- $lt: "2021-12-03T00:00:20.000Z",
1028
- },
1029
- })}`
1030
- )
1031
- .set("authorization", `Bearer ${token}`)
1032
- .expect(200);
1033
- const createdDates = res.body.data.map((d: any) => d.created);
1034
- expect(createdDates).toEqual(expect.arrayContaining(["2021-12-03T00:00:10.000Z"]));
1035
- expect(createdDates).toHaveLength(1);
1036
- });
1037
-
1038
- it("query with a space", async () => {
1039
- const greenBeans = await FoodModel.create({
1040
- calories: 102,
1041
- created: Date.now() - 10,
1042
- name: "Green Beans",
1043
- ownerId: admin?._id,
1044
- });
1045
- const res = await agent.get(`/food?${qs.stringify({name: "Green Beans"})}`).expect(200);
1046
- expect(res.body.data).toHaveLength(1);
1047
- expect(res.body.data[0].id).toBe(greenBeans?.id);
1048
- expect(res.body.data[0].name).toBe("Green Beans");
1049
- });
1050
-
1051
- it("query with a regex", async () => {
1052
- const greenBeans = await FoodModel.create({
1053
- calories: 102,
1054
- created: Date.now() - 10,
1055
- name: "Green Beans",
1056
- ownerId: admin?._id,
1057
- });
1058
-
1059
- // Case sensitive does match correct casing
1060
- let res = await agent.get(`/food?${qs.stringify({name: {$regex: "Green"}})}`).expect(200);
1061
- expect(res.body.data).toHaveLength(1);
1062
- expect(res.body.data[0].id).toBe(greenBeans?.id);
1063
- expect(res.body.data[0].name).toBe("Green Beans");
1064
-
1065
- // Fails with different casing and sensitive
1066
- res = await agent.get(`/food?${qs.stringify({name: {$regex: "green"}})}`).expect(200);
1067
- expect(res.body.data).toHaveLength(0);
1068
-
1069
- // Case insensitive does match different casing
1070
- res = await agent
1071
- .get(`/food?${qs.stringify({name: {$options: "i", $regex: "green"}})}`)
1072
- .expect(200);
1073
- expect(res.body.data).toHaveLength(1);
1074
- expect(res.body.data[0].id).toBe(greenBeans?.id);
1075
- });
1076
-
1077
- it("query with an $in operator", async () => {
1078
- // Query including a hidden food
1079
- let res = await server
1080
- .get(
1081
- `/food?${qs.stringify({
1082
- name: {
1083
- $in: ["Apple", "Spinach"],
1084
- },
1085
- })}`
1086
- )
1087
- .expect(200);
1088
- const names1 = res.body.data.map((d: any) => d.name);
1089
- expect(names1).toEqual(expect.arrayContaining(["Spinach"]));
1090
- expect(names1).toHaveLength(1);
1091
-
1092
- // Query without hidden food.
1093
- res = await server
1094
- .get(
1095
- `/food?${qs.stringify({
1096
- name: {
1097
- $in: ["Carrots", "Spinach"],
1098
- },
1099
- })}`
1100
- )
1101
- .expect(200);
1102
- const names2 = res.body.data.map((d: any) => d.name);
1103
- expect(names2).toEqual(expect.arrayContaining(["Spinach", "Carrots"]));
1104
- expect(names2).toHaveLength(2);
1105
- });
1106
-
1107
- it("query with an $in for _ids in nested object", async () => {
1108
- // Query including a hidden food
1109
- const res = await server
1110
- .get(
1111
- `/food?${qs.stringify({
1112
- eatenBy: {
1113
- $in: [notAdmin._id.toString(), adminOther._id.toString()],
1114
- },
1115
- })}`
1116
- )
1117
- .expect(200);
1118
- expect(res.body.more).toBe(false);
1119
- expect(res.body.total).toBe(2);
1120
- expect(res.body.data).toHaveLength(2);
1121
- const names3 = res.body.data.map((d: any) => d.name);
1122
- expect(names3).toEqual(expect.arrayContaining(["Carrots", "Pizza"]));
1123
- expect(names3).toHaveLength(2);
1124
- });
1125
-
1126
- it("query $and operator on same field", async () => {
1127
- const res = await agent
1128
- .get(`/food?${qs.stringify({$and: [{tags: "healthy"}, {tags: "cheap"}]})}`)
1129
- .expect(200);
1130
- expect(res.body.data).toHaveLength(1);
1131
- expect(res.body.data[0].id).toBe(carrots?._id.toString());
1132
- });
1133
-
1134
- it("query $and operator on same field, nested objects", async () => {
1135
- const res = await agent
1136
- .get(
1137
- `/food?${qs.stringify({
1138
- $and: [{eatenBy: admin.id}, {eatenBy: notAdmin.id}],
1139
- })}`
1140
- )
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 $or operator on same field", async () => {
1147
- const res = await agent
1148
- .get(`/food?${qs.stringify({$or: [{name: "Carrots"}, {name: "Pizza"}]})}`)
1149
- .expect(200);
1150
- expect(res.body.data).toHaveLength(2);
1151
- // Only carrots matches both
1152
- const ids1 = res.body.data.map((d) => d.id);
1153
- expect(ids1).toEqual(
1154
- expect.arrayContaining([carrots?._id.toString(), pizza?._id.toString()])
1155
- );
1156
- expect(ids1).toHaveLength(2);
1157
- });
1158
-
1159
- it("query $and operator on same field, nested objects", async () => {
1160
- const res = await agent
1161
- .get(
1162
- `/food?${qs.stringify({
1163
- $or: [{eatenBy: admin.id}, {eatenBy: notAdmin.id}],
1164
- limit: 3,
1165
- })}`
1166
- )
1167
- .expect(200);
1168
- expect(res.body.data).toHaveLength(2);
1169
- const ids2 = res.body.data.map((d) => d.id);
1170
- expect(ids2).toEqual(
1171
- expect.arrayContaining([carrots?._id.toString(), spinach?._id.toString()])
1172
- );
1173
- expect(ids2).toHaveLength(2);
1174
- });
1175
-
1176
- it("query $and and $or are rejected if field is not in queryFields", async () => {
1177
- let res = await agent
1178
- .get(`/food?${qs.stringify({$and: [{ownerId: "healthy"}, {tags: "cheap"}]})}`)
1179
- .expect(400);
1180
- expect(res.body.title).toBe("ownerId is not allowed as a query param.");
1181
- // Check in the other order
1182
- res = await agent
1183
- .get(`/food?${qs.stringify({$and: [{tags: "cheap"}, {ownerId: "healthy"}]})}`)
1184
- .expect(400);
1185
- expect(res.body.title).toBe("ownerId is not allowed as a query param.");
1186
-
1187
- res = await agent
1188
- .get(`/food?${qs.stringify({$or: [{tags: "cheap"}, {ownerId: "healthy"}]})}`)
1189
- .expect(400);
1190
- expect(res.body.title).toBe("ownerId is not allowed as a query param.");
1191
- });
1192
-
1193
- it("query with a number", async () => {
1194
- const res = await agent.get("/food?calories=100").expect(200);
1195
- expect(res.body.data).toHaveLength(1);
1196
- expect(res.body.data[0].id).toBe(carrots?._id.toString());
1197
- });
1198
-
1199
- it("update", async () => {
1200
- let res = await agent.patch(`/food/${spinach._id}`).send({name: "Kale"}).expect(200);
1201
- expect(res.body.data.name).toBe("Kale");
1202
- expect(res.body.data.calories).toBe(1);
1203
- expect(res.body.data.hidden).toBe(false);
1204
-
1205
- // Update a Map field.
1206
- res = await agent
1207
- .patch(`/food/${spinach._id}`)
1208
- .send({lastEatenWith: {dressing: "2023-12-03T00:00:20.000Z"}})
1209
- .expect(200);
1210
- expect(res.body.data.name).toBe("Kale");
1211
- expect(res.body.data.calories).toBe(1);
1212
- expect(res.body.data.hidden).toBe(false);
1213
- expect(res.body.data.lastEatenWith).toEqual({
1214
- dressing: "2023-12-03T00:00:20.000Z",
1215
- });
1216
-
1217
- // Update a Map field.
1218
- res = await agent
1219
- .patch(`/food/${spinach._id}`)
1220
- .send({
1221
- lastEatenWith: {
1222
- cucumber: "2023-12-04T12:00:20.000Z",
1223
- dressing: "2023-12-03T00:00:20.000Z",
1224
- },
1225
- })
1226
- .expect(200);
1227
- expect(res.body.data.lastEatenWith).toEqual({
1228
- cucumber: "2023-12-04T12:00:20.000Z",
1229
- dressing: "2023-12-03T00:00:20.000Z",
1230
- });
1231
- });
1232
-
1233
- it("update using dot notation", async () => {
1234
- // Allows updating a single field in a nested object
1235
- const res = await agent
1236
- .patch(`/food/${spinach._id}`)
1237
- .send({"source.href": "https://food.com"})
1238
- .expect(200);
1239
- // Assert the field was updated with dot notation.
1240
- expect(res.body.data.source.href).toBe("https://food.com");
1241
- // Assert these fields haven't changed.
1242
- expect(res.body.data.source.name).toBe("Brand");
1243
- expect(res.body.data.source.dateAdded).toBe("2023-12-13T12:30:00.000Z");
1244
-
1245
- const dbSpinach = await FoodModel.findById(spinach._id);
1246
- expect(dbSpinach?.source.href).toBe("https://food.com");
1247
- expect(dbSpinach?.source.name).toBe("Brand");
1248
- expect(dbSpinach?.source.dateAdded).toBe("2023-12-13T12:30:00.000Z");
1249
- });
1250
- });
1251
-
1252
- describe("populate", () => {
1253
- let admin: any;
1254
- let notAdmin: any;
1255
- let agent: TestAgent;
1256
-
1257
- let spinach: Food;
1258
-
1259
- beforeEach(async () => {
1260
- [admin, notAdmin] = await setupDb();
1261
-
1262
- [spinach] = await Promise.all([
1263
- FoodModel.create({
1264
- calories: 1,
1265
- created: new Date("2021-12-03T00:00:20.000Z"),
1266
- hidden: false,
1267
- name: "Spinach",
1268
- ownerId: admin._id,
1269
- source: {
1270
- name: "Brand",
1271
- },
1272
- }),
1273
- FoodModel.create({
1274
- calories: 1,
1275
- created: new Date("2022-12-03T00:00:20.000Z"),
1276
- hidden: false,
1277
- name: "Carrots",
1278
- ownerId: notAdmin._id,
1279
- source: {
1280
- name: "User",
1281
- },
1282
- }),
1283
- ]);
1284
- app = getBaseServer();
1285
- setupAuth(app, UserModel as any);
1286
- addAuthRoutes(app, UserModel as any);
1287
- app.use(
1288
- "/food",
1289
- modelRouter(FoodModel, {
1290
- allowAnonymous: true,
1291
- permissions: {
1292
- create: [Permissions.IsAny],
1293
- delete: [Permissions.IsAny],
1294
- list: [Permissions.IsAny],
1295
- read: [Permissions.IsAny],
1296
- update: [Permissions.IsAny],
1297
- },
1298
- populatePaths: [{fields: ["email"], path: "ownerId"}],
1299
- sort: "-created",
1300
- })
1301
- );
1302
- server = supertest(app);
1303
- agent = await authAsUser(app, "notAdmin");
1304
- });
1305
-
1306
- it("lists with populate", async () => {
1307
- const res = await agent.get("/food").expect(200);
1308
- expect(res.body.data).toHaveLength(2);
1309
- const [carrots, spin] = res.body.data;
1310
- expect(carrots.ownerId._id).toBe(notAdmin._id.toString());
1311
- expect(carrots.ownerId.email).toBe(notAdmin.email);
1312
- expect(carrots.ownerId.name).toBeUndefined();
1313
- expect(spin.ownerId._id).toBe(admin._id.toString());
1314
- expect(spin.ownerId.email).toBe(admin.email);
1315
- expect(spin.ownerId.name).toBeUndefined();
1316
- });
1317
-
1318
- it("reads with populate", async () => {
1319
- const res = await agent.get(`/food/${spinach._id}`).expect(200);
1320
- expect(res.body.data.ownerId._id).toBe(admin._id.toString());
1321
- expect(res.body.data.ownerId.email).toBe(admin.email);
1322
- expect(res.body.data.ownerId.name).toBeUndefined();
1323
- });
1324
-
1325
- it("creates with populate", async () => {
1326
- const res = await server
1327
- .post("/food")
1328
- .send({
1329
- calories: 15,
1330
- name: "Broccoli",
1331
- ownerId: admin._id,
1332
- })
1333
- .expect(201);
1334
- expect(res.body.data.ownerId._id).toBe(admin._id.toString());
1335
- expect(res.body.data.ownerId.email).toBe(admin.email);
1336
- expect(res.body.data.ownerId.name).toBeUndefined();
1337
- });
1338
-
1339
- it("updates with populate", async () => {
1340
- const res = await server
1341
- .patch(`/food/${spinach._id}`)
1342
- .send({
1343
- name: "NotSpinach",
1344
- })
1345
- .expect(200);
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
-
1352
- describe("responseHandler", () => {
1353
- let admin: any;
1354
- let agent: TestAgent;
1355
-
1356
- let spinach: Food;
1357
-
1358
- beforeEach(async () => {
1359
- [admin] = await setupDb();
1360
-
1361
- [spinach] = await Promise.all([
1362
- FoodModel.create({
1363
- calories: 1,
1364
- created: new Date("2021-12-03T00:00:20.000Z"),
1365
- hidden: false,
1366
- name: "Spinach",
1367
- ownerId: admin._id,
1368
- source: {
1369
- name: "Brand",
1370
- },
1371
- }),
1372
- FoodModel.create({
1373
- calories: 100,
1374
- created: Date.now() - 10,
1375
- hidden: true,
1376
- name: "Apple",
1377
- ownerId: admin?._id,
1378
- }),
1379
- ]);
1380
- app = getBaseServer();
1381
- setupAuth(app, UserModel as any);
1382
- addAuthRoutes(app, UserModel as any);
1383
- app.use(
1384
- "/food",
1385
- modelRouter(FoodModel, {
1386
- allowAnonymous: true,
1387
- permissions: {
1388
- create: [Permissions.IsAny],
1389
- delete: [Permissions.IsAny],
1390
- list: [Permissions.IsAny],
1391
- read: [Permissions.IsAny],
1392
- update: [Permissions.IsAny],
1393
- },
1394
- responseHandler: (data, method) => {
1395
- if (method === "list") {
1396
- return (data as any).map((d: any) => ({
1397
- foo: "bar",
1398
- id: (d as any)._id,
1399
- }));
1400
- }
1401
- return {
1402
- foo: "bar",
1403
- id: (data as any)._id,
1404
- };
1405
- },
1406
- })
1407
- );
1408
- server = supertest(app);
1409
- agent = await authAsUser(app, "notAdmin");
1410
- });
1411
-
1412
- it("reads with serialize", async () => {
1413
- const res = await agent.get(`/food/${spinach._id}`).expect(200);
1414
- expect(res.body.data.ownerId).toBeUndefined();
1415
- expect(res.body.data.id).toBe(spinach._id.toString());
1416
- expect(res.body.data.foo).toBe("bar");
1417
- });
1418
-
1419
- it("list with serialize", async () => {
1420
- const res = await agent.get("/food").expect(200);
1421
- expect(res.body.data[0].ownerId).toBeUndefined();
1422
- expect(res.body.data[1].ownerId).toBeUndefined();
1423
-
1424
- expect(res.body.data[0].id).toBeDefined();
1425
- expect(res.body.data[0].foo).toBe("bar");
1426
- expect(res.body.data[1].id).toBeDefined();
1427
- expect(res.body.data[1].foo).toBe("bar");
1428
- });
1429
- });
1430
-
1431
- describe("plugins", () => {
1432
- let agent: TestAgent;
1433
-
1434
- beforeEach(async () => {
1435
- await setupDb();
1436
- app = getBaseServer();
1437
- setupAuth(app, UserModel as any);
1438
- addAuthRoutes(app, UserModel as any);
1439
- app.use(
1440
- "/users",
1441
- modelRouter(UserModel, {
1442
- allowAnonymous: true,
1443
- permissions: {
1444
- create: [Permissions.IsAny],
1445
- delete: [Permissions.IsAny],
1446
- list: [Permissions.IsAny],
1447
- read: [Permissions.IsAny],
1448
- update: [Permissions.IsAny],
1449
- },
1450
- })
1451
- );
1452
- server = supertest(app);
1453
- agent = await authAsUser(app, "notAdmin");
1454
- });
1455
-
1456
- it("check that security fields are filtered", async () => {
1457
- const res = await agent.get("/users").expect(200);
1458
- expect(res.body.data[0].email).toBeDefined();
1459
- expect(res.body.data[0].token).toBeUndefined();
1460
- expect(res.body.data[0].hash).toBeUndefined();
1461
- expect(res.body.data[0].salt).toBeUndefined();
1462
- });
1463
- });
1464
-
1465
- describe("error handling", () => {
1466
- let admin: any;
1467
- let agent: TestAgent;
1468
- let spinach: Food;
1469
-
1470
- beforeEach(async () => {
1471
- [admin] = await setupDb();
1472
-
1473
- spinach = await FoodModel.create({
1474
- calories: 1,
1475
- created: new Date("2021-12-03T00:00:20.000Z"),
1476
- hidden: false,
1477
- name: "Spinach",
1478
- ownerId: admin._id,
1479
- source: {
1480
- name: "Brand",
1481
- },
1482
- });
1483
-
1484
- app = getBaseServer();
1485
- setupAuth(app, UserModel as any);
1486
- addAuthRoutes(app, UserModel as any);
1487
- });
1488
-
1489
- it("PUT returns 500 not supported", async () => {
1490
- app.use(
1491
- "/food",
1492
- modelRouter(FoodModel, {
1493
- allowAnonymous: true,
1494
- permissions: {
1495
- create: [Permissions.IsAny],
1496
- delete: [Permissions.IsAny],
1497
- list: [Permissions.IsAny],
1498
- read: [Permissions.IsAny],
1499
- update: [Permissions.IsAny],
1500
- },
1501
- })
1502
- );
1503
- server = supertest(app);
1504
-
1505
- const res = await server.put(`/food/${spinach._id}`).send({name: "Kale"}).expect(500);
1506
- expect(res.body.title).toBe("PUT is not supported.");
1507
- });
1508
-
1509
- it("preCreate returning undefined throws error", async () => {
1510
- app.use(
1511
- "/food",
1512
- modelRouter(FoodModel, {
1513
- allowAnonymous: true,
1514
- permissions: {
1515
- create: [Permissions.IsAny],
1516
- delete: [Permissions.IsAny],
1517
- list: [Permissions.IsAny],
1518
- read: [Permissions.IsAny],
1519
- update: [Permissions.IsAny],
1520
- },
1521
- preCreate: () => undefined as any,
1522
- })
1523
- );
1524
- server = supertest(app);
1525
-
1526
- const res = await server.post("/food").send({calories: 15, name: "Broccoli"}).expect(403);
1527
- expect(res.body.title).toBe("Create not allowed");
1528
- expect(res.body.detail).toBe("A body must be returned from preCreate");
1529
- });
1530
-
1531
- it("preUpdate returning undefined throws error", async () => {
1532
- app.use(
1533
- "/food",
1534
- modelRouter(FoodModel, {
1535
- allowAnonymous: true,
1536
- permissions: {
1537
- create: [Permissions.IsAny],
1538
- delete: [Permissions.IsAny],
1539
- list: [Permissions.IsAny],
1540
- read: [Permissions.IsAny],
1541
- update: [Permissions.IsAny],
1542
- },
1543
- preUpdate: () => undefined as any,
1544
- })
1545
- );
1546
- server = supertest(app);
1547
-
1548
- const res = await server.patch(`/food/${spinach._id}`).send({name: "Kale"}).expect(403);
1549
- expect(res.body.title).toBe("Update not allowed");
1550
- expect(res.body.detail).toBe("A body must be returned from preUpdate");
1551
- });
1552
-
1553
- it("preDelete returning undefined throws error", async () => {
1554
- app.use(
1555
- "/food",
1556
- modelRouter(FoodModel, {
1557
- allowAnonymous: true,
1558
- permissions: {
1559
- create: [Permissions.IsAny],
1560
- delete: [Permissions.IsAny],
1561
- list: [Permissions.IsAny],
1562
- read: [Permissions.IsAny],
1563
- update: [Permissions.IsAny],
1564
- },
1565
- preDelete: () => undefined as any,
1566
- })
1567
- );
1568
- server = supertest(app);
1569
- agent = await authAsUser(app, "notAdmin");
1570
-
1571
- const res = await agent.delete(`/food/${spinach._id}`).expect(403);
1572
- expect(res.body.title).toBe("Delete not allowed");
1573
- expect(res.body.detail).toBe("A body must be returned from preDelete");
1574
- });
1575
-
1576
- it("postCreate hook error is handled", async () => {
1577
- app.use(
1578
- "/food",
1579
- modelRouter(FoodModel, {
1580
- allowAnonymous: true,
1581
- permissions: {
1582
- create: [Permissions.IsAny],
1583
- delete: [Permissions.IsAny],
1584
- list: [Permissions.IsAny],
1585
- read: [Permissions.IsAny],
1586
- update: [Permissions.IsAny],
1587
- },
1588
- postCreate: () => {
1589
- throw new Error("postCreate failed");
1590
- },
1591
- })
1592
- );
1593
- server = supertest(app);
1594
-
1595
- const res = await server.post("/food").send({calories: 15, name: "Broccoli"}).expect(400);
1596
- expect(res.body.title).toContain("postCreate hook error");
1597
- });
1598
-
1599
- it("postUpdate hook error is handled", async () => {
1600
- app.use(
1601
- "/food",
1602
- modelRouter(FoodModel, {
1603
- allowAnonymous: true,
1604
- permissions: {
1605
- create: [Permissions.IsAny],
1606
- delete: [Permissions.IsAny],
1607
- list: [Permissions.IsAny],
1608
- read: [Permissions.IsAny],
1609
- update: [Permissions.IsAny],
1610
- },
1611
- postUpdate: () => {
1612
- throw new Error("postUpdate failed");
1613
- },
1614
- })
1615
- );
1616
- server = supertest(app);
1617
-
1618
- const res = await server.patch(`/food/${spinach._id}`).send({name: "Kale"}).expect(400);
1619
- expect(res.body.title).toContain("postUpdate hook error");
1620
- });
1621
-
1622
- it("postDelete hook error is handled", async () => {
1623
- app.use(
1624
- "/food",
1625
- modelRouter(FoodModel, {
1626
- allowAnonymous: true,
1627
- permissions: {
1628
- create: [Permissions.IsAny],
1629
- delete: [Permissions.IsAny],
1630
- list: [Permissions.IsAny],
1631
- read: [Permissions.IsAny],
1632
- update: [Permissions.IsAny],
1633
- },
1634
- postDelete: () => {
1635
- throw new Error("postDelete failed");
1636
- },
1637
- })
1638
- );
1639
- server = supertest(app);
1640
- agent = await authAsUser(app, "notAdmin");
1641
-
1642
- const res = await agent.delete(`/food/${spinach._id}`).expect(400);
1643
- expect(res.body.title).toContain("postDelete hook error");
1644
- });
1645
-
1646
- it("responseHandler error in read is handled", async () => {
1647
- app.use(
1648
- "/food",
1649
- modelRouter(FoodModel, {
1650
- allowAnonymous: true,
1651
- permissions: {
1652
- create: [Permissions.IsAny],
1653
- delete: [Permissions.IsAny],
1654
- list: [Permissions.IsAny],
1655
- read: [Permissions.IsAny],
1656
- update: [Permissions.IsAny],
1657
- },
1658
- responseHandler: (_data, method) => {
1659
- if (method === "read") {
1660
- throw new Error("responseHandler read failed");
1661
- }
1662
- return {} as any;
1663
- },
1664
- })
1665
- );
1666
- server = supertest(app);
1667
-
1668
- const res = await server.get(`/food/${spinach._id}`).expect(500);
1669
- expect(res.body.title).toContain("responseHandler error");
1670
- });
1671
-
1672
- it("responseHandler error in create is handled", async () => {
1673
- app.use(
1674
- "/food",
1675
- modelRouter(FoodModel, {
1676
- allowAnonymous: true,
1677
- permissions: {
1678
- create: [Permissions.IsAny],
1679
- delete: [Permissions.IsAny],
1680
- list: [Permissions.IsAny],
1681
- read: [Permissions.IsAny],
1682
- update: [Permissions.IsAny],
1683
- },
1684
- responseHandler: (_data, method) => {
1685
- if (method === "create") {
1686
- throw new Error("responseHandler create failed");
1687
- }
1688
- return {} as any;
1689
- },
1690
- })
1691
- );
1692
- server = supertest(app);
1693
-
1694
- const res = await server.post("/food").send({calories: 15, name: "Broccoli"}).expect(500);
1695
- expect(res.body.title).toContain("responseHandler error");
1696
- });
1697
-
1698
- it("responseHandler error in update is handled", async () => {
1699
- app.use(
1700
- "/food",
1701
- modelRouter(FoodModel, {
1702
- allowAnonymous: true,
1703
- permissions: {
1704
- create: [Permissions.IsAny],
1705
- delete: [Permissions.IsAny],
1706
- list: [Permissions.IsAny],
1707
- read: [Permissions.IsAny],
1708
- update: [Permissions.IsAny],
1709
- },
1710
- responseHandler: (_data, method) => {
1711
- if (method === "update") {
1712
- throw new Error("responseHandler update failed");
1713
- }
1714
- return {} as any;
1715
- },
1716
- })
1717
- );
1718
- server = supertest(app);
1719
-
1720
- const res = await server.patch(`/food/${spinach._id}`).send({name: "Kale"}).expect(500);
1721
- expect(res.body.title).toContain("responseHandler error");
1722
- });
1723
-
1724
- it("responseHandler error in list is handled", async () => {
1725
- app.use(
1726
- "/food",
1727
- modelRouter(FoodModel, {
1728
- allowAnonymous: true,
1729
- permissions: {
1730
- create: [Permissions.IsAny],
1731
- delete: [Permissions.IsAny],
1732
- list: [Permissions.IsAny],
1733
- read: [Permissions.IsAny],
1734
- update: [Permissions.IsAny],
1735
- },
1736
- responseHandler: (_data, method) => {
1737
- if (method === "list") {
1738
- throw new Error("responseHandler list failed");
1739
- }
1740
- return {} as any;
1741
- },
1742
- })
1743
- );
1744
- server = supertest(app);
1745
-
1746
- const res = await server.get("/food").expect(500);
1747
- expect(res.body.title).toContain("responseHandler error");
1748
- });
1749
-
1750
- it("list with non-array responseHandler returns data directly", async () => {
1751
- app.use(
1752
- "/food",
1753
- modelRouter(FoodModel, {
1754
- allowAnonymous: true,
1755
- permissions: {
1756
- create: [Permissions.IsAny],
1757
- delete: [Permissions.IsAny],
1758
- list: [Permissions.IsAny],
1759
- read: [Permissions.IsAny],
1760
- update: [Permissions.IsAny],
1761
- },
1762
- responseHandler: (_data, method) => {
1763
- if (method === "list") {
1764
- return {custom: "response"} as any;
1765
- }
1766
- return {} as any;
1767
- },
1768
- })
1769
- );
1770
- server = supertest(app);
1771
-
1772
- const res = await server.get("/food").expect(200);
1773
- expect(res.body.data).toEqual({custom: "response"});
1774
- expect(res.body.more).toBeUndefined();
1775
- expect(res.body.total).toBeUndefined();
1776
- });
1777
-
1778
- it("list with query sort param", async () => {
1779
- await FoodModel.create({
1780
- calories: 200,
1781
- created: new Date("2021-12-04T00:00:20.000Z"),
1782
- hidden: false,
1783
- name: "Apple",
1784
- ownerId: admin._id,
1785
- });
1786
-
1787
- app.use(
1788
- "/food",
1789
- modelRouter(FoodModel, {
1790
- allowAnonymous: true,
1791
- permissions: {
1792
- create: [Permissions.IsAny],
1793
- delete: [Permissions.IsAny],
1794
- list: [Permissions.IsAny],
1795
- read: [Permissions.IsAny],
1796
- update: [Permissions.IsAny],
1797
- },
1798
- queryFields: ["name"],
1799
- })
1800
- );
1801
- server = supertest(app);
1802
-
1803
- // Sort by name ascending
1804
- let res = await server.get("/food?sort=name").expect(200);
1805
- expect(res.body.data[0].name).toBe("Apple");
1806
- expect(res.body.data[1].name).toBe("Spinach");
1807
-
1808
- // Sort by name descending
1809
- res = await server.get("/food?sort=-name").expect(200);
1810
- expect(res.body.data[0].name).toBe("Spinach");
1811
- expect(res.body.data[1].name).toBe("Apple");
1812
- });
1813
-
1814
- it("queryFilter error is handled", async () => {
1815
- app.use(
1816
- "/food",
1817
- modelRouter(FoodModel, {
1818
- allowAnonymous: true,
1819
- permissions: {
1820
- create: [Permissions.IsAny],
1821
- delete: [Permissions.IsAny],
1822
- list: [Permissions.IsAny],
1823
- read: [Permissions.IsAny],
1824
- update: [Permissions.IsAny],
1825
- },
1826
- queryFilter: () => {
1827
- throw new Error("queryFilter failed");
1828
- },
1829
- })
1830
- );
1831
- server = supertest(app);
1832
-
1833
- const res = await server.get("/food").expect(400);
1834
- expect(res.body.title).toContain("Query filter error");
1835
- });
1836
-
1837
- it("custom endpoints take priority", async () => {
1838
- app.use(
1839
- "/food",
1840
- modelRouter(FoodModel, {
1841
- allowAnonymous: true,
1842
- endpoints: (router: any) => {
1843
- router.get("/custom", (_req: any, res: any) => {
1844
- res.json({custom: true});
1845
- });
1846
- },
1847
- permissions: {
1848
- create: [Permissions.IsAny],
1849
- delete: [Permissions.IsAny],
1850
- list: [Permissions.IsAny],
1851
- read: [Permissions.IsAny],
1852
- update: [Permissions.IsAny],
1853
- },
1854
- })
1855
- );
1856
- server = supertest(app);
1857
-
1858
- const res = await server.get("/food/custom").expect(200);
1859
- expect(res.body.custom).toBe(true);
1860
- });
1861
-
1862
- it("disallowed query param returns 400", async () => {
1863
- app.use(
1864
- "/food",
1865
- modelRouter(FoodModel, {
1866
- allowAnonymous: true,
1867
- permissions: {
1868
- create: [Permissions.IsAny],
1869
- delete: [Permissions.IsAny],
1870
- list: [Permissions.IsAny],
1871
- read: [Permissions.IsAny],
1872
- update: [Permissions.IsAny],
1873
- },
1874
- queryFields: ["name"],
1875
- })
1876
- );
1877
- server = supertest(app);
1878
-
1879
- const res = await server.get("/food?calories=100").expect(400);
1880
- expect(res.body.title).toContain("calories is not allowed as a query param");
1881
- });
1882
-
1883
- it("queryFilter returning null returns empty array", async () => {
1884
- app.use(
1885
- "/food",
1886
- modelRouter(FoodModel, {
1887
- allowAnonymous: true,
1888
- permissions: {
1889
- create: [Permissions.IsAny],
1890
- delete: [Permissions.IsAny],
1891
- list: [Permissions.IsAny],
1892
- read: [Permissions.IsAny],
1893
- update: [Permissions.IsAny],
1894
- },
1895
- queryFilter: () => null,
1896
- })
1897
- );
1898
- server = supertest(app);
1899
-
1900
- const res = await server.get("/food").expect(200);
1901
- expect(res.body.data).toEqual([]);
1902
- });
1903
-
1904
- it("preUpdate returning null throws error", async () => {
152
+ addAuthRoutes(app, UserModel as any);
1905
153
  app.use(
1906
154
  "/food",
1907
155
  modelRouter(FoodModel, {
@@ -1913,85 +161,54 @@ describe("@terreno/api", () => {
1913
161
  read: [Permissions.IsAny],
1914
162
  update: [Permissions.IsAny],
1915
163
  },
1916
- preUpdate: () => null,
1917
- })
1918
- );
1919
- server = supertest(app);
1920
-
1921
- const res = await server.patch(`/food/${spinach._id}`).send({name: "Kale"}).expect(403);
1922
- expect(res.body.title).toBe("Update not allowed");
1923
- });
1924
-
1925
- it("preDelete returning null throws error", async () => {
1926
- app.use(
1927
- "/food",
1928
- modelRouter(FoodModel, {
1929
- allowAnonymous: true,
1930
- permissions: {
1931
- create: [Permissions.IsAny],
1932
- delete: [Permissions.IsAny],
1933
- list: [Permissions.IsAny],
1934
- read: [Permissions.IsAny],
1935
- update: [Permissions.IsAny],
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
+ };
1936
175
  },
1937
- preDelete: () => null,
1938
176
  })
1939
177
  );
1940
178
  server = supertest(app);
1941
179
  agent = await authAsUser(app, "notAdmin");
1942
-
1943
- const res = await agent.delete(`/food/${spinach._id}`).expect(403);
1944
- expect(res.body.title).toBe("Delete not allowed");
1945
180
  });
1946
181
 
1947
- it("preCreate returning null throws error", async () => {
1948
- app.use(
1949
- "/food",
1950
- modelRouter(FoodModel, {
1951
- allowAnonymous: true,
1952
- permissions: {
1953
- create: [Permissions.IsAny],
1954
- delete: [Permissions.IsAny],
1955
- list: [Permissions.IsAny],
1956
- read: [Permissions.IsAny],
1957
- update: [Permissions.IsAny],
1958
- },
1959
- preCreate: () => null,
1960
- })
1961
- );
1962
- server = supertest(app);
1963
-
1964
- const res = await server.post("/food").send({calories: 15, name: "Broccoli"}).expect(403);
1965
- expect(res.body.title).toBe("Create not allowed");
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");
1966
187
  });
1967
188
 
1968
- it("preCreate error is handled", async () => {
1969
- app.use(
1970
- "/food",
1971
- modelRouter(FoodModel, {
1972
- allowAnonymous: true,
1973
- permissions: {
1974
- create: [Permissions.IsAny],
1975
- delete: [Permissions.IsAny],
1976
- list: [Permissions.IsAny],
1977
- read: [Permissions.IsAny],
1978
- update: [Permissions.IsAny],
1979
- },
1980
- preCreate: () => {
1981
- throw new Error("preCreate failed");
1982
- },
1983
- })
1984
- );
1985
- server = supertest(app);
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();
1986
193
 
1987
- const res = await server.post("/food").send({calories: 15, name: "Broccoli"}).expect(400);
1988
- expect(res.body.title).toContain("preCreate hook error");
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");
1989
198
  });
199
+ });
200
+
201
+ describe("plugins", () => {
202
+ let agent: TestAgent;
1990
203
 
1991
- it("preUpdate error is handled", async () => {
204
+ beforeEach(async () => {
205
+ await setupDb();
206
+ app = getBaseServer();
207
+ setupAuth(app, UserModel as any);
208
+ addAuthRoutes(app, UserModel as any);
1992
209
  app.use(
1993
- "/food",
1994
- modelRouter(FoodModel, {
210
+ "/users",
211
+ modelRouter(UserModel, {
1995
212
  allowAnonymous: true,
1996
213
  permissions: {
1997
214
  create: [Permissions.IsAny],
@@ -2000,44 +217,37 @@ describe("@terreno/api", () => {
2000
217
  read: [Permissions.IsAny],
2001
218
  update: [Permissions.IsAny],
2002
219
  },
2003
- preUpdate: () => {
2004
- throw new Error("preUpdate failed");
2005
- },
2006
220
  })
2007
221
  );
2008
222
  server = supertest(app);
2009
-
2010
- const res = await server.patch(`/food/${spinach._id}`).send({name: "Kale"}).expect(400);
2011
- expect(res.body.title).toContain("preUpdate hook error");
223
+ agent = await authAsUser(app, "notAdmin");
2012
224
  });
2013
225
 
2014
- it("invalid array operation type returns 400", async () => {
2015
- // This tests the else branch for invalid array operations
2016
- // We need to manually call the endpoint with an invalid HTTP method
2017
- // The array operations use POST (add), PATCH (update), DELETE (remove)
2018
- // We can't easily test this without modifying the router, so skip for now
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();
2019
232
  });
2020
233
  });
2021
234
 
2022
- describe("array operation errors", () => {
235
+ describe("error handling", () => {
2023
236
  let admin: any;
2024
- let apple: Food;
2025
- let agent: TestAgent;
237
+ let spinach: Food;
2026
238
 
2027
239
  beforeEach(async () => {
2028
240
  [admin] = await setupDb();
2029
241
 
2030
- apple = await FoodModel.create({
2031
- calories: 100,
2032
- categories: [
2033
- {name: "Fruit", show: true},
2034
- {name: "Popular", show: false},
2035
- ],
2036
- created: new Date("2021-12-03T00:00:30.000Z"),
242
+ spinach = await FoodModel.create({
243
+ calories: 1,
244
+ created: new Date("2021-12-03T00:00:20.000Z"),
2037
245
  hidden: false,
2038
- name: "Apple",
246
+ name: "Spinach",
2039
247
  ownerId: admin._id,
2040
- tags: ["healthy", "cheap"],
248
+ source: {
249
+ name: "Brand",
250
+ },
2041
251
  });
2042
252
 
2043
253
  app = getBaseServer();
@@ -2045,216 +255,193 @@ describe("@terreno/api", () => {
2045
255
  addAuthRoutes(app, UserModel as any);
2046
256
  });
2047
257
 
2048
- it("array operation preUpdate returning undefined throws error", async () => {
258
+ it("PUT returns 500 not supported", async () => {
2049
259
  app.use(
2050
260
  "/food",
2051
261
  modelRouter(FoodModel, {
2052
262
  allowAnonymous: true,
2053
263
  permissions: {
2054
- create: [Permissions.IsAdmin],
2055
- delete: [Permissions.IsAdmin],
2056
- list: [Permissions.IsAdmin],
2057
- read: [Permissions.IsAdmin],
2058
- update: [Permissions.IsAdmin],
264
+ create: [Permissions.IsAny],
265
+ delete: [Permissions.IsAny],
266
+ list: [Permissions.IsAny],
267
+ read: [Permissions.IsAny],
268
+ update: [Permissions.IsAny],
2059
269
  },
2060
- preUpdate: () => undefined as any,
2061
270
  })
2062
271
  );
2063
272
  server = supertest(app);
2064
- agent = await authAsUser(app, "admin");
2065
273
 
2066
- const res = await agent.post(`/food/${apple._id}/tags`).send({tags: "organic"}).expect(403);
2067
- expect(res.body.title).toBe("Update not allowed");
2068
- expect(res.body.detail).toBe("A body must be returned from preUpdate");
274
+ const res = await server.put(`/food/${spinach._id}`).send({name: "Kale"}).expect(500);
275
+ expect(res.body.title).toBe("PUT is not supported.");
2069
276
  });
2070
277
 
2071
- it("array operation preUpdate returning null throws error", async () => {
278
+ it("responseHandler error in read is handled", async () => {
2072
279
  app.use(
2073
280
  "/food",
2074
281
  modelRouter(FoodModel, {
2075
282
  allowAnonymous: true,
2076
283
  permissions: {
2077
- create: [Permissions.IsAdmin],
2078
- delete: [Permissions.IsAdmin],
2079
- list: [Permissions.IsAdmin],
2080
- read: [Permissions.IsAdmin],
2081
- update: [Permissions.IsAdmin],
284
+ create: [Permissions.IsAny],
285
+ delete: [Permissions.IsAny],
286
+ list: [Permissions.IsAny],
287
+ read: [Permissions.IsAny],
288
+ update: [Permissions.IsAny],
289
+ },
290
+ responseHandler: (_data, method) => {
291
+ if (method === "read") {
292
+ throw new Error("responseHandler read failed");
293
+ }
294
+ return {} as any;
2082
295
  },
2083
- preUpdate: () => null,
2084
296
  })
2085
297
  );
2086
298
  server = supertest(app);
2087
- agent = await authAsUser(app, "admin");
2088
299
 
2089
- const res = await agent.post(`/food/${apple._id}/tags`).send({tags: "organic"}).expect(403);
2090
- expect(res.body.title).toBe("Update not allowed");
300
+ const res = await server.get(`/food/${spinach._id}`).expect(500);
301
+ expect(res.body.title).toContain("responseHandler error");
2091
302
  });
2092
303
 
2093
- it("array operation preUpdate error is handled", async () => {
304
+ it("responseHandler error in create is handled", async () => {
2094
305
  app.use(
2095
306
  "/food",
2096
307
  modelRouter(FoodModel, {
2097
308
  allowAnonymous: true,
2098
309
  permissions: {
2099
- create: [Permissions.IsAdmin],
2100
- delete: [Permissions.IsAdmin],
2101
- list: [Permissions.IsAdmin],
2102
- read: [Permissions.IsAdmin],
2103
- update: [Permissions.IsAdmin],
310
+ create: [Permissions.IsAny],
311
+ delete: [Permissions.IsAny],
312
+ list: [Permissions.IsAny],
313
+ read: [Permissions.IsAny],
314
+ update: [Permissions.IsAny],
2104
315
  },
2105
- preUpdate: () => {
2106
- throw new Error("preUpdate array failed");
316
+ responseHandler: (_data, method) => {
317
+ if (method === "create") {
318
+ throw new Error("responseHandler create failed");
319
+ }
320
+ return {} as any;
2107
321
  },
2108
322
  })
2109
323
  );
2110
324
  server = supertest(app);
2111
- agent = await authAsUser(app, "admin");
2112
325
 
2113
- const res = await agent.post(`/food/${apple._id}/tags`).send({tags: "organic"}).expect(400);
2114
- expect(res.body.title).toContain("preUpdate hook error");
326
+ const res = await server.post("/food").send({calories: 15, name: "Broccoli"}).expect(500);
327
+ expect(res.body.title).toContain("responseHandler error");
2115
328
  });
2116
329
 
2117
- it("array operation postUpdate error is handled", async () => {
330
+ it("responseHandler error in update is handled", async () => {
2118
331
  app.use(
2119
332
  "/food",
2120
333
  modelRouter(FoodModel, {
2121
334
  allowAnonymous: true,
2122
335
  permissions: {
2123
- create: [Permissions.IsAdmin],
2124
- delete: [Permissions.IsAdmin],
2125
- list: [Permissions.IsAdmin],
2126
- read: [Permissions.IsAdmin],
2127
- update: [Permissions.IsAdmin],
336
+ create: [Permissions.IsAny],
337
+ delete: [Permissions.IsAny],
338
+ list: [Permissions.IsAny],
339
+ read: [Permissions.IsAny],
340
+ update: [Permissions.IsAny],
2128
341
  },
2129
- postUpdate: () => {
2130
- throw new Error("postUpdate array failed");
342
+ responseHandler: (_data, method) => {
343
+ if (method === "update") {
344
+ throw new Error("responseHandler update failed");
345
+ }
346
+ return {} as any;
2131
347
  },
2132
348
  })
2133
349
  );
2134
350
  server = supertest(app);
2135
- agent = await authAsUser(app, "admin");
2136
351
 
2137
- const res = await agent.post(`/food/${apple._id}/tags`).send({tags: "organic"}).expect(400);
2138
- expect(res.body.title).toContain("PATCH Post Update error");
352
+ const res = await server.patch(`/food/${spinach._id}`).send({name: "Kale"}).expect(500);
353
+ expect(res.body.title).toContain("responseHandler error");
2139
354
  });
2140
355
 
2141
- it("array operation denied without update permission", async () => {
356
+ it("responseHandler error in list is handled", async () => {
2142
357
  app.use(
2143
358
  "/food",
2144
359
  modelRouter(FoodModel, {
2145
360
  allowAnonymous: true,
2146
361
  permissions: {
2147
- create: [Permissions.IsAdmin],
2148
- delete: [Permissions.IsAdmin],
362
+ create: [Permissions.IsAny],
363
+ delete: [Permissions.IsAny],
2149
364
  list: [Permissions.IsAny],
2150
365
  read: [Permissions.IsAny],
2151
- update: [Permissions.IsAdmin],
366
+ update: [Permissions.IsAny],
2152
367
  },
2153
- })
2154
- );
2155
- server = supertest(app);
2156
- agent = await authAsUser(app, "notAdmin");
2157
-
2158
- const res = await agent.post(`/food/${apple._id}/tags`).send({tags: "organic"}).expect(405);
2159
- expect(res.body.title).toContain("Access to PATCH");
2160
- });
2161
-
2162
- it("array operation on non-existent document returns 404", async () => {
2163
- app.use(
2164
- "/food",
2165
- modelRouter(FoodModel, {
2166
- allowAnonymous: true,
2167
- permissions: {
2168
- create: [Permissions.IsAdmin],
2169
- delete: [Permissions.IsAdmin],
2170
- list: [Permissions.IsAdmin],
2171
- read: [Permissions.IsAdmin],
2172
- update: [Permissions.IsAdmin],
368
+ responseHandler: (_data, method) => {
369
+ if (method === "list") {
370
+ throw new Error("responseHandler list failed");
371
+ }
372
+ return {} as any;
2173
373
  },
2174
374
  })
2175
375
  );
2176
376
  server = supertest(app);
2177
- agent = await authAsUser(app, "admin");
2178
377
 
2179
- const fakeId = "000000000000000000000000";
2180
- const res = await agent.post(`/food/${fakeId}/tags`).send({tags: "organic"}).expect(404);
2181
- expect(res.body.title).toContain("Could not find document to PATCH");
378
+ const res = await server.get("/food").expect(500);
379
+ expect(res.body.title).toContain("responseHandler error");
2182
380
  });
2183
381
 
2184
- it("array operation denied when user cannot update specific doc", async () => {
2185
- // Create food owned by admin, then try to update as notAdmin
382
+ it("list with non-array responseHandler returns data directly", async () => {
2186
383
  app.use(
2187
384
  "/food",
2188
385
  modelRouter(FoodModel, {
2189
386
  allowAnonymous: true,
2190
387
  permissions: {
2191
- create: [Permissions.IsAuthenticated],
2192
- delete: [Permissions.IsAuthenticated],
2193
- list: [Permissions.IsAuthenticated],
2194
- read: [Permissions.IsAuthenticated],
2195
- update: [Permissions.IsOwner],
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;
2196
399
  },
2197
400
  })
2198
401
  );
2199
402
  server = supertest(app);
2200
- // Login as notAdmin and try to update admin's food (apple)
2201
- agent = await authAsUser(app, "notAdmin");
2202
403
 
2203
- const res = await agent.post(`/food/${apple._id}/tags`).send({tags: "organic"}).expect(403);
2204
- expect(res.body.title).toContain("Patch not allowed");
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();
2205
408
  });
2206
409
 
2207
- it("array operation transform error is handled", async () => {
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",
416
+ ownerId: admin._id,
417
+ });
418
+
2208
419
  app.use(
2209
420
  "/food",
2210
421
  modelRouter(FoodModel, {
2211
422
  allowAnonymous: true,
2212
423
  permissions: {
2213
- create: [Permissions.IsAdmin],
2214
- delete: [Permissions.IsAdmin],
2215
- list: [Permissions.IsAdmin],
2216
- read: [Permissions.IsAdmin],
2217
- update: [Permissions.IsAdmin],
424
+ create: [Permissions.IsAny],
425
+ delete: [Permissions.IsAny],
426
+ list: [Permissions.IsAny],
427
+ read: [Permissions.IsAny],
428
+ update: [Permissions.IsAny],
2218
429
  },
2219
- transformer: AdminOwnerTransformer({
2220
- adminWriteFields: ["name"],
2221
- }),
430
+ queryFields: ["name"],
2222
431
  })
2223
432
  );
2224
433
  server = supertest(app);
2225
- agent = await authAsUser(app, "admin");
2226
-
2227
- // Try to update tags field, which is not in the allowed write fields
2228
- const res = await agent.post(`/food/${apple._id}/tags`).send({tags: "organic"}).expect(403);
2229
- expect(res.body.title).toContain("cannot write fields");
2230
- });
2231
- });
2232
-
2233
- describe("transformer errors", () => {
2234
- let admin: any;
2235
- let spinach: Food;
2236
- let agent: TestAgent;
2237
434
 
2238
- beforeEach(async () => {
2239
- [admin] = await setupDb();
2240
-
2241
- spinach = await FoodModel.create({
2242
- calories: 1,
2243
- created: new Date("2021-12-03T00:00:20.000Z"),
2244
- hidden: false,
2245
- name: "Spinach",
2246
- ownerId: admin._id,
2247
- source: {
2248
- name: "Brand",
2249
- },
2250
- });
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");
2251
438
 
2252
- app = getBaseServer();
2253
- setupAuth(app, UserModel as any);
2254
- addAuthRoutes(app, UserModel as any);
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");
2255
442
  });
2256
443
 
2257
- it("transform error in create is handled", async () => {
444
+ it("queryFilter error is handled", async () => {
2258
445
  app.use(
2259
446
  "/food",
2260
447
  modelRouter(FoodModel, {
@@ -2266,23 +453,27 @@ describe("@terreno/api", () => {
2266
453
  read: [Permissions.IsAny],
2267
454
  update: [Permissions.IsAny],
2268
455
  },
2269
- transformer: AdminOwnerTransformer({
2270
- // Only allow 'name' to be written, so 'calories' will throw
2271
- anonWriteFields: ["name"],
2272
- }),
456
+ queryFilter: () => {
457
+ throw new Error("queryFilter failed");
458
+ },
2273
459
  })
2274
460
  );
2275
461
  server = supertest(app);
2276
462
 
2277
- const res = await server.post("/food").send({calories: 15, name: "Broccoli"}).expect(400);
2278
- expect(res.body.title).toContain("cannot write fields");
463
+ const res = await server.get("/food").expect(400);
464
+ expect(res.body.title).toContain("Query filter error");
2279
465
  });
2280
466
 
2281
- it("transform error in patch is handled", async () => {
467
+ it("custom endpoints take priority", async () => {
2282
468
  app.use(
2283
469
  "/food",
2284
470
  modelRouter(FoodModel, {
2285
471
  allowAnonymous: true,
472
+ endpoints: (router: any) => {
473
+ router.get("/custom", (_req: any, res: any) => {
474
+ res.json({custom: true});
475
+ });
476
+ },
2286
477
  permissions: {
2287
478
  create: [Permissions.IsAny],
2288
479
  delete: [Permissions.IsAny],
@@ -2290,25 +481,18 @@ describe("@terreno/api", () => {
2290
481
  read: [Permissions.IsAny],
2291
482
  update: [Permissions.IsAny],
2292
483
  },
2293
- transformer: AdminOwnerTransformer({
2294
- // Only allow 'name' to be written, so 'calories' will throw
2295
- anonWriteFields: ["name"],
2296
- }),
2297
484
  })
2298
485
  );
2299
486
  server = supertest(app);
2300
487
 
2301
- const res = await server.patch(`/food/${spinach._id}`).send({calories: 100}).expect(403);
2302
- expect(res.body.title).toContain("cannot write fields");
488
+ const res = await server.get("/food/custom").expect(200);
489
+ expect(res.body.custom).toBe(true);
2303
490
  });
2304
491
 
2305
- it("model.create validation error is handled", async () => {
2306
- // Use a model that has required fields
2307
- const {RequiredModel} = await import("./tests");
2308
-
492
+ it("disallowed query param returns 400", async () => {
2309
493
  app.use(
2310
- "/required",
2311
- modelRouter(RequiredModel, {
494
+ "/food",
495
+ modelRouter(FoodModel, {
2312
496
  allowAnonymous: true,
2313
497
  permissions: {
2314
498
  create: [Permissions.IsAny],
@@ -2317,16 +501,16 @@ describe("@terreno/api", () => {
2317
501
  read: [Permissions.IsAny],
2318
502
  update: [Permissions.IsAny],
2319
503
  },
504
+ queryFields: ["name"],
2320
505
  })
2321
506
  );
2322
507
  server = supertest(app);
2323
508
 
2324
- // Send without required 'name' field
2325
- const res = await server.post("/required").send({about: "test"}).expect(400);
2326
- expect(res.body.title).toContain("Required");
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");
2327
511
  });
2328
512
 
2329
- it("preDelete hook throwing APIError is re-thrown", async () => {
513
+ it("queryFilter returning null returns empty array", async () => {
2330
514
  app.use(
2331
515
  "/food",
2332
516
  modelRouter(FoodModel, {
@@ -2338,36 +522,32 @@ describe("@terreno/api", () => {
2338
522
  read: [Permissions.IsAny],
2339
523
  update: [Permissions.IsAny],
2340
524
  },
2341
- preDelete: () => {
2342
- throw new APIError({
2343
- disableExternalErrorTracking: true,
2344
- status: 400,
2345
- title: "Custom preDelete APIError",
2346
- });
2347
- },
525
+ queryFilter: () => null,
2348
526
  })
2349
527
  );
2350
528
  server = supertest(app);
2351
- agent = await authAsUser(app, "notAdmin");
2352
529
 
2353
- const res = await agent.delete(`/food/${spinach._id}`).expect(400);
2354
- expect(res.body.title).toBe("Custom preDelete APIError");
2355
- expect(res.body.disableExternalErrorTracking).toBe(true);
530
+ const res = await server.get("/food").expect(200);
531
+ expect(res.body.data).toEqual([]);
2356
532
  });
2357
533
  });
2358
534
 
2359
- describe("special query params", () => {
535
+ describe("transformer errors", () => {
2360
536
  let admin: any;
537
+ let spinach: Food;
2361
538
 
2362
539
  beforeEach(async () => {
2363
540
  [admin] = await setupDb();
2364
541
 
2365
- await FoodModel.create({
542
+ spinach = await FoodModel.create({
2366
543
  calories: 1,
2367
544
  created: new Date("2021-12-03T00:00:20.000Z"),
2368
545
  hidden: false,
2369
546
  name: "Spinach",
2370
547
  ownerId: admin._id,
548
+ source: {
549
+ name: "Brand",
550
+ },
2371
551
  });
2372
552
 
2373
553
  app = getBaseServer();
@@ -2375,46 +555,7 @@ describe("@terreno/api", () => {
2375
555
  addAuthRoutes(app, UserModel as any);
2376
556
  });
2377
557
 
2378
- it("period query param is stripped from query", async () => {
2379
- app.use(
2380
- "/food",
2381
- modelRouter(FoodModel, {
2382
- allowAnonymous: true,
2383
- permissions: {
2384
- create: [Permissions.IsAny],
2385
- delete: [Permissions.IsAny],
2386
- list: [Permissions.IsAny],
2387
- read: [Permissions.IsAny],
2388
- update: [Permissions.IsAny],
2389
- },
2390
- queryFields: ["name", "period"],
2391
- queryFilter: (_user, query) => {
2392
- // Simulate a queryFilter that accepts and processes period
2393
- if (query?.period) {
2394
- // Period is processed but shouldn't be passed to mongo
2395
- return query;
2396
- }
2397
- return query ?? {};
2398
- },
2399
- })
2400
- );
2401
- server = supertest(app);
2402
-
2403
- // period should be accepted and processed without error
2404
- const res = await server.get("/food?period=weekly").expect(200);
2405
- expect(res.body.data).toBeDefined();
2406
- });
2407
-
2408
- it("query with false value", async () => {
2409
- // Create a food that is hidden
2410
- await FoodModel.create({
2411
- calories: 50,
2412
- created: new Date("2021-12-04T00:00:20.000Z"),
2413
- hidden: true,
2414
- name: "HiddenFood",
2415
- ownerId: admin._id,
2416
- });
2417
-
558
+ it("transform error in create is handled", async () => {
2418
559
  app.use(
2419
560
  "/food",
2420
561
  modelRouter(FoodModel, {
@@ -2426,19 +567,18 @@ describe("@terreno/api", () => {
2426
567
  read: [Permissions.IsAny],
2427
568
  update: [Permissions.IsAny],
2428
569
  },
2429
- queryFields: ["name", "hidden"],
570
+ transformer: AdminOwnerTransformer({
571
+ anonWriteFields: ["name"],
572
+ }),
2430
573
  })
2431
574
  );
2432
575
  server = supertest(app);
2433
576
 
2434
- // Query for non-hidden foods using ?hidden=false
2435
- const res = await server.get("/food?hidden=false").expect(200);
2436
- expect(res.body.data.every((f: any) => f.hidden === false)).toBe(true);
577
+ const res = await server.post("/food").send({calories: 15, name: "Broccoli"}).expect(400);
578
+ expect(res.body.title).toContain("cannot write fields");
2437
579
  });
2438
580
 
2439
- it("$search query triggers special handling code path", async () => {
2440
- // The $search code path just accesses the collection but doesn't do anything with it
2441
- // This test verifies the code path is exercised
581
+ it("transform error in patch is handled", async () => {
2442
582
  app.use(
2443
583
  "/food",
2444
584
  modelRouter(FoodModel, {
@@ -2450,24 +590,21 @@ describe("@terreno/api", () => {
2450
590
  read: [Permissions.IsAny],
2451
591
  update: [Permissions.IsAny],
2452
592
  },
2453
- // Need to include $search in queryFields for it to pass validation
2454
- queryFields: ["name", "$search"],
593
+ transformer: AdminOwnerTransformer({
594
+ anonWriteFields: ["name"],
595
+ }),
2455
596
  })
2456
597
  );
2457
598
  server = supertest(app);
2458
599
 
2459
- // The $search will be added to the query params, triggering the special handling
2460
- // Even though the code doesn't actually do anything useful with it (stub for Atlas)
2461
- const res = await server.get("/food?$search=test");
2462
- // May return 500 because $search is passed to Mongo which doesn't support it without Atlas
2463
- // The important thing is we've exercised the code path
2464
- expect(res.status === 200 || res.status === 500).toBe(true);
600
+ const res = await server.patch(`/food/${spinach._id}`).send({calories: 100}).expect(403);
601
+ expect(res.body.title).toContain("cannot write fields");
2465
602
  });
2466
603
 
2467
- it("$autocomplete query triggers special handling code path", async () => {
604
+ it("model.create validation error is handled", async () => {
2468
605
  app.use(
2469
- "/food",
2470
- modelRouter(FoodModel, {
606
+ "/required",
607
+ modelRouter(RequiredModel, {
2471
608
  allowAnonymous: true,
2472
609
  permissions: {
2473
610
  create: [Permissions.IsAny],
@@ -2476,13 +613,12 @@ describe("@terreno/api", () => {
2476
613
  read: [Permissions.IsAny],
2477
614
  update: [Permissions.IsAny],
2478
615
  },
2479
- queryFields: ["name", "$autocomplete"],
2480
616
  })
2481
617
  );
2482
618
  server = supertest(app);
2483
619
 
2484
- const res = await server.get("/food?$autocomplete=test");
2485
- expect(res.status === 200 || res.status === 500).toBe(true);
620
+ const res = await server.post("/required").send({about: "test"}).expect(400);
621
+ expect(res.body.title).toContain("Required");
2486
622
  });
2487
623
  });
2488
624
 
@@ -2508,7 +644,6 @@ describe("@terreno/api", () => {
2508
644
  {fields: ["email"], path: "ownerId"},
2509
645
  {fields: ["name"], path: "eatenBy"},
2510
646
  ]);
2511
- // The result should be a query with populate applied
2512
647
  expect(result).toBeDefined();
2513
648
  });
2514
649
  });
@@ -2526,9 +661,6 @@ describe("@terreno/api", () => {
2526
661
  });
2527
662
 
2528
663
  it("soft deletes user with deleted field", async () => {
2529
- // UserModel has the isDisabledPlugin which adds a 'disabled' field,
2530
- // but we need to test the 'deleted' field check.
2531
- // Let's use a model that has the deleted field.
2532
664
  app.use(
2533
665
  "/users",
2534
666
  modelRouter(UserModel, {
@@ -2545,11 +677,9 @@ describe("@terreno/api", () => {
2545
677
  server = supertest(app);
2546
678
  agent = await authAsUser(app, "notAdmin");
2547
679
 
2548
- // Delete a user - this should use deleteOne since User doesn't have deleted field
2549
680
  const res = await agent.delete(`/users/${admin._id}`).expect(204);
2550
681
  expect(res.body).toEqual({});
2551
682
 
2552
- // Verify user was deleted
2553
683
  const deletedUser = await UserModel.findById(admin._id);
2554
684
  expect(deletedUser).toBeNull();
2555
685
  });
@@ -2575,7 +705,6 @@ describe("@terreno/api", () => {
2575
705
  });
2576
706
 
2577
707
  it("handles populate with valid path in create", async () => {
2578
- // Test that valid populate works in create flow
2579
708
  app.use(
2580
709
  "/food",
2581
710
  modelRouter(FoodModel, {
@@ -2597,7 +726,6 @@ describe("@terreno/api", () => {
2597
726
  .send({calories: 15, name: "Broccoli", ownerId: admin._id})
2598
727
  .expect(201);
2599
728
  expect(res.body.data.name).toBe("Broccoli");
2600
- // Verify populate worked - ownerId should be an object with email
2601
729
  expect(res.body.data.ownerId.email).toBe(admin.email);
2602
730
  });
2603
731
  });
@@ -2626,7 +754,6 @@ describe("@terreno/api", () => {
2626
754
  });
2627
755
 
2628
756
  it("handles patch save error with validation failure", async () => {
2629
- // The FoodModel has strict: "throw" which will cause validation errors for unknown fields
2630
757
  app.use(
2631
758
  "/food",
2632
759
  modelRouter(FoodModel, {
@@ -2642,7 +769,6 @@ describe("@terreno/api", () => {
2642
769
  );
2643
770
  server = supertest(app);
2644
771
 
2645
- // Try to patch with an invalid field (will be caught by strict: "throw")
2646
772
  const res = await server
2647
773
  .patch(`/food/${spinach._id}`)
2648
774
  .send({invalidField: "value"})
@@ -2661,7 +787,6 @@ describe("@terreno/api", () => {
2661
787
  });
2662
788
 
2663
789
  it("handles undefined body after transform when no preCreate", async () => {
2664
- // Create a transformer that returns undefined
2665
790
  app.use(
2666
791
  "/food",
2667
792
  modelRouter(FoodModel, {
@@ -2699,18 +824,13 @@ describe("@terreno/api", () => {
2699
824
  });
2700
825
 
2701
826
  it("soft deletes document with deleted field using isDeletedPlugin", async () => {
2702
- // Create a test schema with the isDeletedPlugin
2703
827
  const mongoose = await import("mongoose");
2704
828
 
2705
- // Create a temporary model with the deleted field
2706
829
  const softDeleteSchema = new mongoose.Schema({
2707
830
  deleted: {default: false, type: Boolean},
2708
831
  name: String,
2709
832
  });
2710
- // Manually add the deleted field (simulating what isDeletedPlugin does)
2711
- // The schema already has the deleted field, so it should use soft delete
2712
833
 
2713
- // Check if the model already exists to avoid OverwriteModelError
2714
834
  let SoftDeleteModel;
2715
835
  try {
2716
836
  SoftDeleteModel = mongoose.model("SoftDeleteTest");
@@ -2718,10 +838,8 @@ describe("@terreno/api", () => {
2718
838
  SoftDeleteModel = mongoose.model("SoftDeleteTest", softDeleteSchema);
2719
839
  }
2720
840
 
2721
- // Clean up any existing documents
2722
841
  await SoftDeleteModel.deleteMany({});
2723
842
 
2724
- // Create a test document
2725
843
  const testDoc = await SoftDeleteModel.create({name: "TestItem"});
2726
844
 
2727
845
  app.use(
@@ -2740,516 +858,13 @@ describe("@terreno/api", () => {
2740
858
  server = supertest(app);
2741
859
  agent = await authAsUser(app, "notAdmin");
2742
860
 
2743
- // Delete should soft delete (set deleted: true) instead of hard delete
2744
861
  await agent.delete(`/softdelete/${testDoc._id}`).expect(204);
2745
862
 
2746
- // Verify document was soft deleted (not hard deleted)
2747
863
  const softDeleted = await SoftDeleteModel.findById(testDoc._id);
2748
864
  expect(softDeleted).not.toBeNull();
2749
865
  expect(softDeleted?.deleted).toBe(true);
2750
866
 
2751
- // Clean up
2752
867
  await SoftDeleteModel.deleteMany({});
2753
868
  });
2754
869
  });
2755
-
2756
- describe("array operation with undefined preUpdate return", () => {
2757
- let admin: any;
2758
- let apple: Food;
2759
- let agent: TestAgent;
2760
-
2761
- beforeEach(async () => {
2762
- [admin] = await setupDb();
2763
-
2764
- apple = await FoodModel.create({
2765
- calories: 100,
2766
- categories: [
2767
- {name: "Fruit", show: true},
2768
- {name: "Popular", show: false},
2769
- ],
2770
- created: new Date("2021-12-03T00:00:30.000Z"),
2771
- hidden: false,
2772
- name: "Apple",
2773
- ownerId: admin._id,
2774
- tags: ["healthy", "cheap"],
2775
- });
2776
-
2777
- app = getBaseServer();
2778
- setupAuth(app, UserModel as any);
2779
- addAuthRoutes(app, UserModel as any);
2780
- });
2781
-
2782
- it("array operation preUpdate returning undefined for array POST throws error", async () => {
2783
- app.use(
2784
- "/food",
2785
- modelRouter(FoodModel, {
2786
- allowAnonymous: true,
2787
- permissions: {
2788
- create: [Permissions.IsAdmin],
2789
- delete: [Permissions.IsAdmin],
2790
- list: [Permissions.IsAdmin],
2791
- read: [Permissions.IsAdmin],
2792
- update: [Permissions.IsAdmin],
2793
- },
2794
- preUpdate: () => undefined as any,
2795
- })
2796
- );
2797
- server = supertest(app);
2798
- agent = await authAsUser(app, "admin");
2799
-
2800
- const res = await agent.post(`/food/${apple._id}/tags`).send({tags: "organic"}).expect(403);
2801
- expect(res.body.title).toBe("Update not allowed");
2802
- expect(res.body.detail).toBe("A body must be returned from preUpdate");
2803
- });
2804
-
2805
- it("array operation preUpdate returning null for array PATCH throws error", async () => {
2806
- app.use(
2807
- "/food",
2808
- modelRouter(FoodModel, {
2809
- allowAnonymous: true,
2810
- permissions: {
2811
- create: [Permissions.IsAdmin],
2812
- delete: [Permissions.IsAdmin],
2813
- list: [Permissions.IsAdmin],
2814
- read: [Permissions.IsAdmin],
2815
- update: [Permissions.IsAdmin],
2816
- },
2817
- preUpdate: () => null,
2818
- })
2819
- );
2820
- server = supertest(app);
2821
- agent = await authAsUser(app, "admin");
2822
-
2823
- const res = await agent
2824
- .patch(`/food/${apple._id}/tags/healthy`)
2825
- .send({tags: "unhealthy"})
2826
- .expect(403);
2827
- expect(res.body.title).toBe("Update not allowed");
2828
- });
2829
-
2830
- it("array operation preUpdate error for array DELETE is handled", async () => {
2831
- app.use(
2832
- "/food",
2833
- modelRouter(FoodModel, {
2834
- allowAnonymous: true,
2835
- permissions: {
2836
- create: [Permissions.IsAdmin],
2837
- delete: [Permissions.IsAdmin],
2838
- list: [Permissions.IsAdmin],
2839
- read: [Permissions.IsAdmin],
2840
- update: [Permissions.IsAdmin],
2841
- },
2842
- preUpdate: () => {
2843
- throw new Error("preUpdate error during delete");
2844
- },
2845
- })
2846
- );
2847
- server = supertest(app);
2848
- agent = await authAsUser(app, "admin");
2849
-
2850
- const res = await agent.delete(`/food/${apple._id}/tags/healthy`).expect(400);
2851
- expect(res.body.title).toContain("preUpdate hook error");
2852
- });
2853
- });
2854
- });
2855
-
2856
- describe("errors module", () => {
2857
- describe("APIError", () => {
2858
- it("sets default status to 500 when not provided", () => {
2859
- const error = new APIError({title: "Test error"});
2860
- expect(error.status).toBe(500);
2861
- });
2862
-
2863
- it("sets status to 500 for invalid status codes below 400", () => {
2864
- const error = new APIError({status: 200, title: "Test error"});
2865
- expect(error.status).toBe(500);
2866
- });
2867
-
2868
- it("sets status to 500 for invalid status codes above 599", () => {
2869
- const error = new APIError({status: 600, title: "Test error"});
2870
- expect(error.status).toBe(500);
2871
- });
2872
-
2873
- it("includes error stack in message when error is provided", () => {
2874
- const originalError = new Error("Original error");
2875
- const apiError = new APIError({
2876
- error: originalError,
2877
- title: "Wrapped error",
2878
- });
2879
- expect(apiError.message).toContain("Wrapped error");
2880
- expect(originalError.stack).toBeDefined();
2881
- expect(apiError.message).toContain(originalError.stack as string);
2882
- });
2883
-
2884
- it("includes detail in message when provided", () => {
2885
- const error = new APIError({
2886
- detail: "More details here",
2887
- title: "Test error",
2888
- });
2889
- expect(error.message).toContain("Test error");
2890
- expect(error.message).toContain("More details here");
2891
- });
2892
-
2893
- it("sets fields in meta when provided", () => {
2894
- const error = new APIError({
2895
- fields: {email: "Invalid email format"},
2896
- title: "Validation error",
2897
- });
2898
- expect(error.meta?.fields).toEqual({email: "Invalid email format"});
2899
- });
2900
- });
2901
-
2902
- describe("errorsPlugin", () => {
2903
- it("adds apiErrors field to schema", async () => {
2904
- const mongoose = await import("mongoose");
2905
- const {errorsPlugin} = await import("./errors");
2906
-
2907
- const testSchema = new mongoose.Schema({name: String});
2908
- errorsPlugin(testSchema);
2909
-
2910
- expect(testSchema.path("apiErrors")).toBeDefined();
2911
- });
2912
- });
2913
-
2914
- describe("isAPIError", () => {
2915
- it("returns true for APIError instances", () => {
2916
- const {isAPIError} = require("./errors");
2917
- const error = new APIError({title: "Test"});
2918
- expect(isAPIError(error)).toBe(true);
2919
- });
2920
-
2921
- it("returns false for regular Error instances", () => {
2922
- const {isAPIError} = require("./errors");
2923
- const error = new Error("Test");
2924
- expect(isAPIError(error)).toBe(false);
2925
- });
2926
- });
2927
-
2928
- describe("getDisableExternalErrorTracking", () => {
2929
- it("returns undefined for non-objects", () => {
2930
- const {getDisableExternalErrorTracking} = require("./errors");
2931
- expect(getDisableExternalErrorTracking(null)).toBeUndefined();
2932
- expect(getDisableExternalErrorTracking("string")).toBeUndefined();
2933
- });
2934
-
2935
- it("returns value from APIError", () => {
2936
- const {getDisableExternalErrorTracking} = require("./errors");
2937
- const error = new APIError({disableExternalErrorTracking: true, title: "Test"});
2938
- expect(getDisableExternalErrorTracking(error)).toBe(true);
2939
- });
2940
-
2941
- it("returns value from plain object with property", () => {
2942
- const {getDisableExternalErrorTracking} = require("./errors");
2943
- const obj = {disableExternalErrorTracking: true};
2944
- expect(getDisableExternalErrorTracking(obj)).toBe(true);
2945
- });
2946
- });
2947
-
2948
- describe("getAPIErrorBody", () => {
2949
- it("includes all non-undefined fields", () => {
2950
- const {getAPIErrorBody} = require("./errors");
2951
- const error = new APIError({
2952
- code: "TEST_CODE",
2953
- detail: "Test detail",
2954
- id: "error-123",
2955
- links: {about: "http://example.com"},
2956
- meta: {extra: "data"},
2957
- source: {parameter: "id"},
2958
- status: 400,
2959
- title: "Test error",
2960
- });
2961
- const body = getAPIErrorBody(error);
2962
-
2963
- expect(body.title).toBe("Test error");
2964
- expect(body.status).toBe(400);
2965
- expect(body.code).toBe("TEST_CODE");
2966
- expect(body.detail).toBe("Test detail");
2967
- expect(body.id).toBe("error-123");
2968
- expect(body.links).toEqual({about: "http://example.com"});
2969
- expect(body.source).toEqual({parameter: "id"});
2970
- expect(body.meta).toEqual({extra: "data"});
2971
- });
2972
- });
2973
-
2974
- describe("apiUnauthorizedMiddleware", () => {
2975
- it("returns 401 for Unauthorized errors", () => {
2976
- const {apiUnauthorizedMiddleware} = require("./errors");
2977
- const err = new Error("Unauthorized");
2978
- const res = {
2979
- json: function (data: any) {
2980
- (this as any).body = data;
2981
- return this;
2982
- },
2983
- send: function () {
2984
- return this;
2985
- },
2986
- status: function (code: number) {
2987
- (this as any).statusCode = code;
2988
- return this;
2989
- },
2990
- };
2991
- const next = () => {};
2992
-
2993
- apiUnauthorizedMiddleware(err, {}, res, next);
2994
- expect((res as any).statusCode).toBe(401);
2995
- expect((res as any).body.title).toBe("Unauthorized");
2996
- });
2997
-
2998
- it("calls next for non-Unauthorized errors", () => {
2999
- const {apiUnauthorizedMiddleware} = require("./errors");
3000
- const err = new Error("Some other error");
3001
- let nextCalled = false;
3002
- const next = () => {
3003
- nextCalled = true;
3004
- };
3005
-
3006
- apiUnauthorizedMiddleware(err, {}, {}, next);
3007
- expect(nextCalled).toBe(true);
3008
- });
3009
- });
3010
- });
3011
-
3012
- describe("permissions module", () => {
3013
- describe("OwnerQueryFilter", () => {
3014
- it("returns ownerId filter when user is provided", () => {
3015
- const {OwnerQueryFilter} = require("./permissions");
3016
- const user = {id: "user-123"};
3017
- const filter = OwnerQueryFilter(user);
3018
- expect(filter).toEqual({ownerId: "user-123"});
3019
- });
3020
-
3021
- it("returns null when user is undefined", () => {
3022
- const {OwnerQueryFilter} = require("./permissions");
3023
- const filter = OwnerQueryFilter(undefined);
3024
- expect(filter).toBeNull();
3025
- });
3026
- });
3027
-
3028
- describe("Permissions.IsAuthenticatedOrReadOnly", () => {
3029
- it("returns true for authenticated non-anonymous users", () => {
3030
- const {Permissions} = require("./permissions");
3031
- const user = {id: "user-123", isAnonymous: false};
3032
- expect(Permissions.IsAuthenticatedOrReadOnly("create", user)).toBe(true);
3033
- });
3034
-
3035
- it("returns true for read methods when user is anonymous", () => {
3036
- const {Permissions} = require("./permissions");
3037
- const user = {id: "user-123", isAnonymous: true};
3038
- expect(Permissions.IsAuthenticatedOrReadOnly("list", user)).toBe(true);
3039
- expect(Permissions.IsAuthenticatedOrReadOnly("read", user)).toBe(true);
3040
- });
3041
-
3042
- it("returns false for write methods when user is anonymous", () => {
3043
- const {Permissions} = require("./permissions");
3044
- const user = {id: "user-123", isAnonymous: true};
3045
- expect(Permissions.IsAuthenticatedOrReadOnly("create", user)).toBe(false);
3046
- expect(Permissions.IsAuthenticatedOrReadOnly("update", user)).toBe(false);
3047
- expect(Permissions.IsAuthenticatedOrReadOnly("delete", user)).toBe(false);
3048
- });
3049
- });
3050
-
3051
- describe("Permissions.IsOwnerOrReadOnly", () => {
3052
- it("returns true when no object is provided", () => {
3053
- const {Permissions} = require("./permissions");
3054
- expect(Permissions.IsOwnerOrReadOnly("update", {id: "user-123"}, undefined)).toBe(true);
3055
- });
3056
-
3057
- it("returns true for admin users", () => {
3058
- const {Permissions} = require("./permissions");
3059
- const user = {admin: true, id: "admin-123"};
3060
- const obj = {ownerId: "other-user"};
3061
- expect(Permissions.IsOwnerOrReadOnly("update", user, obj)).toBe(true);
3062
- });
3063
-
3064
- it("returns true when user is owner", () => {
3065
- const {Permissions} = require("./permissions");
3066
- const user = {id: "user-123"};
3067
- const obj = {ownerId: "user-123"};
3068
- expect(Permissions.IsOwnerOrReadOnly("update", user, obj)).toBe(true);
3069
- });
3070
-
3071
- it("returns true for read methods when not owner", () => {
3072
- const {Permissions} = require("./permissions");
3073
- const user = {id: "user-123"};
3074
- const obj = {ownerId: "other-user"};
3075
- expect(Permissions.IsOwnerOrReadOnly("list", user, obj)).toBe(true);
3076
- expect(Permissions.IsOwnerOrReadOnly("read", user, obj)).toBe(true);
3077
- });
3078
-
3079
- it("returns false for write methods when not owner", () => {
3080
- const {Permissions} = require("./permissions");
3081
- const user = {id: "user-123"};
3082
- const obj = {ownerId: "other-user"};
3083
- expect(Permissions.IsOwnerOrReadOnly("update", user, obj)).toBe(false);
3084
- expect(Permissions.IsOwnerOrReadOnly("delete", user, obj)).toBe(false);
3085
- });
3086
- });
3087
- });
3088
-
3089
- describe("utils module", () => {
3090
- describe("isValidObjectId", () => {
3091
- it("returns true for valid ObjectId strings", () => {
3092
- const {isValidObjectId} = require("./utils");
3093
- expect(isValidObjectId("507f1f77bcf86cd799439011")).toBe(true);
3094
- });
3095
-
3096
- it("returns false for invalid ObjectId strings", () => {
3097
- const {isValidObjectId} = require("./utils");
3098
- expect(isValidObjectId("invalid-id")).toBe(false);
3099
- expect(isValidObjectId("12345")).toBe(false);
3100
- expect(isValidObjectId("")).toBe(false);
3101
- });
3102
-
3103
- it("returns false for 12-character strings that are not valid ObjectIds", () => {
3104
- const {isValidObjectId} = require("./utils");
3105
- // mongoose's native isValid returns true for any 12-char string
3106
- // but our implementation should return false since toString won't match
3107
- expect(isValidObjectId("123456789012")).toBe(false);
3108
- });
3109
- });
3110
-
3111
- describe("timeout", () => {
3112
- it("resolves after specified time", async () => {
3113
- const {timeout} = require("./utils");
3114
- const start = Date.now();
3115
- await timeout(50);
3116
- const elapsed = Date.now() - start;
3117
- expect(elapsed).toBeGreaterThanOrEqual(40);
3118
- });
3119
- });
3120
-
3121
- // Note: Comprehensive checkModelsStrict tests are in utils.test.ts with mocked mongoose
3122
- });
3123
-
3124
- describe("populate module", () => {
3125
- describe("unpopulate", () => {
3126
- it("throws error when path is empty", async () => {
3127
- const {unpopulate} = await import("./populate");
3128
- const doc = {name: "test"};
3129
- expect(() => unpopulate(doc as any, "")).toThrow("path is required");
3130
- });
3131
-
3132
- it("unpopulates single populated field", async () => {
3133
- const {unpopulate} = await import("./populate");
3134
- const doc = {
3135
- name: "test",
3136
- ownerId: {_id: "owner-123", email: "owner@test.com"},
3137
- };
3138
- const result = unpopulate(doc as any, "ownerId") as any;
3139
- expect(result.ownerId).toBe("owner-123");
3140
- });
3141
-
3142
- it("unpopulates array of populated fields", async () => {
3143
- const {unpopulate} = await import("./populate");
3144
- const doc = {
3145
- items: [{_id: "item-1", name: "Item 1"}, {_id: "item-2", name: "Item 2"}, "item-3"],
3146
- name: "test",
3147
- };
3148
- const result = unpopulate(doc as any, "items") as any;
3149
- expect(result.items).toEqual(["item-1", "item-2", "item-3"]);
3150
- });
3151
-
3152
- it("handles nested paths", async () => {
3153
- const {unpopulate} = await import("./populate");
3154
- const doc = {
3155
- name: "test",
3156
- nested: {
3157
- items: [
3158
- {_id: "item-1", name: "Item 1"},
3159
- {_id: "item-2", name: "Item 2"},
3160
- ],
3161
- },
3162
- };
3163
- const result = unpopulate(doc as any, "nested.items") as any;
3164
- expect(result.nested.items).toEqual(["item-1", "item-2"]);
3165
- });
3166
-
3167
- it("returns original doc when path does not exist", async () => {
3168
- const {unpopulate} = await import("./populate");
3169
- const doc = {name: "test"};
3170
- const result = unpopulate(doc as any, "nonexistent") as any;
3171
- expect(result).toEqual(doc);
3172
- });
3173
-
3174
- it("handles nested array paths", async () => {
3175
- const {unpopulate} = await import("./populate");
3176
- const doc = {
3177
- containers: [
3178
- {items: [{_id: "item-1"}, {_id: "item-2"}]},
3179
- {items: [{_id: "item-3"}, {_id: "item-4"}]},
3180
- ],
3181
- name: "test",
3182
- };
3183
- const result = unpopulate(doc as any, "containers.items") as any;
3184
- expect(result.containers[0].items).toEqual(["item-1", "item-2"]);
3185
- expect(result.containers[1].items).toEqual(["item-3", "item-4"]);
3186
- });
3187
- });
3188
- });
3189
-
3190
- describe("auth module edge cases", () => {
3191
- describe("generateTokens", () => {
3192
- it("returns null tokens when user is missing", async () => {
3193
- const {generateTokens} = await import("./auth");
3194
- const result = await generateTokens(null);
3195
- expect(result.token).toBeNull();
3196
- expect(result.refreshToken).toBeNull();
3197
- });
3198
-
3199
- it("returns null tokens when user has no _id", async () => {
3200
- const {generateTokens} = await import("./auth");
3201
- const result = await generateTokens({email: "test@test.com"});
3202
- expect(result.token).toBeNull();
3203
- expect(result.refreshToken).toBeNull();
3204
- });
3205
-
3206
- it("includes custom payload from generateJWTPayload option", async () => {
3207
- const {generateTokens} = await import("./auth");
3208
- const jwt = await import("jsonwebtoken");
3209
-
3210
- const user = {_id: "user-123"};
3211
- const result = await generateTokens(user, {
3212
- generateJWTPayload: (u) => ({customField: "customValue", userId: u._id}),
3213
- });
3214
-
3215
- expect(result.token).toBeDefined();
3216
- const decoded = jwt.decode(result.token as string) as any;
3217
- expect(decoded.customField).toBe("customValue");
3218
- expect(decoded.id).toBe("user-123");
3219
- });
3220
-
3221
- it("uses custom token expiration from generateTokenExpiration option", async () => {
3222
- const {generateTokens} = await import("./auth");
3223
- const jwt = await import("jsonwebtoken");
3224
-
3225
- const user = {_id: "user-123"};
3226
- const result = await generateTokens(user, {
3227
- generateTokenExpiration: () => "1h",
3228
- });
3229
-
3230
- expect(result.token).toBeDefined();
3231
- const decoded = jwt.decode(result.token as string) as any;
3232
- // Check that exp is roughly 1 hour from now (within 5 seconds tolerance)
3233
- const expectedExp = Math.floor(Date.now() / 1000) + 3600;
3234
- expect(decoded.exp).toBeGreaterThan(expectedExp - 5);
3235
- expect(decoded.exp).toBeLessThan(expectedExp + 5);
3236
- });
3237
-
3238
- it("uses custom refresh token expiration from generateRefreshTokenExpiration option", async () => {
3239
- const {generateTokens} = await import("./auth");
3240
- const jwt = await import("jsonwebtoken");
3241
-
3242
- const user = {_id: "user-123"};
3243
- const result = await generateTokens(user, {
3244
- generateRefreshTokenExpiration: () => "7d",
3245
- });
3246
-
3247
- expect(result.refreshToken).toBeDefined();
3248
- const decoded = jwt.decode(result.refreshToken as string) as any;
3249
- // Check that exp is roughly 7 days from now
3250
- const expectedExp = Math.floor(Date.now() / 1000) + 7 * 24 * 3600;
3251
- expect(decoded.exp).toBeGreaterThan(expectedExp - 10);
3252
- expect(decoded.exp).toBeLessThan(expectedExp + 10);
3253
- });
3254
- });
3255
870
  });