@terreno/api 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,21 @@
1
1
  import {afterEach, beforeEach, describe, expect, it} from "bun:test";
2
+ import type {ErrorObject} from "ajv";
2
3
 
3
4
  import {modelRouter} from "./api";
4
5
  import {addAuthRoutes, setupAuth} from "./auth";
5
6
  import {
6
7
  buildQuerySchemaFromFields,
7
8
  configureOpenApiValidator,
9
+ createModelValidators,
10
+ createValidator,
11
+ getOpenApiValidatorConfig,
12
+ getSchemaFromModel,
8
13
  isOpenApiValidatorConfigured,
9
14
  resetOpenApiValidatorConfig,
15
+ validateModelRequestBody,
16
+ validateQueryParams,
10
17
  validateRequestBody,
18
+ validateResponseData,
11
19
  } from "./openApiValidator";
12
20
  import {Permissions} from "./permissions";
13
21
  import {authAsUser, FoodModel, getBaseServer, RequiredModel, setupDb, UserModel} from "./tests";
@@ -237,5 +245,407 @@ describe("openApiValidator", () => {
237
245
  middleware(req, res, () => {});
238
246
  }).toThrow();
239
247
  });
248
+
249
+ it("calls onError callback instead of throwing when provided at option level", () => {
250
+ configureOpenApiValidator();
251
+
252
+ let capturedErrors: ErrorObject[] = [];
253
+ const middleware = validateRequestBody(
254
+ {name: {required: true, type: "string"}},
255
+ {
256
+ onError: (errors) => {
257
+ capturedErrors = errors;
258
+ },
259
+ }
260
+ );
261
+
262
+ let nextCalled = false;
263
+ const req = {body: {}, method: "POST", path: "/test"} as any;
264
+ const res = {} as any;
265
+ middleware(req, res, () => {
266
+ nextCalled = true;
267
+ });
268
+
269
+ expect(nextCalled).toBe(true);
270
+ expect(capturedErrors.length).toBeGreaterThan(0);
271
+ });
272
+
273
+ it("calls global onValidationError when no per-route handler", () => {
274
+ let capturedErrors: ErrorObject[] = [];
275
+ configureOpenApiValidator({
276
+ onValidationError: (errors) => {
277
+ capturedErrors = errors;
278
+ },
279
+ });
280
+
281
+ const middleware = validateRequestBody({name: {required: true, type: "string"}});
282
+
283
+ let nextCalled = false;
284
+ const req = {body: {}, method: "POST", path: "/test"} as any;
285
+ const res = {} as any;
286
+ middleware(req, res, () => {
287
+ nextCalled = true;
288
+ });
289
+
290
+ expect(nextCalled).toBe(true);
291
+ expect(capturedErrors.length).toBeGreaterThan(0);
292
+ });
293
+
294
+ it("skips validation when enabled=false on options", () => {
295
+ configureOpenApiValidator();
296
+
297
+ const middleware = validateRequestBody(
298
+ {name: {required: true, type: "string"}},
299
+ {enabled: false}
300
+ );
301
+
302
+ let nextCalled = false;
303
+ const req = {body: {}, method: "POST", path: "/test"} as any;
304
+ const res = {} as any;
305
+ middleware(req, res, () => {
306
+ nextCalled = true;
307
+ });
308
+
309
+ expect(nextCalled).toBe(true);
310
+ });
311
+
312
+ it("respects validateRequests=false in global config", () => {
313
+ configureOpenApiValidator({validateRequests: false});
314
+
315
+ const middleware = validateRequestBody({name: {required: true, type: "string"}});
316
+
317
+ let nextCalled = false;
318
+ const req = {body: {}, method: "POST", path: "/test"} as any;
319
+ const res = {} as any;
320
+ middleware(req, res, () => {
321
+ nextCalled = true;
322
+ });
323
+
324
+ expect(nextCalled).toBe(true);
325
+ });
326
+ });
327
+
328
+ describe("validateQueryParams middleware", () => {
329
+ it("is a no-op when not configured", () => {
330
+ resetOpenApiValidatorConfig();
331
+
332
+ const middleware = validateQueryParams({
333
+ page: {type: "number"},
334
+ });
335
+
336
+ let nextCalled = false;
337
+ const req = {method: "GET", path: "/test", query: {}} as any;
338
+ const res = {} as any;
339
+ middleware(req, res, () => {
340
+ nextCalled = true;
341
+ });
342
+
343
+ expect(nextCalled).toBe(true);
344
+ });
345
+
346
+ it("coerces types when configured", () => {
347
+ configureOpenApiValidator({coerceTypes: true});
348
+
349
+ const middleware = validateQueryParams({
350
+ page: {type: "number"},
351
+ });
352
+
353
+ const req = {method: "GET", path: "/test", query: {page: "3"}} as any;
354
+ const res = {} as any;
355
+ middleware(req, res, () => {});
356
+
357
+ expect(req.query.page).toBe(3);
358
+ });
359
+
360
+ it("throws validation error when query does not match", () => {
361
+ configureOpenApiValidator({coerceTypes: false});
362
+
363
+ const middleware = validateQueryParams({
364
+ page: {required: true, type: "number"},
365
+ });
366
+
367
+ const req = {method: "GET", path: "/test", query: {}} as any;
368
+ const res = {} as any;
369
+ // Required top-level property missing triggers an error
370
+ // We need required: [] at schema level via required: true on property. Confirm via calling.
371
+ expect(() => {
372
+ middleware(req, res, () => {});
373
+ }).toThrow();
374
+ });
375
+
376
+ it("uses onError callback for query validation", () => {
377
+ let captured: any[] = [];
378
+ configureOpenApiValidator({coerceTypes: false});
379
+
380
+ const middleware = validateQueryParams(
381
+ {page: {required: true, type: "number"}},
382
+ {
383
+ onError: (errors) => {
384
+ captured = errors;
385
+ },
386
+ }
387
+ );
388
+
389
+ let nextCalled = false;
390
+ const req = {method: "GET", path: "/test", query: {}} as any;
391
+ const res = {} as any;
392
+ middleware(req, res, () => {
393
+ nextCalled = true;
394
+ });
395
+
396
+ expect(nextCalled).toBe(true);
397
+ expect(captured.length).toBeGreaterThan(0);
398
+ });
399
+
400
+ it("skips query validation when enabled=false", () => {
401
+ configureOpenApiValidator();
402
+
403
+ const middleware = validateQueryParams(
404
+ {page: {required: true, type: "number"}},
405
+ {enabled: false}
406
+ );
407
+
408
+ let nextCalled = false;
409
+ const req = {method: "GET", path: "/test", query: {}} as any;
410
+ const res = {} as any;
411
+ middleware(req, res, () => {
412
+ nextCalled = true;
413
+ });
414
+
415
+ expect(nextCalled).toBe(true);
416
+ });
417
+ });
418
+
419
+ describe("validateResponseData", () => {
420
+ it("returns valid when validateResponses is disabled", () => {
421
+ configureOpenApiValidator({validateResponses: false});
422
+ const result = validateResponseData({foo: "bar"}, {name: {type: "string"}});
423
+ expect(result.valid).toBe(true);
424
+ });
425
+
426
+ it("validates response shape when validateResponses is enabled", () => {
427
+ configureOpenApiValidator({validateResponses: true});
428
+ const result = validateResponseData(
429
+ {name: "Apple"},
430
+ {name: {required: true, type: "string"}}
431
+ );
432
+ expect(result.valid).toBe(true);
433
+ });
434
+
435
+ it("returns errors for invalid response shape", () => {
436
+ configureOpenApiValidator({coerceTypes: false, validateResponses: true});
437
+ const result = validateResponseData(
438
+ {name: 42 as any},
439
+ {name: {required: true, type: "string"}}
440
+ );
441
+ expect(result.valid).toBe(false);
442
+ expect(result.errors).toBeDefined();
443
+ });
444
+ });
445
+
446
+ describe("createValidator", () => {
447
+ it("runs body then query validation and calls next once both pass", () => {
448
+ configureOpenApiValidator({coerceTypes: true});
449
+
450
+ const middleware = createValidator({
451
+ body: {name: {required: true, type: "string"}},
452
+ query: {page: {type: "number"}},
453
+ });
454
+
455
+ let nextCalled = false;
456
+ const req = {
457
+ body: {name: "ok"},
458
+ method: "POST",
459
+ path: "/test",
460
+ query: {page: "2"},
461
+ } as any;
462
+ const res = {} as any;
463
+ middleware(req, res, () => {
464
+ nextCalled = true;
465
+ });
466
+
467
+ expect(nextCalled).toBe(true);
468
+ expect(req.query.page).toBe(2);
469
+ });
470
+
471
+ it("skips when neither body nor query schemas are provided", () => {
472
+ configureOpenApiValidator();
473
+
474
+ const middleware = createValidator({});
475
+
476
+ let nextCalled = false;
477
+ const req = {body: {}, method: "POST", path: "/test", query: {}} as any;
478
+ const res = {} as any;
479
+ middleware(req, res, () => {
480
+ nextCalled = true;
481
+ });
482
+
483
+ expect(nextCalled).toBe(true);
484
+ });
485
+
486
+ it("runs only query validation when only query provided", () => {
487
+ configureOpenApiValidator({coerceTypes: true});
488
+
489
+ const middleware = createValidator({
490
+ query: {page: {type: "number"}},
491
+ });
492
+
493
+ let nextCalled = false;
494
+ const req = {method: "GET", path: "/test", query: {page: "5"}} as any;
495
+ const res = {} as any;
496
+ middleware(req, res, () => {
497
+ nextCalled = true;
498
+ });
499
+
500
+ expect(nextCalled).toBe(true);
501
+ expect(req.query.page).toBe(5);
502
+ });
503
+
504
+ it("propagates body validation error via next", () => {
505
+ let capturedErrors: ErrorObject[] = [];
506
+ configureOpenApiValidator({
507
+ onValidationError: (errors) => {
508
+ capturedErrors = errors;
509
+ },
510
+ });
511
+
512
+ const middleware = createValidator({
513
+ body: {name: {required: true, type: "string"}},
514
+ query: {page: {type: "number"}},
515
+ });
516
+
517
+ const req = {body: {}, method: "POST", path: "/test", query: {}} as any;
518
+ const res = {} as any;
519
+
520
+ let nextCalled = false;
521
+ middleware(req, res, () => {
522
+ nextCalled = true;
523
+ });
524
+
525
+ expect(capturedErrors.length).toBeGreaterThan(0);
526
+ expect(nextCalled).toBe(true);
527
+ });
528
+ });
529
+
530
+ describe("createModelValidators", () => {
531
+ beforeEach(() => {
532
+ configureOpenApiValidator();
533
+ });
534
+
535
+ it("returns create and update middleware", () => {
536
+ const validators = createModelValidators(RequiredModel);
537
+ expect(typeof validators.create).toBe("function");
538
+ expect(typeof validators.update).toBe("function");
539
+ });
540
+
541
+ it("create validator rejects body with wrong type", () => {
542
+ configureOpenApiValidator({coerceTypes: false});
543
+ const {create} = createModelValidators(RequiredModel);
544
+
545
+ const req = {body: {name: 123}, method: "POST", path: "/required"} as any;
546
+ const res = {} as any;
547
+ expect(() => {
548
+ create(req, res, () => {});
549
+ }).toThrow();
550
+ });
551
+
552
+ it("update validator allows a partial body", () => {
553
+ const {update} = createModelValidators(RequiredModel);
554
+
555
+ let nextCalled = false;
556
+ const req = {body: {about: "info"}, method: "PATCH", path: "/required"} as any;
557
+ const res = {} as any;
558
+ update(req, res, () => {
559
+ nextCalled = true;
560
+ });
561
+
562
+ expect(nextCalled).toBe(true);
563
+ });
564
+
565
+ it("supports onAdditionalPropertiesRemoved option", () => {
566
+ configureOpenApiValidator({removeAdditional: true});
567
+ const removedProps: string[] = [];
568
+ const {create} = createModelValidators(RequiredModel, {
569
+ onAdditionalPropertiesRemoved: (props) => {
570
+ removedProps.push(...props);
571
+ },
572
+ });
573
+
574
+ // Extra property gets stripped and hook is called
575
+ const req = {
576
+ body: {extra: "strip me", name: "Apple"},
577
+ method: "POST",
578
+ path: "/required",
579
+ } as any;
580
+ create(req, {} as any, () => {});
581
+ expect(removedProps).toContain("extra");
582
+ });
583
+
584
+ it("supports onError option", () => {
585
+ configureOpenApiValidator({coerceTypes: false});
586
+ let errorHandled: any[] = [];
587
+ const {create} = createModelValidators(RequiredModel, {
588
+ onError: (errors) => {
589
+ errorHandled = errors;
590
+ },
591
+ });
592
+
593
+ // Wrong type triggers error handler
594
+ const req = {body: {name: 42}, method: "POST", path: "/required"} as any;
595
+ create(req, {} as any, () => {});
596
+ expect(errorHandled.length).toBeGreaterThan(0);
597
+ });
598
+ });
599
+
600
+ describe("validateModelRequestBody", () => {
601
+ beforeEach(() => {
602
+ configureOpenApiValidator();
603
+ });
604
+
605
+ it("creates a validator from a Mongoose model", () => {
606
+ const middleware = validateModelRequestBody(RequiredModel);
607
+
608
+ const req = {body: {}, method: "POST", path: "/required"} as any;
609
+ const res = {} as any;
610
+ expect(() => {
611
+ middleware(req, res, () => {});
612
+ }).toThrow();
613
+ });
614
+
615
+ it("respects excludeFields option", () => {
616
+ const middleware = validateModelRequestBody(RequiredModel, {
617
+ excludeFields: ["name"],
618
+ });
619
+
620
+ // Without 'name' required, empty body passes
621
+ let nextCalled = false;
622
+ const req = {body: {}, method: "POST", path: "/required"} as any;
623
+ const res = {} as any;
624
+ middleware(req, res, () => {
625
+ nextCalled = true;
626
+ });
627
+
628
+ expect(nextCalled).toBe(true);
629
+ });
630
+ });
631
+
632
+ describe("getSchemaFromModel", () => {
633
+ it("returns properties for a Mongoose model", () => {
634
+ const schema = getSchemaFromModel(RequiredModel);
635
+ expect(schema.name).toBeDefined();
636
+ expect(schema.about).toBeDefined();
637
+ });
638
+ });
639
+
640
+ describe("getOpenApiValidatorConfig", () => {
641
+ it("returns a shallow copy of the config", () => {
642
+ configureOpenApiValidator({removeAdditional: false});
643
+ const config = getOpenApiValidatorConfig();
644
+ expect(config.removeAdditional).toBe(false);
645
+
646
+ // Mutating the returned copy should not affect internal state
647
+ (config as any).removeAdditional = true;
648
+ expect(getOpenApiValidatorConfig().removeAdditional).toBe(false);
649
+ });
240
650
  });
241
651
  });
package/src/plugins.ts CHANGED
@@ -15,10 +15,12 @@ export interface BaseUser {
15
15
  email: string;
16
16
  }
17
17
 
18
- export function baseUserPlugin(schema: Schema<any, any, any, any>) {
19
- schema.add({admin: {default: false, type: Boolean}});
20
- schema.add({email: {index: true, type: String}});
21
- }
18
+ export const baseUserPlugin = (schema: Schema<any, any, any, any>): void => {
19
+ schema.add({
20
+ admin: {default: false, description: "Whether the user has admin privileges", type: Boolean},
21
+ });
22
+ schema.add({email: {description: "The user's email address", index: true, type: String}});
23
+ };
22
24
 
23
25
  /** For models with the isDeletedPlugin, extend this interface to add the appropriate fields. */
24
26
  export interface IsDeleted {
@@ -26,7 +28,7 @@ export interface IsDeleted {
26
28
  deleted: boolean;
27
29
  }
28
30
 
29
- export function isDeletedPlugin(schema: Schema<any, any, any, any>, defaultValue = false) {
31
+ export const isDeletedPlugin = (schema: Schema<any, any, any, any>, defaultValue = false): void => {
30
32
  schema.add({
31
33
  deleted: {
32
34
  default: defaultValue,
@@ -37,21 +39,24 @@ export function isDeletedPlugin(schema: Schema<any, any, any, any>, defaultValue
37
39
  type: Boolean,
38
40
  },
39
41
  });
40
- function applyDeleteFilter(q: Query<any, any>) {
42
+ const applyDeleteFilter = (q: Query<any, any>): void => {
41
43
  const query = q.getQuery();
42
44
  if (query && query.deleted === undefined) {
43
45
  void q.where({deleted: {$ne: true}});
44
46
  }
45
- }
47
+ };
46
48
  schema.pre("find", function () {
47
49
  applyDeleteFilter(this);
48
50
  });
49
51
  schema.pre("findOne", function () {
50
52
  applyDeleteFilter(this);
51
53
  });
52
- }
54
+ };
53
55
 
54
- export function isDisabledPlugin(schema: Schema<any, any, any, any>, defaultValue = false) {
56
+ export const isDisabledPlugin = (
57
+ schema: Schema<any, any, any, any>,
58
+ defaultValue = false
59
+ ): void => {
55
60
  schema.add({
56
61
  disabled: {
57
62
  default: defaultValue,
@@ -60,16 +65,18 @@ export function isDisabledPlugin(schema: Schema<any, any, any, any>, defaultValu
60
65
  type: Boolean,
61
66
  },
62
67
  });
63
- }
68
+ };
64
69
 
65
70
  export interface CreatedDeleted {
66
71
  updated: {type: Date; required: true};
67
72
  created: {type: Date; required: true};
68
73
  }
69
74
 
70
- export function createdUpdatedPlugin(schema: Schema<any, any, any, any>) {
71
- schema.add({updated: {index: true, type: Date}});
72
- schema.add({created: {index: true, type: Date}});
75
+ export const createdUpdatedPlugin = (schema: Schema<any, any, any, any>): void => {
76
+ schema.add({
77
+ updated: {description: "When this document was last updated", index: true, type: Date},
78
+ });
79
+ schema.add({created: {description: "When this document was created", index: true, type: Date}});
73
80
 
74
81
  schema.pre("save", function () {
75
82
  if (this.disableCreatedUpdatedPlugin === true) {
@@ -86,11 +93,13 @@ export function createdUpdatedPlugin(schema: Schema<any, any, any, any>) {
86
93
  schema.pre(/save|updateOne|insertMany/, function () {
87
94
  void this.updateOne({}, {$set: {updated: new Date()}});
88
95
  });
89
- }
96
+ };
90
97
 
91
- export function firebaseJWTPlugin(schema: Schema) {
92
- schema.add({firebaseId: {index: true, type: String}});
93
- }
98
+ export const firebaseJWTPlugin = (schema: Schema): void => {
99
+ schema.add({
100
+ firebaseId: {description: "The user's Firebase authentication ID", index: true, type: String},
101
+ });
102
+ };
94
103
 
95
104
  /**
96
105
  * This adds a static method `Model.findOneOrNone` to the schema. This should replace `Model.findOne` in most instances.
@@ -99,7 +108,7 @@ export function firebaseJWTPlugin(schema: Schema) {
99
108
  * document, or throws an exception if multiple are found.
100
109
  * @param schema Mongoose Schema
101
110
  */
102
- export function findOneOrNone<T>(schema: Schema<T>) {
111
+ export const findOneOrNone = <T>(schema: Schema<T>): void => {
103
112
  schema.statics.findOneOrNone = async function (
104
113
  query: Record<string, any>,
105
114
  errorArgs?: Partial<APIErrorConstructor>
@@ -118,7 +127,7 @@ export function findOneOrNone<T>(schema: Schema<T>) {
118
127
  }
119
128
  return results[0];
120
129
  };
121
- }
130
+ };
122
131
 
123
132
  /**
124
133
  * This adds a static method `Model.findExactlyOne` to the schema. This or findOneOrNone should replace `Model.findOne`
@@ -128,7 +137,7 @@ export function findOneOrNone<T>(schema: Schema<T>) {
128
137
  * multiple or none are found.
129
138
  * @param schema Mongoose Schema
130
139
  */
131
- export function findExactlyOne<T>(schema: Schema<T>) {
140
+ export const findExactlyOne = <T>(schema: Schema<T>): void => {
132
141
  schema.statics.findExactlyOne = async function (
133
142
  query: Record<string, any>,
134
143
  errorArgs?: Partial<APIErrorConstructor>
@@ -152,7 +161,7 @@ export function findExactlyOne<T>(schema: Schema<T>) {
152
161
  }
153
162
  return results[0];
154
163
  };
155
- }
164
+ };
156
165
 
157
166
  /**
158
167
  * This adds a static method `Model.upsert` to the schema. This method will either update an existing document
@@ -160,7 +169,7 @@ export function findExactlyOne<T>(schema: Schema<T>) {
160
169
  * match the conditions to prevent ambiguous updates.
161
170
  * @param schema Mongoose Schema
162
171
  */
163
- export function upsertPlugin<T>(schema: Schema<any, any, any, any>) {
172
+ export const upsertPlugin = <T>(schema: Schema<any, any, any, any>): void => {
164
173
  schema.statics.upsert = async function (
165
174
  conditions: Record<string, any>,
166
175
  update: Record<string, any>
@@ -187,7 +196,7 @@ export function upsertPlugin<T>(schema: Schema<any, any, any, any>) {
187
196
  const newDoc = new this(combinedData);
188
197
  return newDoc.save();
189
198
  };
190
- }
199
+ };
191
200
 
192
201
  /** For models with the upsertPlugin, extend this interface to add the upsert static method. */
193
202
  export interface HasUpsert<T> {
@@ -1,7 +1,8 @@
1
1
  import {beforeEach, describe, expect, it} from "bun:test";
2
+ import mongoose, {Schema} from "mongoose";
2
3
 
3
- import {unpopulate} from "./populate";
4
- import {FoodModel, setupDb} from "./tests";
4
+ import {fixMixedFields, getOpenApiSpecForModel, unpopulate} from "./populate";
5
+ import {FoodModel, setupDb, UserModel} from "./tests";
5
6
 
6
7
  describe("populate functions", () => {
7
8
  let admin: any;
@@ -121,3 +122,78 @@ describe("unpopulate edge cases", () => {
121
122
  expect(result.containers[1].items).toEqual(["item-3", "item-4"]);
122
123
  });
123
124
  });
125
+
126
+ describe("fixMixedFields", () => {
127
+ it("returns early when schema is missing", () => {
128
+ const properties = {foo: {type: "object"}};
129
+ expect(() => fixMixedFields(null, properties)).not.toThrow();
130
+ });
131
+
132
+ it("returns early when properties is missing", () => {
133
+ const schema = new Schema({});
134
+ expect(() => fixMixedFields(schema, null as any)).not.toThrow();
135
+ });
136
+
137
+ it("replaces Mixed fields with only description", () => {
138
+ const schema = new Schema({data: {description: "any data", type: Schema.Types.Mixed}});
139
+ const properties: any = {data: {description: "any data", type: "object"}};
140
+ fixMixedFields(schema, properties);
141
+ expect(properties.data).toEqual({description: "any data"});
142
+ });
143
+
144
+ it("recurses into arrays of sub-documents", () => {
145
+ const subSchema = new Schema({meta: {type: Schema.Types.Mixed}});
146
+ const schema = new Schema({items: [subSchema]});
147
+ const properties: any = {
148
+ items: {
149
+ items: {
150
+ properties: {
151
+ meta: {type: "object"},
152
+ },
153
+ },
154
+ type: "array",
155
+ },
156
+ };
157
+ fixMixedFields(schema, properties);
158
+ expect(properties.items.items.properties.meta).toEqual({description: undefined});
159
+ });
160
+
161
+ it("skips unknown paths", () => {
162
+ const schema = new Schema({foo: String});
163
+ const properties = {unknownKey: {type: "string"}};
164
+ expect(() => fixMixedFields(schema, properties)).not.toThrow();
165
+ });
166
+ });
167
+
168
+ describe("getOpenApiSpecForModel edge cases", () => {
169
+ it("returns model properties without populatePaths", () => {
170
+ const result = getOpenApiSpecForModel(UserModel);
171
+ expect(result.properties).toBeDefined();
172
+ });
173
+
174
+ it("returns with extraModelProperties merged", () => {
175
+ const result = getOpenApiSpecForModel(UserModel, {
176
+ extraModelProperties: {customField: {type: "string"}},
177
+ });
178
+ expect(result.properties.customField).toEqual({type: "string"});
179
+ });
180
+
181
+ it("skips populate paths without ref", () => {
182
+ // Create a schema with a non-referenced ObjectId field
183
+ const testSchema = new Schema({name: String, simpleId: Schema.Types.ObjectId});
184
+ const TestModelNoRef =
185
+ mongoose.models.TestModelNoRef || mongoose.model("TestModelNoRef", testSchema);
186
+ const result = getOpenApiSpecForModel(TestModelNoRef, {
187
+ populatePaths: [{path: "simpleId"}],
188
+ });
189
+ // Should not throw, simpleId stays as-is
190
+ expect(result.properties).toBeDefined();
191
+ });
192
+
193
+ it("populates with fields allowlist", () => {
194
+ const result = getOpenApiSpecForModel(FoodModel, {
195
+ populatePaths: [{fields: ["name"], path: "ownerId"}],
196
+ });
197
+ expect(result.properties).toBeDefined();
198
+ });
199
+ });