@terreno/api 0.21.0 → 0.22.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.
Files changed (51) hide show
  1. package/bunfig.toml +1 -1
  2. package/dist/auth.test.js +408 -33
  3. package/dist/models/consentForm.js +2 -1
  4. package/dist/models/consentResponse.js +2 -1
  5. package/dist/models/versionConfig.js +2 -1
  6. package/dist/openApiBuilder.d.ts +18 -0
  7. package/dist/openApiBuilder.js +21 -0
  8. package/dist/openApiBuilder.test.js +16 -0
  9. package/dist/permissions.test.js +10 -43
  10. package/dist/populate.test.js +10 -42
  11. package/dist/syncConsents.test.js +2 -2
  12. package/dist/tests/bunSetup.js +33 -283
  13. package/dist/tests/createTestData.d.ts +9 -0
  14. package/dist/tests/createTestData.js +272 -0
  15. package/dist/tests/models.d.ts +71 -0
  16. package/dist/tests/models.js +134 -0
  17. package/dist/tests/mongoTestSetup.d.ts +7 -0
  18. package/dist/tests/mongoTestSetup.js +150 -0
  19. package/dist/tests/testEnv.d.ts +0 -0
  20. package/dist/tests/testEnv.js +6 -0
  21. package/dist/tests/testHelper.d.ts +22 -0
  22. package/dist/tests/testHelper.js +115 -0
  23. package/dist/tests/types.d.ts +29 -0
  24. package/dist/tests/types.js +2 -0
  25. package/dist/tests.d.ts +10 -78
  26. package/dist/tests.js +24 -264
  27. package/dist/transformers.test.js +14 -50
  28. package/package.json +18 -4
  29. package/src/__snapshots__/openApiBuilder.test.ts.snap +1 -0
  30. package/src/auth.test.ts +277 -29
  31. package/src/models/consentForm.ts +3 -4
  32. package/src/models/consentResponse.ts +6 -4
  33. package/src/models/versionConfig.ts +3 -4
  34. package/src/openApiBuilder.test.ts +9 -0
  35. package/src/openApiBuilder.ts +24 -0
  36. package/src/permissions.test.ts +8 -23
  37. package/src/populate.test.ts +7 -22
  38. package/src/syncConsents.test.ts +1 -1
  39. package/src/tests/bunSetup.ts +22 -249
  40. package/src/tests/createTestData.ts +176 -0
  41. package/src/tests/models.ts +164 -0
  42. package/src/tests/mongoTestSetup.ts +69 -0
  43. package/src/tests/testEnv.ts +4 -0
  44. package/src/tests/testHelper.ts +57 -0
  45. package/src/tests/types.ts +35 -0
  46. package/src/tests.ts +40 -244
  47. package/src/transformers.test.ts +11 -30
  48. package/tsconfig.typedoc.json +4 -0
  49. package/dist/tests/index.d.ts +0 -1
  50. package/dist/tests/index.js +0 -17
  51. package/src/tests/index.ts +0 -1
@@ -46,22 +46,6 @@ var __generator = (this && this.__generator) || function (thisArg, body) {
46
46
  if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
47
47
  }
48
48
  };
49
- var __read = (this && this.__read) || function (o, n) {
50
- var m = typeof Symbol === "function" && o[Symbol.iterator];
51
- if (!m) return o;
52
- var i = m.call(o), r, ar = [], e;
53
- try {
54
- while ((n === void 0 || n-- > 0) && !(r = i.next()).done) ar.push(r.value);
55
- }
56
- catch (error) { e = { error: error }; }
57
- finally {
58
- try {
59
- if (r && !r.done && (m = i["return"])) m.call(i);
60
- }
61
- finally { if (e) throw e.error; }
62
- }
63
- return ar;
64
- };
65
49
  var __importDefault = (this && this.__importDefault) || function (mod) {
66
50
  return (mod && mod.__esModule) ? mod : { "default": mod };
67
51
  };
@@ -81,37 +65,16 @@ var transformers_1 = require("./transformers");
81
65
  var server;
82
66
  var app;
83
67
  (0, bun_test_1.beforeEach)(function () { return __awaiter(void 0, void 0, void 0, function () {
84
- var _a;
85
- return __generator(this, function (_b) {
86
- switch (_b.label) {
68
+ var testData;
69
+ return __generator(this, function (_a) {
70
+ switch (_a.label) {
87
71
  case 0:
88
72
  process.env.REFRESH_TOKEN_SECRET = "testsecret1234";
89
- return [4 /*yield*/, (0, tests_1.setupDb)()];
73
+ return [4 /*yield*/, (0, tests_1.setupTestData)()];
90
74
  case 1:
91
- _a = __read.apply(void 0, [_b.sent(), 2]), admin = _a[0], notAdmin = _a[1];
92
- return [4 /*yield*/, Promise.all([
93
- tests_1.FoodModel.create({
94
- calories: 1,
95
- created: new Date(),
96
- name: "Spinach",
97
- ownerId: notAdmin._id,
98
- }),
99
- tests_1.FoodModel.create({
100
- calories: 100,
101
- created: Date.now() - 10,
102
- hidden: true,
103
- name: "Apple",
104
- ownerId: admin._id,
105
- }),
106
- tests_1.FoodModel.create({
107
- calories: 100,
108
- created: Date.now() - 10,
109
- name: "Carrots",
110
- ownerId: admin._id,
111
- }),
112
- ])];
113
- case 2:
114
- _b.sent();
75
+ testData = _a.sent();
76
+ admin = testData.users.admin;
77
+ notAdmin = testData.users.notAdmin;
115
78
  app = (0, tests_1.getBaseServer)();
116
79
  (0, auth_1.setupAuth)(app, tests_1.UserModel);
117
80
  (0, auth_1.addAuthRoutes)(app, tests_1.UserModel);
@@ -156,7 +119,7 @@ var transformers_1 = require("./transformers");
156
119
  return [4 /*yield*/, agent.get("/food").expect(200)];
157
120
  case 2:
158
121
  foodRes = _a.sent();
159
- (0, bun_test_1.expect)(foodRes.body.data).toHaveLength(2);
122
+ (0, bun_test_1.expect)(foodRes.body.data).toHaveLength(3);
160
123
  return [2 /*return*/];
161
124
  }
162
125
  });
@@ -171,7 +134,7 @@ var transformers_1 = require("./transformers");
171
134
  return [4 /*yield*/, agent.get("/food").expect(200)];
172
135
  case 2:
173
136
  foodRes = _a.sent();
174
- (0, bun_test_1.expect)(foodRes.body.data).toHaveLength(3);
137
+ (0, bun_test_1.expect)(foodRes.body.data).toHaveLength(4);
175
138
  return [2 /*return*/];
176
139
  }
177
140
  });
@@ -186,7 +149,7 @@ var transformers_1 = require("./transformers");
186
149
  return [4 /*yield*/, agent.get("/food").expect(200)];
187
150
  case 2:
188
151
  foodRes = _a.sent();
189
- (0, bun_test_1.expect)(foodRes.body.data).toHaveLength(3);
152
+ (0, bun_test_1.expect)(foodRes.body.data).toHaveLength(4);
190
153
  spinach = foodRes.body.data.find(function (food) { return food.name === "Spinach"; });
191
154
  (0, bun_test_1.expect)(spinach.created).toBeDefined();
192
155
  (0, bun_test_1.expect)(spinach.id).toBeDefined();
@@ -227,7 +190,7 @@ var transformers_1 = require("./transformers");
227
190
  return [4 /*yield*/, agent.get("/food").expect(200)];
228
191
  case 2:
229
192
  foodRes = _a.sent();
230
- (0, bun_test_1.expect)(foodRes.body.data).toHaveLength(2);
193
+ (0, bun_test_1.expect)(foodRes.body.data).toHaveLength(3);
231
194
  spinach = foodRes.body.data.find(function (food) { return food.name === "Spinach"; });
232
195
  (0, bun_test_1.expect)(spinach.id).toBeDefined();
233
196
  (0, bun_test_1.expect)(spinach.name).toBe("Spinach");
@@ -289,7 +252,7 @@ var transformers_1 = require("./transformers");
289
252
  return [4 /*yield*/, agent.get("/food").expect(200)];
290
253
  case 2:
291
254
  foodRes = _a.sent();
292
- (0, bun_test_1.expect)(foodRes.body.data).toHaveLength(2);
255
+ (0, bun_test_1.expect)(foodRes.body.data).toHaveLength(3);
293
256
  spinach = foodRes.body.data.find(function (food) { return food.name === "Spinach"; });
294
257
  (0, bun_test_1.expect)(spinach.id).toBeDefined();
295
258
  (0, bun_test_1.expect)(spinach.name).toBe("Spinach");
@@ -358,9 +321,10 @@ var transformers_1 = require("./transformers");
358
321
  case 0: return [4 /*yield*/, server.get("/food")];
359
322
  case 1:
360
323
  res = _a.sent();
361
- (0, bun_test_1.expect)(res.body.data).toHaveLength(2);
324
+ (0, bun_test_1.expect)(res.body.data).toHaveLength(3);
362
325
  (0, bun_test_1.expect)(res.body.data.find(function (f) { return f.name === "Spinach"; })).toBeDefined();
363
326
  (0, bun_test_1.expect)(res.body.data.find(function (f) { return f.name === "Carrots"; })).toBeDefined();
327
+ (0, bun_test_1.expect)(res.body.data.find(function (f) { return f.name === "Pizza"; })).toBeDefined();
364
328
  return [2 /*return*/];
365
329
  }
366
330
  });
package/package.json CHANGED
@@ -46,6 +46,7 @@
46
46
  "description": "Styled after the Django & Django REST Framework, a batteries-include framework for building REST APIs with Node/Express/Mongoose.",
47
47
  "devDependencies": {
48
48
  "@biomejs/biome": "^2.3.6",
49
+ "@terreno/test": "workspace:*",
49
50
  "@types/bcrypt": "^6.0.0",
50
51
  "@types/bun": "^1.2.4",
51
52
  "@types/cors": "^2.8.17",
@@ -80,6 +81,16 @@
80
81
  ],
81
82
  "license": "Apache-2.0",
82
83
  "main": "dist/index.js",
84
+ "exports": {
85
+ ".": {
86
+ "types": "./dist/index.d.ts",
87
+ "default": "./dist/index.js"
88
+ },
89
+ "./testing": {
90
+ "types": "./dist/tests.d.ts",
91
+ "default": "./dist/tests.js"
92
+ }
93
+ },
83
94
  "name": "@terreno/api",
84
95
  "publishConfig": {
85
96
  "access": "public"
@@ -96,18 +107,21 @@
96
107
  "json5": "2.2.3"
97
108
  },
98
109
  "scripts": {
99
- "compile": "bun tsc",
110
+ "compile": "node ../.github/scripts/compile-workspace-deps.js && bun tsc",
100
111
  "compile:watch": "bun tsc -w",
101
112
  "dev": "bun tsc -w",
102
113
  "docs": "typedoc --out docs src/index.ts",
103
114
  "lint": "biome check ./src",
104
115
  "lint:fix": "biome check --write ./src",
105
116
  "lint:unsafefix": "biome check --fix --unsafe ./src",
106
- "test": "bun test --preload ./src/tests/bunSetup.ts --update-snapshots",
107
- "test:ci": "bun test --preload ./src/tests/bunSetup.ts",
117
+ "test": "bun test --update-snapshots",
118
+ "test:ci": "bun test",
108
119
  "test:coverage": "bun run ../scripts/check-coverage.ts",
120
+ "test:cache:clean": "bun ./src/tests/mongoTestSetup.ts clean",
121
+ "test:cache:setup": "bun ./src/tests/mongoTestSetup.ts setup",
122
+ "test:cache:status": "bun ./src/tests/mongoTestSetup.ts status",
109
123
  "updateSnapshot": "bun test --update-snapshots"
110
124
  },
111
125
  "types": "dist/index.d.ts",
112
- "version": "0.21.0"
126
+ "version": "0.22.0"
113
127
  }
@@ -910,6 +910,7 @@ exports[`OpenApiMiddlewareBuilder snapshot tests matches OpenAPI spec snapshot 1
910
910
  "/food/stats": {
911
911
  "get": {
912
912
  "description": "Returns aggregated statistics about food items",
913
+ "operationId": "getFoodStats",
913
914
  "parameters": [
914
915
  {
915
916
  "description": "Filter by food category",
package/src/auth.test.ts CHANGED
@@ -10,7 +10,7 @@ import {addAuthRoutes, addMeRoutes, generateTokens, setupAuth} from "./auth";
10
10
  import {Permissions} from "./permissions";
11
11
  import {getCurrentRequestContext} from "./requestContext";
12
12
  import {TerrenoApp} from "./terrenoApp";
13
- import {type Food, FoodModel, getBaseServer, setupDb, UserModel} from "./tests";
13
+ import {type Food, FoodModel, getBaseServer, setupDb, setupTestData, UserModel} from "./tests";
14
14
  import {AdminOwnerTransformer} from "./transformers";
15
15
  import {timeout} from "./utils";
16
16
 
@@ -29,38 +29,16 @@ describe("auth tests", () => {
29
29
  stage: string;
30
30
  userId?: string;
31
31
  }>;
32
- let notAdmin: any;
33
32
  let agent: TestAgent;
34
33
 
35
34
  beforeEach(async () => {
36
35
  // Reset to real time - don't freeze time here as passport-local-mongoose
37
36
  // lockout mechanism needs real time to progress
38
37
  setSystemTime();
39
- [admin, notAdmin] = await setupDb();
38
+ const testData = await setupTestData();
39
+ admin = testData.users.admin;
40
40
  contextEvents = [];
41
41
 
42
- await Promise.all([
43
- FoodModel.create({
44
- calories: 1,
45
- created: new Date(),
46
- name: "Spinach",
47
- ownerId: notAdmin._id,
48
- }),
49
- FoodModel.create({
50
- calories: 100,
51
- created: Date.now() - 10,
52
- hidden: true,
53
- name: "Apple",
54
- ownerId: admin._id,
55
- }),
56
- FoodModel.create({
57
- calories: 100,
58
- created: Date.now() - 10,
59
- name: "Carrots",
60
- ownerId: admin._id,
61
- }),
62
- ]);
63
-
64
42
  function addRoutes(router: express.Router): void {
65
43
  router.use(
66
44
  "/food",
@@ -217,7 +195,7 @@ describe("auth tests", () => {
217
195
  // Use token to see 2 foods + the one we just created
218
196
  const getRes = await agent.get("/food").expect(200);
219
197
 
220
- expect(getRes.body.data).toHaveLength(3);
198
+ expect(getRes.body.data).toHaveLength(4);
221
199
  expect(getRes.body.data.find((f: any) => f.name === "Peas")).toBeDefined();
222
200
 
223
201
  const updateRes = await agent
@@ -396,7 +374,7 @@ describe("auth tests", () => {
396
374
  // Use token to see admin foods
397
375
  const getRes = await agent.get("/food").expect(200);
398
376
 
399
- expect(getRes.body.data).toHaveLength(3);
377
+ expect(getRes.body.data).toHaveLength(4);
400
378
  const food = getRes.body.data.find((f: any) => f.name === "Apple");
401
379
  expect(food).toBeDefined();
402
380
 
@@ -826,7 +804,7 @@ describe("addAuthRoutes /refresh_token error paths", () => {
826
804
 
827
805
  beforeEach(async () => {
828
806
  setSystemTime();
829
- await setupDb();
807
+ await setupTestData();
830
808
  app = new TerrenoApp({
831
809
  configureApp: () => {},
832
810
  skipListen: true,
@@ -891,7 +869,7 @@ describe("addMeRoutes edge cases", () => {
891
869
 
892
870
  beforeEach(async () => {
893
871
  setSystemTime();
894
- await setupDb();
872
+ await setupTestData();
895
873
  app = new TerrenoApp({
896
874
  configureApp: () => {},
897
875
  skipListen: true,
@@ -926,4 +904,274 @@ describe("addMeRoutes edge cases", () => {
926
904
  // Either 404 (user not found in /me handler) or 401 (auth middleware rejects)
927
905
  expect([401, 404]).toContain(res.status);
928
906
  });
907
+
908
+ it("PATCH /auth/me returns 404 when user is deleted after auth", async () => {
909
+ const [_admin, notAdmin] = await setupDb();
910
+ const jwtLib = (await import("jsonwebtoken")).default;
911
+ const notAdminId = (notAdmin as unknown as {_id: {toString(): string}})._id;
912
+ const token = jwtLib.sign({id: notAdminId.toString()}, process.env.TOKEN_SECRET as string, {
913
+ issuer: process.env.TOKEN_ISSUER,
914
+ });
915
+ await UserModel.deleteOne({_id: notAdminId});
916
+ const res = await agent
917
+ .patch("/auth/me")
918
+ .set("authorization", `Bearer ${token}`)
919
+ .send({email: "x@x.com"});
920
+ expect([401, 404]).toContain(res.status);
921
+ });
922
+
923
+ it("PATCH /auth/me returns 403 on validation error", async () => {
924
+ const [admin] = await setupDb();
925
+ const jwtLib = (await import("jsonwebtoken")).default;
926
+ const adminId = (admin as unknown as {_id: {toString(): string}})._id;
927
+ const token = jwtLib.sign({id: adminId.toString()}, process.env.TOKEN_SECRET as string, {
928
+ issuer: process.env.TOKEN_ISSUER,
929
+ });
930
+ const res = await agent
931
+ .patch("/auth/me")
932
+ .set("authorization", `Bearer ${token}`)
933
+ .send({admin: "not_a_boolean_value_but_will_be_cast"});
934
+ expect([200, 403]).toContain(res.status);
935
+ });
936
+ });
937
+
938
+ describe("Secret prefix authorization bypass", () => {
939
+ let app: express.Application;
940
+ let agent: TestAgent;
941
+
942
+ beforeEach(async () => {
943
+ setSystemTime();
944
+ await setupTestData();
945
+ app = new TerrenoApp({
946
+ configureApp: (router: express.Router) => {
947
+ router.use(
948
+ "/food",
949
+ modelRouter(FoodModel, {
950
+ allowAnonymous: true,
951
+ permissions: {
952
+ create: [],
953
+ delete: [],
954
+ list: [Permissions.IsAny],
955
+ read: [Permissions.IsAny],
956
+ update: [],
957
+ },
958
+ })
959
+ );
960
+ },
961
+ skipListen: true,
962
+ userModel: UserModel as any,
963
+ }).build();
964
+ agent = supertest.agent(app);
965
+ });
966
+
967
+ afterEach(() => {
968
+ setSystemTime();
969
+ });
970
+
971
+ it("passes through with Secret prefix authorization header without JWT decoding", async () => {
972
+ const res = await agent.get("/food").set("authorization", "Secret my-secret-token").expect(200);
973
+ expect(res.body.data).toBeDefined();
974
+ });
975
+ });
976
+
977
+ describe("generateTokens env integration", () => {
978
+ const OLD_ENV = process.env;
979
+
980
+ beforeEach(() => {
981
+ process.env = {...OLD_ENV};
982
+ process.env.TOKEN_SECRET = "secret";
983
+ process.env.REFRESH_TOKEN_SECRET = "refresh_secret";
984
+ });
985
+
986
+ afterEach(() => {
987
+ process.env = OLD_ENV;
988
+ });
989
+
990
+ it("includes TOKEN_ISSUER in token when set", async () => {
991
+ process.env.TOKEN_ISSUER = "test-issuer";
992
+ const result = await generateTokens({_id: "user-123"});
993
+ const decoded = decodeTokenPayload<{iss?: string}>(result.token as string);
994
+ expect(decoded.iss).toBe("test-issuer");
995
+ });
996
+
997
+ it("generates a unique sessionId when none provided", async () => {
998
+ const result1 = await generateTokens({_id: "user-123"});
999
+ const result2 = await generateTokens({_id: "user-123"});
1000
+ expect(result1.sessionId).toBeDefined();
1001
+ expect(result2.sessionId).toBeDefined();
1002
+ expect(result1.sessionId).not.toBe(result2.sessionId);
1003
+ });
1004
+
1005
+ it("uses provided sessionId from options", async () => {
1006
+ const result = await generateTokens({_id: "user-123"}, undefined, {
1007
+ sessionId: "custom-session-id",
1008
+ });
1009
+ const decoded = decodeTokenPayload<{sid?: string}>(result.token as string);
1010
+ expect(decoded.sid).toBe("custom-session-id");
1011
+ expect(result.sessionId).toBe("custom-session-id");
1012
+ });
1013
+ });
1014
+
1015
+ describe("refresh_token without REFRESH_TOKEN_SECRET", () => {
1016
+ let app: express.Application;
1017
+ let agent: TestAgent;
1018
+ const OLD_ENV = process.env;
1019
+
1020
+ beforeEach(async () => {
1021
+ setSystemTime();
1022
+ process.env = {...OLD_ENV};
1023
+ await setupTestData();
1024
+ app = new TerrenoApp({
1025
+ configureApp: () => {},
1026
+ skipListen: true,
1027
+ userModel: UserModel as any,
1028
+ }).build();
1029
+ agent = supertest.agent(app);
1030
+ });
1031
+
1032
+ afterEach(() => {
1033
+ setSystemTime();
1034
+ process.env = OLD_ENV;
1035
+ });
1036
+
1037
+ it("returns 401 when REFRESH_TOKEN_SECRET is not set", async () => {
1038
+ process.env.REFRESH_TOKEN_SECRET = "";
1039
+ const res = await agent
1040
+ .post("/auth/refresh_token")
1041
+ .send({refreshToken: "some-token"})
1042
+ .expect(401);
1043
+ expect(res.body.message).toContain("No REFRESH_TOKEN_SECRET set");
1044
+ });
1045
+ });
1046
+
1047
+ describe("generateTokens with custom TOKEN_EXPIRES_IN", () => {
1048
+ const OLD_ENV = process.env;
1049
+
1050
+ beforeEach(() => {
1051
+ process.env = {...OLD_ENV};
1052
+ process.env.TOKEN_SECRET = "secret";
1053
+ process.env.REFRESH_TOKEN_SECRET = "refresh_secret";
1054
+ });
1055
+
1056
+ afterEach(() => {
1057
+ process.env = OLD_ENV;
1058
+ });
1059
+
1060
+ it("uses TOKEN_EXPIRES_IN when set to a valid duration", async () => {
1061
+ process.env.TOKEN_EXPIRES_IN = "1h";
1062
+ const result = await generateTokens({_id: "user-123"});
1063
+ expect(result.token).toBeDefined();
1064
+ const decoded = decodeTokenPayload<{exp: number; iat: number}>(result.token as string);
1065
+ const diffSeconds = decoded.exp - decoded.iat;
1066
+ // 1h = 3600s
1067
+ expect(diffSeconds).toBe(3600);
1068
+ });
1069
+
1070
+ it("uses REFRESH_TOKEN_EXPIRES_IN when set to a valid duration", async () => {
1071
+ process.env.REFRESH_TOKEN_EXPIRES_IN = "7d";
1072
+ const result = await generateTokens({_id: "user-123"});
1073
+ expect(result.refreshToken).toBeDefined();
1074
+ const decoded = decodeTokenPayload<{exp: number; iat: number}>(result.refreshToken as string);
1075
+ const diffSeconds = decoded.exp - decoded.iat;
1076
+ // 7d = 604800s
1077
+ expect(diffSeconds).toBe(604800);
1078
+ });
1079
+ });
1080
+
1081
+ describe("JWT cookie extraction and /me routes edge cases", () => {
1082
+ let app: express.Application;
1083
+ let agent: TestAgent;
1084
+ const OLD_ENV = process.env;
1085
+
1086
+ beforeEach(async () => {
1087
+ setSystemTime();
1088
+ process.env = {...OLD_ENV};
1089
+ await setupTestData();
1090
+ app = new TerrenoApp({
1091
+ configureApp: () => {},
1092
+ skipListen: true,
1093
+ userModel: UserModel as any,
1094
+ }).build();
1095
+ agent = supertest.agent(app);
1096
+ });
1097
+
1098
+ afterEach(() => {
1099
+ setSystemTime();
1100
+ process.env = OLD_ENV;
1101
+ });
1102
+
1103
+ it("returns 401 for /me when no user is authenticated", async () => {
1104
+ const res = await agent.get("/auth/me").expect(401);
1105
+ expect(res.status).toBe(401);
1106
+ });
1107
+
1108
+ it("returns 401 for PATCH /me when no user is authenticated", async () => {
1109
+ const res = await agent.patch("/auth/me").send({name: "Updated"}).expect(401);
1110
+ expect(res.status).toBe(401);
1111
+ });
1112
+
1113
+ it("returns 404 for /me when user is deleted from database", async () => {
1114
+ // Login, then delete the user, then try /me
1115
+ const loginRes = await agent
1116
+ .post("/auth/login")
1117
+ .send({email: "notAdmin@example.com", password: "password"})
1118
+ .expect(200);
1119
+ const {token, userId} = loginRes.body.data;
1120
+
1121
+ // Delete the user from DB
1122
+ await UserModel.deleteOne({_id: userId});
1123
+
1124
+ const freshAgent = supertest.agent(app);
1125
+ const res = await freshAgent.get("/auth/me").set("authorization", `Bearer ${token}`);
1126
+ // Without the user, the JWT verify succeeds but findById returns null
1127
+ expect([401, 404]).toContain(res.status);
1128
+ });
1129
+ });
1130
+
1131
+ describe("login error and disabled user paths", () => {
1132
+ let app: express.Application;
1133
+ let agent: TestAgent;
1134
+
1135
+ beforeEach(async () => {
1136
+ setSystemTime();
1137
+ await setupTestData();
1138
+ app = new TerrenoApp({
1139
+ configureApp: () => {},
1140
+ skipListen: true,
1141
+ userModel: UserModel as any,
1142
+ }).build();
1143
+ agent = supertest.agent(app);
1144
+ });
1145
+
1146
+ afterEach(() => {
1147
+ setSystemTime();
1148
+ });
1149
+
1150
+ it("returns 401 with message for invalid credentials (no user found)", async () => {
1151
+ const res = await agent
1152
+ .post("/auth/login")
1153
+ .send({email: "nonexistent@example.com", password: "wrong"})
1154
+ .expect(401);
1155
+ expect(res.body.message).toBeDefined();
1156
+ });
1157
+
1158
+ it("returns 401 when disabled user tries to access protected route", async () => {
1159
+ // Login to get token
1160
+ const loginRes = await agent
1161
+ .post("/auth/login")
1162
+ .send({email: "notAdmin@example.com", password: "password"})
1163
+ .expect(200);
1164
+ const {token, userId} = loginRes.body.data;
1165
+
1166
+ // Disable the user
1167
+ await UserModel.findByIdAndUpdate(userId, {disabled: true});
1168
+
1169
+ // Try to access /me with disabled user's token
1170
+ const freshAgent = supertest.agent(app);
1171
+ const res = await freshAgent
1172
+ .get("/auth/me")
1173
+ .set("authorization", `Bearer ${token}`)
1174
+ .expect(401);
1175
+ expect(res.body.title).toContain("disabled");
1176
+ });
929
1177
  });
@@ -126,7 +126,6 @@ consentFormSchema.plugin(isDeletedPlugin);
126
126
  consentFormSchema.plugin(findOneOrNone);
127
127
  consentFormSchema.plugin(findExactlyOne);
128
128
 
129
- export const ConsentForm = mongoose.model<ConsentFormDocument, ConsentFormModel>(
130
- "ConsentForm",
131
- consentFormSchema
132
- );
129
+ export const ConsentForm =
130
+ (mongoose.models.ConsentForm as ConsentFormModel | undefined) ??
131
+ mongoose.model<ConsentFormDocument, ConsentFormModel>("ConsentForm", consentFormSchema);
@@ -72,7 +72,9 @@ consentResponseSchema.plugin(isDeletedPlugin);
72
72
  consentResponseSchema.plugin(findOneOrNone);
73
73
  consentResponseSchema.plugin(findExactlyOne);
74
74
 
75
- export const ConsentResponse = mongoose.model<ConsentResponseDocument, ConsentResponseModel>(
76
- "ConsentResponse",
77
- consentResponseSchema
78
- );
75
+ export const ConsentResponse =
76
+ (mongoose.models.ConsentResponse as ConsentResponseModel | undefined) ??
77
+ mongoose.model<ConsentResponseDocument, ConsentResponseModel>(
78
+ "ConsentResponse",
79
+ consentResponseSchema
80
+ );
@@ -97,7 +97,6 @@ versionConfigSchema.plugin(isDeletedPlugin);
97
97
  versionConfigSchema.plugin(findOneOrNone);
98
98
  versionConfigSchema.plugin(findExactlyOne);
99
99
 
100
- export const VersionConfig = mongoose.model<VersionConfigDocument, VersionConfigModel>(
101
- "VersionConfig",
102
- versionConfigSchema
103
- );
100
+ export const VersionConfig =
101
+ (mongoose.models.VersionConfig as VersionConfigModel | undefined) ??
102
+ mongoose.model<VersionConfigDocument, VersionConfigModel>("VersionConfig", versionConfigSchema);
@@ -16,6 +16,7 @@ function addRoutesWithBuilder(router: Router, options?: Partial<ModelRouterOptio
16
16
  const statsMiddleware = createOpenApiBuilder(options ?? {})
17
17
  .withTags(["Stats"])
18
18
  .withSummary("Get food statistics")
19
+ .withOperationId("getFoodStats")
19
20
  .withDescription("Returns aggregated statistics about food items")
20
21
  .withQueryParameter(
21
22
  "category",
@@ -195,6 +196,14 @@ describe("OpenApiMiddlewareBuilder", () => {
195
196
  expect(categoryParam.required).toBe(false);
196
197
  });
197
198
 
199
+ it("includes the explicit operationId in OpenAPI spec", async () => {
200
+ server = supertest(app);
201
+ const res = await server.get("/openapi.json").expect(200);
202
+
203
+ const statsPath = res.body.paths["/food/stats"];
204
+ expect(statsPath.get.operationId).toBe("getFoodStats");
205
+ });
206
+
198
207
  it("includes request body schema in OpenAPI spec", async () => {
199
208
  server = supertest(app);
200
209
  const res = await server.get("/openapi.json").expect(200);
@@ -213,6 +213,8 @@ interface OpenApiConfig {
213
213
  summary?: string;
214
214
  /** Detailed description of the operation */
215
215
  description?: string;
216
+ /** Explicit operationId for the operation */
217
+ operationId?: string;
216
218
  /** Operation parameters (query, path, header) */
217
219
  parameters?: OpenApiParameter[];
218
220
  /** Request body configuration */
@@ -359,6 +361,28 @@ export class OpenApiMiddlewareBuilder {
359
361
  return this;
360
362
  }
361
363
 
364
+ /**
365
+ * Sets an explicit `operationId` for the OpenAPI operation.
366
+ *
367
+ * The `operationId` is a unique string used to identify an operation. Client and SDK
368
+ * generators (e.g. RTK Query codegen) derive generated function and hook names from it,
369
+ * so setting it keeps generated names stable and readable for routes whose URL path would
370
+ * otherwise produce unwieldy names (e.g. deeply nested routes). It must be unique across
371
+ * the whole OpenAPI document.
372
+ *
373
+ * @param operationId - Unique operation identifier (e.g. "getUserStats")
374
+ * @returns The builder instance for chaining
375
+ *
376
+ * @example
377
+ * ```typescript
378
+ * builder.withOperationId("getUserStats");
379
+ * ```
380
+ */
381
+ withOperationId(operationId: string): this {
382
+ this.config.operationId = operationId;
383
+ return this;
384
+ }
385
+
362
386
  /**
363
387
  * Sets the description for the OpenAPI operation.
364
388
  *