@terreno/api 0.13.3 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/dist/__tests__/versionCheckPlugin.test.js +136 -3
  2. package/dist/api.arrayOperations.test.js +1 -0
  3. package/dist/api.d.ts +15 -4
  4. package/dist/api.errors.test.js +1 -0
  5. package/dist/api.hooks.test.js +1 -0
  6. package/dist/api.js +153 -104
  7. package/dist/api.query.test.js +1 -0
  8. package/dist/api.test.js +174 -0
  9. package/dist/auth.d.ts +10 -5
  10. package/dist/auth.js +163 -90
  11. package/dist/auth.test.js +159 -0
  12. package/dist/betterAuthApp.test.js +1 -0
  13. package/dist/betterAuthSetup.d.ts +5 -6
  14. package/dist/betterAuthSetup.js +30 -17
  15. package/dist/betterAuthSetup.test.js +1 -0
  16. package/dist/config.d.ts +48 -0
  17. package/dist/config.js +257 -0
  18. package/dist/config.test.d.ts +1 -0
  19. package/dist/config.test.js +328 -0
  20. package/dist/configuration.test.js +1 -0
  21. package/dist/configurationApp.d.ts +1 -1
  22. package/dist/configurationApp.js +17 -13
  23. package/dist/configurationPlugin.test.js +1 -0
  24. package/dist/consentApp.test.js +1 -0
  25. package/dist/envConfigurationPlugin.d.ts +2 -0
  26. package/dist/envConfigurationPlugin.js +173 -0
  27. package/dist/envConfigurationPlugin.test.d.ts +1 -0
  28. package/dist/envConfigurationPlugin.test.js +322 -0
  29. package/dist/errors.d.ts +18 -7
  30. package/dist/errors.js +111 -12
  31. package/dist/errors.test.js +16 -1
  32. package/dist/example.js +19 -7
  33. package/dist/expressServer.d.ts +10 -9
  34. package/dist/expressServer.js +62 -53
  35. package/dist/expressServer.test.js +165 -2
  36. package/dist/githubAuth.d.ts +2 -1
  37. package/dist/githubAuth.js +41 -26
  38. package/dist/githubAuth.test.js +1 -0
  39. package/dist/index.d.ts +4 -0
  40. package/dist/index.js +4 -0
  41. package/dist/logger.d.ts +1 -1
  42. package/dist/logger.js +42 -20
  43. package/dist/models/versionConfig.d.ts +2 -0
  44. package/dist/models/versionConfig.js +8 -0
  45. package/dist/notifiers/googleChatNotifier.js +14 -16
  46. package/dist/notifiers/googleChatNotifier.test.js +1 -0
  47. package/dist/notifiers/slackNotifier.js +16 -14
  48. package/dist/notifiers/slackNotifier.test.js +41 -3
  49. package/dist/notifiers/zoomNotifier.js +7 -10
  50. package/dist/notifiers/zoomNotifier.test.js +1 -0
  51. package/dist/openApi.d.ts +1 -1
  52. package/dist/openApi.test.js +1 -0
  53. package/dist/openApiBuilder.d.ts +39 -6
  54. package/dist/openApiBuilder.js +1 -31
  55. package/dist/openApiBuilder.test.js +1 -0
  56. package/dist/openApiValidator.js +1 -0
  57. package/dist/openApiValidator.test.js +1 -0
  58. package/dist/permissions.d.ts +4 -4
  59. package/dist/permissions.js +67 -65
  60. package/dist/permissions.middleware.test.js +1 -0
  61. package/dist/permissions.test.js +1 -0
  62. package/dist/plugins.d.ts +5 -5
  63. package/dist/plugins.js +18 -9
  64. package/dist/plugins.test.js +1 -1
  65. package/dist/populate.d.ts +15 -8
  66. package/dist/populate.js +23 -24
  67. package/dist/populate.test.js +1 -0
  68. package/dist/realtime/changeStreamWatcher.d.ts +73 -0
  69. package/dist/realtime/changeStreamWatcher.js +724 -0
  70. package/dist/realtime/index.d.ts +6 -0
  71. package/dist/realtime/index.js +27 -0
  72. package/dist/realtime/queryMatcher.d.ts +14 -0
  73. package/dist/realtime/queryMatcher.js +250 -0
  74. package/dist/realtime/queryStore.d.ts +37 -0
  75. package/dist/realtime/queryStore.js +195 -0
  76. package/dist/realtime/realtime.test.d.ts +10 -0
  77. package/dist/realtime/realtime.test.js +3066 -0
  78. package/dist/realtime/realtimeApp.d.ts +93 -0
  79. package/dist/realtime/realtimeApp.js +560 -0
  80. package/dist/realtime/registry.d.ts +40 -0
  81. package/dist/realtime/registry.js +38 -0
  82. package/dist/realtime/socketUser.d.ts +10 -0
  83. package/dist/realtime/socketUser.js +17 -0
  84. package/dist/realtime/types.d.ts +100 -0
  85. package/dist/realtime/types.js +2 -0
  86. package/dist/requestContext.d.ts +37 -0
  87. package/dist/requestContext.js +344 -0
  88. package/dist/requestContext.test.d.ts +1 -0
  89. package/dist/requestContext.test.js +384 -0
  90. package/dist/terrenoApp.d.ts +8 -0
  91. package/dist/terrenoApp.js +50 -13
  92. package/dist/terrenoApp.test.js +194 -21
  93. package/dist/terrenoPlugin.d.ts +11 -0
  94. package/dist/tests/bunSetup.js +1 -0
  95. package/dist/tests.js +1 -1
  96. package/dist/transformers.d.ts +2 -2
  97. package/dist/transformers.js +5 -3
  98. package/dist/transformers.test.js +90 -0
  99. package/dist/types/consentResponse.d.ts +6 -3
  100. package/dist/versionCheckPlugin.d.ts +2 -0
  101. package/dist/versionCheckPlugin.js +18 -12
  102. package/package.json +4 -2
  103. package/src/__tests__/versionCheckPlugin.test.ts +94 -3
  104. package/src/api.arrayOperations.test.ts +1 -0
  105. package/src/api.errors.test.ts +1 -0
  106. package/src/api.hooks.test.ts +1 -0
  107. package/src/api.query.test.ts +1 -0
  108. package/src/api.test.ts +132 -0
  109. package/src/api.ts +199 -84
  110. package/src/auth.test.ts +160 -0
  111. package/src/auth.ts +120 -50
  112. package/src/betterAuthApp.test.ts +1 -0
  113. package/src/betterAuthSetup.test.ts +1 -0
  114. package/src/betterAuthSetup.ts +59 -22
  115. package/src/config.test.ts +255 -0
  116. package/src/config.ts +216 -0
  117. package/src/configuration.test.ts +1 -0
  118. package/src/configurationApp.ts +59 -24
  119. package/src/configurationPlugin.test.ts +1 -0
  120. package/src/consentApp.test.ts +1 -0
  121. package/src/envConfigurationPlugin.test.ts +143 -0
  122. package/src/envConfigurationPlugin.ts +100 -0
  123. package/src/errors.test.ts +19 -1
  124. package/src/errors.ts +118 -38
  125. package/src/example.ts +49 -21
  126. package/src/express.d.ts +18 -1
  127. package/src/expressServer.test.ts +147 -2
  128. package/src/expressServer.ts +80 -50
  129. package/src/githubAuth.test.ts +1 -0
  130. package/src/githubAuth.ts +59 -38
  131. package/src/index.ts +4 -0
  132. package/src/logger.ts +47 -17
  133. package/src/models/versionConfig.ts +13 -2
  134. package/src/notifiers/googleChatNotifier.test.ts +1 -0
  135. package/src/notifiers/googleChatNotifier.ts +7 -9
  136. package/src/notifiers/slackNotifier.test.ts +29 -3
  137. package/src/notifiers/slackNotifier.ts +9 -7
  138. package/src/notifiers/zoomNotifier.test.ts +1 -0
  139. package/src/notifiers/zoomNotifier.ts +8 -11
  140. package/src/openApi.test.ts +1 -0
  141. package/src/openApi.ts +4 -4
  142. package/src/openApiBuilder.test.ts +1 -0
  143. package/src/openApiBuilder.ts +14 -11
  144. package/src/openApiValidator.test.ts +1 -0
  145. package/src/openApiValidator.ts +3 -2
  146. package/src/permissions.middleware.test.ts +1 -0
  147. package/src/permissions.test.ts +1 -0
  148. package/src/permissions.ts +30 -25
  149. package/src/plugins.test.ts +1 -1
  150. package/src/plugins.ts +21 -14
  151. package/src/populate.test.ts +1 -0
  152. package/src/populate.ts +44 -36
  153. package/src/realtime/changeStreamWatcher.ts +572 -0
  154. package/src/realtime/index.ts +34 -0
  155. package/src/realtime/queryMatcher.ts +179 -0
  156. package/src/realtime/queryStore.ts +132 -0
  157. package/src/realtime/realtime.test.ts +2465 -0
  158. package/src/realtime/realtimeApp.ts +478 -0
  159. package/src/realtime/registry.ts +64 -0
  160. package/src/realtime/socketUser.ts +25 -0
  161. package/src/realtime/types.ts +112 -0
  162. package/src/requestContext.test.ts +321 -0
  163. package/src/requestContext.ts +368 -0
  164. package/src/terrenoApp.test.ts +137 -11
  165. package/src/terrenoApp.ts +64 -17
  166. package/src/terrenoPlugin.ts +12 -0
  167. package/src/tests/bunSetup.ts +1 -0
  168. package/src/tests.ts +7 -2
  169. package/src/transformers.test.ts +70 -2
  170. package/src/transformers.ts +15 -7
  171. package/src/types/consentResponse.ts +8 -10
  172. package/src/versionCheckPlugin.ts +15 -7
package/src/errors.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // https://jsonapi.org/format/#errors
2
2
  import * as Sentry from "@sentry/bun";
3
3
  import type {NextFunction, Request, Response} from "express";
4
- import {Schema} from "mongoose";
4
+ import mongoose, {Schema} from "mongoose";
5
5
 
6
6
  import {logger} from "./logger";
7
7
 
@@ -42,7 +42,7 @@ export interface APIErrorConstructor {
42
42
  };
43
43
  // A meta object containing non-standard meta-information about the error.
44
44
  meta?: {[id: string]: string};
45
- error?: Error;
45
+ error?: unknown;
46
46
  // If true, this error will not be sent to external error reporting tools like Sentry.
47
47
  disableExternalErrorTracking?: boolean;
48
48
  }
@@ -82,19 +82,17 @@ export class APIError extends Error {
82
82
  }
83
83
  | undefined;
84
84
 
85
- meta: {[id: string]: any} | undefined;
85
+ meta: {[id: string]: unknown} | undefined;
86
86
 
87
- error?: Error;
87
+ error?: unknown;
88
88
 
89
89
  disableExternalErrorTracking?: boolean;
90
90
 
91
91
  constructor(data: APIErrorConstructor) {
92
+ const errorStack =
93
+ data.error instanceof Error && data.error.stack ? `\n${data.error.stack}` : "";
92
94
  // Include details in when the error is printed to the console or sent to Sentry.
93
- super(
94
- `${data.title}${data.detail ? `: ${data.detail}` : ""}${
95
- data.error ? `\n${data.error.stack}` : ""
96
- }`
97
- );
95
+ super(`${data.title}${data.detail ? `: ${data.detail}` : ""}${errorStack}`);
98
96
  this.name = "APIError";
99
97
 
100
98
  let {title, id, links, status, code, detail, source, meta, fields, error} = data;
@@ -120,9 +118,9 @@ export class APIError extends Error {
120
118
  this.meta.fields = fields;
121
119
  }
122
120
  this.error = error;
123
- const logMessage = `APIError(${status}): ${title} ${detail ? detail : ""}${
124
- data.error?.stack ? `\n${data.error?.stack}` : ""
125
- }`;
121
+ const dataErrorStack =
122
+ data.error instanceof Error && data.error.stack ? `\n${data.error.stack}` : "";
123
+ const logMessage = `APIError(${status}): ${title} ${detail ? detail : ""}${dataErrorStack}`;
126
124
  if (data.disableExternalErrorTracking) {
127
125
  logger.warn(logMessage);
128
126
  } else {
@@ -138,32 +136,54 @@ export class APIError extends Error {
138
136
  // Create an errors field for storing error information in a JSONAPI compatible form directly on a
139
137
  // model.
140
138
  export const errorsPlugin = (schema: Schema): void => {
141
- const errorSchema = new Schema({
142
- code: {description: "Application-specific error code", type: String},
143
- detail: {description: "Human-readable explanation of the error", type: String},
144
- id: {description: "Unique identifier for this error occurrence", type: String},
145
- links: {
146
- about: {description: "Link to documentation about this error", type: String},
147
- type: {description: "Link describing the error type", type: String},
148
- },
149
- meta: {description: "Non-standard meta information about the error", type: Schema.Types.Mixed},
150
- source: {
151
- header: {description: "HTTP header that caused the error", type: String},
152
- parameter: {description: "Query parameter that caused the error", type: String},
153
- pointer: {
154
- description: "JSON pointer to the request field that caused the error",
155
- type: String,
139
+ const errorSchema = new Schema(
140
+ {
141
+ code: {description: "Application-specific error code", type: String},
142
+ detail: {description: "Human-readable explanation of the error", type: String},
143
+ id: {description: "Unique identifier for this error occurrence", type: String},
144
+ links: {
145
+ about: {description: "Link to documentation about this error", type: String},
146
+ type: {description: "Link describing the error type", type: String},
147
+ },
148
+ meta: {
149
+ description: "Non-standard meta information about the error",
150
+ type: Schema.Types.Mixed,
151
+ },
152
+ source: {
153
+ header: {description: "HTTP header that caused the error", type: String},
154
+ parameter: {description: "Query parameter that caused the error", type: String},
155
+ pointer: {
156
+ description: "JSON pointer to the request field that caused the error",
157
+ type: String,
158
+ },
156
159
  },
160
+ status: {description: "HTTP status code for this error", type: Number},
161
+ title: {description: "Short summary of the error", required: true, type: String},
157
162
  },
158
- status: {description: "HTTP status code for this error", type: Number},
159
- title: {description: "Short summary of the error", required: true, type: String},
160
- });
163
+ {_id: false, strict: "throw"}
164
+ );
161
165
 
162
166
  schema.add({apiErrors: errorSchema});
163
167
  };
164
168
 
165
- export const isAPIError = (error: Error): error is APIError => {
166
- return error.name === "APIError";
169
+ export const isAPIError = (error: unknown): error is APIError => {
170
+ return error instanceof Error && error.name === "APIError";
171
+ };
172
+
173
+ /** Extract a human-readable message from an unknown error. */
174
+ export const errorMessage = (error: unknown): string => {
175
+ if (error instanceof Error) {
176
+ return error.message;
177
+ }
178
+ return String(error);
179
+ };
180
+
181
+ /** Extract a stack trace string from an unknown error. */
182
+ export const errorStack = (error: unknown): string => {
183
+ if (error instanceof Error && error.stack) {
184
+ return error.stack;
185
+ }
186
+ return String(error);
167
187
  };
168
188
 
169
189
  /**
@@ -186,8 +206,9 @@ export const getDisableExternalErrorTracking = (error: unknown): boolean | undef
186
206
  // Creates an APIError body to send to clients as JSON. Errors don't have a toJSON defined,
187
207
  // and we want to strip out things like message, name, and stack for the client.
188
208
  // There is almost certainly a more elegant solution to this.
189
- export const getAPIErrorBody = (error: APIError): {[id: string]: any} => {
190
- const errorData = {status: error.status, title: error.title};
209
+ export const getAPIErrorBody = (error: APIError): Record<string, unknown> => {
210
+ const errorData: Record<string, unknown> = {status: error.status, title: error.title};
211
+ const indexable = error as unknown as Record<string, unknown>;
191
212
  for (const key of [
192
213
  "id",
193
214
  "links",
@@ -198,8 +219,8 @@ export const getAPIErrorBody = (error: APIError): {[id: string]: any} => {
198
219
  "meta",
199
220
  "disableExternalErrorTracking",
200
221
  ]) {
201
- if (error[key]) {
202
- errorData[key] = error[key];
222
+ if (indexable[key]) {
223
+ errorData[key] = indexable[key];
203
224
  }
204
225
  }
205
226
  return errorData;
@@ -219,6 +240,40 @@ export const apiUnauthorizedMiddleware = (
219
240
  }
220
241
  };
221
242
 
243
+ /**
244
+ * Converts Mongoose validation/cast errors into client-friendly APIErrors.
245
+ */
246
+ export const mongooseErrorToAPIError = (err: Error): APIError | null => {
247
+ if (err instanceof mongoose.Error.ValidationError) {
248
+ const fields: {[id: string]: string} = {};
249
+ for (const [path, subErr] of Object.entries(err.errors)) {
250
+ fields[path] = subErr.message;
251
+ }
252
+ return new APIError({
253
+ detail: err.message,
254
+ disableExternalErrorTracking: true,
255
+ fields,
256
+ status: 400,
257
+ title: "Validation failed",
258
+ });
259
+ }
260
+
261
+ if (err instanceof mongoose.Error.CastError) {
262
+ const path = err.path ?? "field";
263
+ return new APIError({
264
+ detail: `Invalid value for ${path}`,
265
+ disableExternalErrorTracking: true,
266
+ fields: {
267
+ [path]: `Expected ${err.kind ?? "a valid value"}, got ${JSON.stringify(err.value)}`,
268
+ },
269
+ status: 400,
270
+ title: "Validation failed",
271
+ });
272
+ }
273
+
274
+ return null;
275
+ };
276
+
222
277
  export const apiErrorMiddleware = (
223
278
  err: Error,
224
279
  _req: Request,
@@ -230,7 +285,32 @@ export const apiErrorMiddleware = (
230
285
  Sentry.captureException(err);
231
286
  }
232
287
  res.status(err.status).json(getAPIErrorBody(err)).send();
233
- } else {
234
- next(err);
288
+ return;
289
+ }
290
+
291
+ const mongooseError = mongooseErrorToAPIError(err);
292
+ if (mongooseError) {
293
+ res.status(mongooseError.status).json(getAPIErrorBody(mongooseError)).send();
294
+ return;
295
+ }
296
+
297
+ next(err);
298
+ };
299
+
300
+ /**
301
+ * Final Express error handler for unexpected errors. Always returns JSON so
302
+ * clients (e.g. RTK Query) can parse the response.
303
+ */
304
+ export const apiFallthroughErrorMiddleware = (
305
+ err: Error,
306
+ _req: Request,
307
+ res: Response,
308
+ _next: NextFunction
309
+ ): void => {
310
+ logger.error(`Fallthrough error: ${err}${err.stack ? `\n${err.stack}` : ""}`);
311
+ Sentry.captureException(err);
312
+ if (res.headersSent) {
313
+ return;
235
314
  }
315
+ res.status(500).json({status: 500, title: "Internal server error"}).send();
236
316
  };
package/src/example.ts CHANGED
@@ -3,11 +3,17 @@ import mongoose, {model, Schema} from "mongoose";
3
3
  import passportLocalMongoose from "passport-local-mongoose";
4
4
 
5
5
  import {type ModelRouterOptions, modelRouter} from "./api";
6
- import {addAuthRoutes, setupAuth} from "./auth";
6
+ import {addAuthRoutes, setupAuth, type UserModel as UserMongooseModel} from "./auth";
7
7
  import {setupServer} from "./expressServer";
8
8
  import {logger} from "./logger";
9
9
  import {Permissions} from "./permissions";
10
- import {baseUserPlugin, createdUpdatedPlugin} from "./plugins";
10
+ import {
11
+ baseUserPlugin,
12
+ createdUpdatedPlugin,
13
+ findExactlyOne,
14
+ findOneOrNone,
15
+ isDeletedPlugin,
16
+ } from "./plugins";
11
17
 
12
18
  mongoose
13
19
  .connect("mongodb://localhost:27017/example")
@@ -31,27 +37,46 @@ interface Food {
31
37
  hidden?: boolean;
32
38
  }
33
39
 
34
- const userSchema = new Schema<User>({
35
- admin: {default: false, description: "Whether the user has admin privileges", type: Boolean},
36
- username: {description: "The user's username", type: String},
37
- });
40
+ const userSchema = new Schema<User>(
41
+ {
42
+ admin: {default: false, description: "Whether the user has admin privileges", type: Boolean},
43
+ username: {description: "The user's username", type: String},
44
+ },
45
+ {strict: "throw", toJSON: {virtuals: true}, toObject: {virtuals: true}}
46
+ );
38
47
 
48
+ // biome-ignore lint/suspicious/noExplicitAny: passport-local-mongoose's plugin type is incompatible with mongoose Schema generics
39
49
  userSchema.plugin(passportLocalMongoose as any, {usernameField: "email"});
40
50
  userSchema.plugin(createdUpdatedPlugin);
51
+ userSchema.plugin(isDeletedPlugin);
52
+ userSchema.plugin(findOneOrNone);
53
+ userSchema.plugin(findExactlyOne);
41
54
  userSchema.plugin(baseUserPlugin);
42
55
  const UserModel = model<User>("User", userSchema);
43
56
 
44
- const schema = new Schema<Food>({
45
- calories: {description: "Number of calories in the food", type: Number},
46
- created: {description: "When this food was created", type: Date},
47
- hidden: {default: false, description: "Whether this food is hidden from listings", type: Boolean},
48
- name: {description: "The name of the food", type: String},
49
- ownerId: {description: "The user who owns this food entry", ref: "User", type: "ObjectId"},
50
- });
57
+ const schema = new Schema<Food>(
58
+ {
59
+ calories: {description: "Number of calories in the food", type: Number},
60
+ created: {description: "When this food was created", type: Date},
61
+ hidden: {
62
+ default: false,
63
+ description: "Whether this food is hidden from listings",
64
+ type: Boolean,
65
+ },
66
+ name: {description: "The name of the food", type: String},
67
+ ownerId: {description: "The user who owns this food entry", ref: "User", type: "ObjectId"},
68
+ },
69
+ {strict: "throw", toJSON: {virtuals: true}, toObject: {virtuals: true}}
70
+ );
71
+
72
+ schema.plugin(createdUpdatedPlugin);
73
+ schema.plugin(isDeletedPlugin);
74
+ schema.plugin(findOneOrNone);
75
+ schema.plugin(findExactlyOne);
51
76
 
52
77
  const FoodModel = model<Food>("Food", schema);
53
78
 
54
- function getBaseServer() {
79
+ const getBaseServer = () => {
55
80
  const app = express();
56
81
 
57
82
  app.use((req, res, next) => {
@@ -65,14 +90,17 @@ function getBaseServer() {
65
90
  }
66
91
  });
67
92
  app.use(express.json());
68
- setupAuth(app, UserModel as any);
69
- addAuthRoutes(app, UserModel as any);
93
+ setupAuth(app, UserModel as unknown as UserMongooseModel);
94
+ addAuthRoutes(app, UserModel as unknown as UserMongooseModel);
70
95
 
71
- function addRoutes(router: express.Router, options?: Partial<ModelRouterOptions<any>>): void {
96
+ const addRoutes = (
97
+ router: express.Router,
98
+ options?: Partial<ModelRouterOptions<unknown>>
99
+ ): void => {
72
100
  router.use(
73
101
  "/food",
74
102
  modelRouter(FoodModel, {
75
- ...options,
103
+ ...(options as Partial<ModelRouterOptions<Food>>),
76
104
  openApiOverwrite: {
77
105
  get: {responses: {200: {description: "Get all the food"}}},
78
106
  },
@@ -86,14 +114,14 @@ function getBaseServer() {
86
114
  queryFields: ["name", "calories", "created", "ownerId", "hidden"],
87
115
  })
88
116
  );
89
- }
117
+ };
90
118
 
91
119
  return setupServer({
92
120
  addRoutes,
93
121
  loggingOptions: {
94
122
  level: "debug",
95
123
  },
96
- userModel: UserModel as any,
124
+ userModel: UserModel as unknown as UserMongooseModel,
97
125
  });
98
- }
126
+ };
99
127
  getBaseServer();
package/src/express.d.ts CHANGED
@@ -1,5 +1,22 @@
1
1
  declare namespace Express {
2
2
  export interface Request {
3
- user?: {_id: string | ObjectId; id: string; admin: boolean};
3
+ authTokenPayload?: {
4
+ sid?: string;
5
+ sessionId?: string;
6
+ [key: string]: unknown;
7
+ };
8
+ jobId?: string;
9
+ requestId?: string;
10
+ sessionId?: string;
11
+ user?: {
12
+ _id: string | ObjectId;
13
+ id: string;
14
+ admin: boolean;
15
+ disabled?: boolean;
16
+ type?: string;
17
+ testUser?: boolean;
18
+ email?: string;
19
+ [key: string]: unknown;
20
+ };
4
21
  }
5
22
  }
@@ -1,6 +1,9 @@
1
+ // biome-ignore-all lint/suspicious/noExplicitAny: test mock typing
1
2
  import {afterEach, beforeEach, describe, expect, it, mock} from "bun:test";
3
+ import {Writable} from "node:stream";
2
4
  import express from "express";
3
5
  import supertest from "supertest";
6
+ import winston from "winston";
4
7
 
5
8
  import {
6
9
  createRouter,
@@ -11,6 +14,7 @@ import {
11
14
  setupServer,
12
15
  wrapScript,
13
16
  } from "./expressServer";
17
+ import {logger, winstonLogger} from "./logger";
14
18
  import {UserModel} from "./tests";
15
19
 
16
20
  describe("expressServer", () => {
@@ -58,6 +62,52 @@ describe("expressServer", () => {
58
62
  });
59
63
 
60
64
  describe("logRequests", () => {
65
+ it("attaches request and session context to route logs", async () => {
66
+ const logs: string[] = [];
67
+ const logStream = new Writable({
68
+ write(chunk, _encoding, callback) {
69
+ logs.push(chunk.toString());
70
+ callback();
71
+ },
72
+ });
73
+ const transport = new winston.transports.Stream({
74
+ format: winston.format.json(),
75
+ stream: logStream,
76
+ });
77
+
78
+ const app = setupServer({
79
+ addRoutes: (router) => {
80
+ router.get("/context-test", (req, res) => {
81
+ logger.info("context route log");
82
+ return res.json({requestId: req.requestId, sessionId: req.sessionId});
83
+ });
84
+ },
85
+ logRequests: false,
86
+ skipListen: true,
87
+ userModel: UserModel as any,
88
+ });
89
+ winstonLogger.add(transport);
90
+
91
+ const res = await supertest(app)
92
+ .get("/context-test")
93
+ .set("X-Request-ID", "req-123")
94
+ .set("X-Session-ID", "session-123")
95
+ .expect(200);
96
+
97
+ expect(res.headers["x-request-id"]).toBe("req-123");
98
+ expect(res.headers["x-session-id"]).toBe("session-123");
99
+ expect(res.body).toEqual({requestId: "req-123", sessionId: "session-123"});
100
+
101
+ const parsedLog = logs
102
+ .map((entry) => JSON.parse(entry))
103
+ .find((entry) => entry.message === "context route log");
104
+ expect(parsedLog).toBeDefined();
105
+ expect(parsedLog.requestId).toBe("req-123");
106
+ expect(parsedLog.sessionId).toBe("session-123");
107
+
108
+ winstonLogger.remove(transport);
109
+ });
110
+
61
111
  it("logs request with admin user type", () => {
62
112
  const req = {
63
113
  body: {},
@@ -653,7 +703,6 @@ describe("expressServer", () => {
653
703
  const timerIds: ReturnType<typeof setTimeout>[] = [];
654
704
 
655
705
  beforeEach(() => {
656
- // biome-ignore lint/suspicious/noExplicitAny: Mock requires type override for process.exit.
657
706
  process.exit = mock(() => {
658
707
  throw new Error("process.exit called");
659
708
  }) as unknown as typeof process.exit;
@@ -723,6 +772,103 @@ describe("expressServer", () => {
723
772
  });
724
773
  });
725
774
 
775
+ describe("setupServer with listen", () => {
776
+ const originalEnv = process.env;
777
+ const http = require("node:http");
778
+ let activeServer: any = null;
779
+ let originalListen: any = null;
780
+
781
+ beforeEach(() => {
782
+ process.env = {
783
+ ...originalEnv,
784
+ PORT: "0",
785
+ REFRESH_TOKEN_SECRET: "test-refresh-secret",
786
+ SESSION_SECRET: "test-session-secret",
787
+ TOKEN_EXPIRES_IN: "1h",
788
+ TOKEN_ISSUER: "test-issuer",
789
+ TOKEN_SECRET: "test-secret",
790
+ };
791
+
792
+ originalListen = http.Server.prototype.listen;
793
+ http.Server.prototype.listen = function (...args: any[]) {
794
+ activeServer = this;
795
+ return originalListen.apply(this, args);
796
+ };
797
+ });
798
+
799
+ afterEach(async () => {
800
+ process.env = originalEnv;
801
+ http.Server.prototype.listen = originalListen;
802
+ if (activeServer) {
803
+ await new Promise<void>((resolve) => activeServer.close(() => resolve()));
804
+ activeServer = null;
805
+ }
806
+ });
807
+
808
+ it("starts listening on a port when skipListen is false", async () => {
809
+ const addRoutes = () => {};
810
+
811
+ const app = setupServer({
812
+ addRoutes,
813
+ skipListen: false,
814
+ userModel: UserModel as any,
815
+ });
816
+
817
+ expect(app).toBeDefined();
818
+ await new Promise((resolve) => setTimeout(resolve, 100));
819
+ });
820
+ });
821
+
822
+ describe("wrapScript timeout callbacks", () => {
823
+ const originalEnv = process.env;
824
+ const originalExit = process.exit;
825
+ const originalSetTimeout = globalThis.setTimeout;
826
+ const timerIds: ReturnType<typeof setTimeout>[] = [];
827
+ const timerCallbacks: Array<{callback: () => void; delay: number}> = [];
828
+
829
+ beforeEach(() => {
830
+ process.env = {
831
+ ...process.env,
832
+ REFRESH_TOKEN_SECRET: "test-refresh-secret",
833
+ SESSION_SECRET: "test-session-secret",
834
+ TOKEN_EXPIRES_IN: "1h",
835
+ TOKEN_ISSUER: "test-issuer",
836
+ TOKEN_SECRET: "test-secret",
837
+ };
838
+ process.exit = mock(() => {
839
+ throw new Error("__EXIT__");
840
+ }) as unknown as typeof process.exit;
841
+
842
+ timerCallbacks.length = 0;
843
+ timerIds.length = 0;
844
+ globalThis.setTimeout = ((cb: () => void, delay: number) => {
845
+ timerCallbacks.push({callback: cb, delay});
846
+ const id = originalSetTimeout(cb, delay);
847
+ timerIds.push(id);
848
+ return id;
849
+ }) as typeof setTimeout;
850
+ });
851
+
852
+ afterEach(() => {
853
+ for (const id of timerIds) {
854
+ clearTimeout(id);
855
+ }
856
+ globalThis.setTimeout = originalSetTimeout;
857
+ process.exit = originalExit;
858
+ process.env = originalEnv;
859
+ });
860
+
861
+ it("registers warn and terminate timeouts with correct delays", async () => {
862
+ const func = async () => "ok";
863
+ await expect(wrapScript(func, {terminateTimeout: 100})).rejects.toThrow("__EXIT__");
864
+
865
+ const warnTimer = timerCallbacks.find((t) => t.delay === 50000);
866
+ const closeTimer = timerCallbacks.find((t) => t.delay === 100000);
867
+ expect(warnTimer).toBeDefined();
868
+ expect(closeTimer).toBeDefined();
869
+ });
870
+ });
871
+
726
872
  describe("setupServer error handling", () => {
727
873
  const originalEnv = process.env;
728
874
 
@@ -750,7 +896,6 @@ describe("expressServer", () => {
750
896
  setupServer({
751
897
  addRoutes,
752
898
  skipListen: true,
753
- // biome-ignore lint/suspicious/noExplicitAny: Test mock for UserModel.
754
899
  userModel: UserModel as any,
755
900
  })
756
901
  ).toThrow("route initialization failed");