@terreno/api 0.14.0 → 0.14.2

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.
@@ -212,6 +212,149 @@ var requestContext_1 = require("./requestContext");
212
212
  }
213
213
  });
214
214
  }); });
215
+ (0, bun_test_1.it)("parses Google Cloud trace context header in middleware", function () { return __awaiter(void 0, void 0, void 0, function () {
216
+ var app, res;
217
+ return __generator(this, function (_a) {
218
+ switch (_a.label) {
219
+ case 0:
220
+ app = (0, express_1.default)();
221
+ app.use(requestContext_1.requestContextMiddleware);
222
+ app.get("/trace-gcloud", function (_req, res) {
223
+ var ctx = (0, requestContext_1.getCurrentRequestContext)();
224
+ res.json({
225
+ spanId: ctx === null || ctx === void 0 ? void 0 : ctx.spanId,
226
+ traceId: ctx === null || ctx === void 0 ? void 0 : ctx.traceId,
227
+ traceSampled: ctx === null || ctx === void 0 ? void 0 : ctx.traceSampled,
228
+ });
229
+ });
230
+ return [4 /*yield*/, (0, supertest_1.default)(app)
231
+ .get("/trace-gcloud")
232
+ .set("X-Cloud-Trace-Context", "105445aa7843bc8bf206b12000100000/1;o=1")
233
+ .expect(200)];
234
+ case 1:
235
+ res = _a.sent();
236
+ (0, bun_test_1.expect)(res.body.traceId).toBe("105445aa7843bc8bf206b12000100000");
237
+ (0, bun_test_1.expect)(res.body.spanId).toBe("1");
238
+ (0, bun_test_1.expect)(res.body.traceSampled).toBe(true);
239
+ return [2 /*return*/];
240
+ }
241
+ });
242
+ }); });
243
+ (0, bun_test_1.it)("parses Google Cloud trace context without trace sampling", function () { return __awaiter(void 0, void 0, void 0, function () {
244
+ var app, res;
245
+ return __generator(this, function (_a) {
246
+ switch (_a.label) {
247
+ case 0:
248
+ app = (0, express_1.default)();
249
+ app.use(requestContext_1.requestContextMiddleware);
250
+ app.get("/trace-gcloud-nosample", function (_req, res) {
251
+ var ctx = (0, requestContext_1.getCurrentRequestContext)();
252
+ res.json({
253
+ spanId: ctx === null || ctx === void 0 ? void 0 : ctx.spanId,
254
+ traceId: ctx === null || ctx === void 0 ? void 0 : ctx.traceId,
255
+ traceSampled: ctx === null || ctx === void 0 ? void 0 : ctx.traceSampled,
256
+ });
257
+ });
258
+ return [4 /*yield*/, (0, supertest_1.default)(app)
259
+ .get("/trace-gcloud-nosample")
260
+ .set("X-Cloud-Trace-Context", "abc123/42;o=0")
261
+ .expect(200)];
262
+ case 1:
263
+ res = _a.sent();
264
+ (0, bun_test_1.expect)(res.body.traceId).toBe("abc123");
265
+ (0, bun_test_1.expect)(res.body.spanId).toBe("42");
266
+ (0, bun_test_1.expect)(res.body.traceSampled).toBe(false);
267
+ return [2 /*return*/];
268
+ }
269
+ });
270
+ }); });
271
+ (0, bun_test_1.it)("falls back to traceparent when cloud trace context is absent", function () { return __awaiter(void 0, void 0, void 0, function () {
272
+ var app, res;
273
+ return __generator(this, function (_a) {
274
+ switch (_a.label) {
275
+ case 0:
276
+ app = (0, express_1.default)();
277
+ app.use(requestContext_1.requestContextMiddleware);
278
+ app.get("/trace-parent", function (_req, res) {
279
+ var ctx = (0, requestContext_1.getCurrentRequestContext)();
280
+ res.json({
281
+ spanId: ctx === null || ctx === void 0 ? void 0 : ctx.spanId,
282
+ traceId: ctx === null || ctx === void 0 ? void 0 : ctx.traceId,
283
+ traceSampled: ctx === null || ctx === void 0 ? void 0 : ctx.traceSampled,
284
+ });
285
+ });
286
+ return [4 /*yield*/, (0, supertest_1.default)(app)
287
+ .get("/trace-parent")
288
+ .set("traceparent", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")
289
+ .expect(200)];
290
+ case 1:
291
+ res = _a.sent();
292
+ (0, bun_test_1.expect)(res.body.traceId).toBe("4bf92f3577b34da6a3ce929d0e0e4736");
293
+ (0, bun_test_1.expect)(res.body.spanId).toBe("00f067aa0ba902b7");
294
+ (0, bun_test_1.expect)(res.body.traceSampled).toBe(true);
295
+ return [2 /*return*/];
296
+ }
297
+ });
298
+ }); });
299
+ (0, bun_test_1.it)("uses trace id as request id when no explicit request id header is set", function () { return __awaiter(void 0, void 0, void 0, function () {
300
+ var app, res;
301
+ return __generator(this, function (_a) {
302
+ switch (_a.label) {
303
+ case 0:
304
+ app = (0, express_1.default)();
305
+ app.use(requestContext_1.requestContextMiddleware);
306
+ app.get("/trace-request-id", function (_req, res) {
307
+ var ctx = (0, requestContext_1.getCurrentRequestContext)();
308
+ res.json({ requestId: ctx === null || ctx === void 0 ? void 0 : ctx.requestId });
309
+ });
310
+ return [4 /*yield*/, (0, supertest_1.default)(app)
311
+ .get("/trace-request-id")
312
+ .set("X-Cloud-Trace-Context", "trace-as-rid/99;o=1")
313
+ .expect(200)];
314
+ case 1:
315
+ res = _a.sent();
316
+ (0, bun_test_1.expect)(res.body.requestId).toBe("trace-as-rid");
317
+ return [2 /*return*/];
318
+ }
319
+ });
320
+ }); });
321
+ (0, bun_test_1.it)("handles traceSampled attribute values 'true', '1', 'false', '0'", function () {
322
+ var ctxTrue = (0, requestContext_1.getRequestContextFromAttributes)({
323
+ "x-request-id": "r1",
324
+ "x-trace-sampled": "true",
325
+ });
326
+ (0, bun_test_1.expect)(ctxTrue.traceSampled).toBe(true);
327
+ var ctx1 = (0, requestContext_1.getRequestContextFromAttributes)({
328
+ "x-request-id": "r2",
329
+ "x-trace-sampled": "1",
330
+ });
331
+ (0, bun_test_1.expect)(ctx1.traceSampled).toBe(true);
332
+ var ctxFalse = (0, requestContext_1.getRequestContextFromAttributes)({
333
+ "x-request-id": "r3",
334
+ "x-trace-sampled": "false",
335
+ });
336
+ (0, bun_test_1.expect)(ctxFalse.traceSampled).toBe(false);
337
+ var ctx0 = (0, requestContext_1.getRequestContextFromAttributes)({
338
+ "x-request-id": "r4",
339
+ "x-trace-sampled": "0",
340
+ });
341
+ (0, bun_test_1.expect)(ctx0.traceSampled).toBe(false);
342
+ });
343
+ (0, bun_test_1.it)("parses Google Cloud trace context with missing span id", function () {
344
+ var ctx = (0, requestContext_1.getRequestContextFromAttributes)({
345
+ "x-cloud-trace-context": "only-trace-id",
346
+ "x-request-id": "r5",
347
+ });
348
+ (0, bun_test_1.expect)(ctx.traceId).toBe("only-trace-id");
349
+ (0, bun_test_1.expect)(ctx.spanId).toBeUndefined();
350
+ });
351
+ (0, bun_test_1.it)("returns undefined trace when traceparent has empty trace id", function () {
352
+ var ctx = (0, requestContext_1.getRequestContextFromAttributes)({
353
+ traceparent: "00--span-01",
354
+ "x-request-id": "r6",
355
+ });
356
+ (0, bun_test_1.expect)(ctx.traceId).toBeUndefined();
357
+ });
215
358
  (0, bun_test_1.it)("adds job id to logger context", function () {
216
359
  var output = "";
217
360
  var stream = new node_stream_1.Writable({
package/package.json CHANGED
@@ -106,5 +106,5 @@
106
106
  "updateSnapshot": "bun test --update-snapshots"
107
107
  },
108
108
  "types": "dist/index.d.ts",
109
- "version": "0.14.0"
109
+ "version": "0.14.2"
110
110
  }
@@ -153,6 +153,45 @@ describe("VersionCheckPlugin", () => {
153
153
  expect(res.body.pollingIntervalMs).toBe(86400000);
154
154
  });
155
155
 
156
+ it("handles numeric version parameter directly", async () => {
157
+ await VersionConfig.create({
158
+ webRequiredVersion: 100,
159
+ webWarningVersion: 150,
160
+ });
161
+
162
+ const res = await app.get("/version-check?version=50&platform=web");
163
+ expect(res.status).toBe(200);
164
+ expect(res.body.status).toBe("required");
165
+ });
166
+
167
+ it("returns default warning message when warningMessage not set", async () => {
168
+ await VersionConfig.create({
169
+ webRequiredVersion: 0,
170
+ webWarningVersion: 100,
171
+ });
172
+
173
+ const res = await app.get("/version-check").query({platform: "web", version: 50});
174
+ expect(res.status).toBe(200);
175
+ expect(res.body.status).toBe("warning");
176
+ expect(res.body.message).toBe(
177
+ "A new version is available. Please update for the best experience."
178
+ );
179
+ });
180
+
181
+ it("returns default required message when requiredMessage not set", async () => {
182
+ await VersionConfig.create({
183
+ webRequiredVersion: 100,
184
+ webWarningVersion: 150,
185
+ });
186
+
187
+ const res = await app.get("/version-check").query({platform: "web", version: 50});
188
+ expect(res.status).toBe(200);
189
+ expect(res.body.status).toBe("required");
190
+ expect(res.body.message).toBe(
191
+ "This version is no longer supported. Please update to continue."
192
+ );
193
+ });
194
+
156
195
  it("version equal to required returns warning not required", async () => {
157
196
  await VersionConfig.create({
158
197
  webRequiredVersion: 100,
@@ -163,4 +202,46 @@ describe("VersionCheckPlugin", () => {
163
202
  expect(res.status).toBe(200);
164
203
  expect(res.body.status).toBe("warning");
165
204
  });
205
+
206
+ it("uses default messages when warningMessage/requiredMessage are not set", async () => {
207
+ await VersionConfig.create({
208
+ webRequiredVersion: 100,
209
+ webWarningVersion: 200,
210
+ });
211
+
212
+ const warningRes = await app.get("/version-check").query({platform: "web", version: 150});
213
+ expect(warningRes.body.status).toBe("warning");
214
+ expect(warningRes.body.message).toBe(
215
+ "A new version is available. Please update for the best experience."
216
+ );
217
+
218
+ const requiredRes = await app.get("/version-check").query({platform: "web", version: 50});
219
+ expect(requiredRes.body.status).toBe("required");
220
+ expect(requiredRes.body.message).toBe(
221
+ "This version is no longer supported. Please update to continue."
222
+ );
223
+ });
224
+
225
+ it("handles numeric version parameter", async () => {
226
+ const res = await app.get("/version-check?version=50&platform=web");
227
+ expect(res.status).toBe(200);
228
+ });
229
+ });
230
+
231
+ describe("VersionCheckPlugin direct usage", () => {
232
+ it("can be instantiated and register called directly on an express app", async () => {
233
+ const express = require("express");
234
+ const plugin = new VersionCheckPlugin();
235
+ expect(plugin).toBeDefined();
236
+ expect(plugin).toBeInstanceOf(VersionCheckPlugin);
237
+ expect(typeof plugin.register).toBe("function");
238
+
239
+ const expressApp = express();
240
+ plugin.register(expressApp);
241
+
242
+ const testApp = supertest(expressApp);
243
+ const res = await testApp.get("/version-check");
244
+ expect(res.status).toBe(200);
245
+ expect(res.body.status).toBe("ok");
246
+ });
166
247
  });
@@ -12,6 +12,7 @@ import type {Application, NextFunction, Request, Response} from "express";
12
12
  import mongoose from "mongoose";
13
13
  import type {UserModel} from "./auth";
14
14
  import type {BetterAuthConfig, BetterAuthSessionData, BetterAuthUser} from "./betterAuth";
15
+ import {APIError} from "./errors";
15
16
  import {logger} from "./logger";
16
17
  import {findOneOrNoneFor} from "./plugins";
17
18
  import {updateRequestContextFromRequest} from "./requestContext";
@@ -44,12 +45,18 @@ export const createBetterAuth = (options: CreateBetterAuthOptions): BetterAuthIn
44
45
 
45
46
  const secret = config.secret || process.env.BETTER_AUTH_SECRET;
46
47
  if (!secret) {
47
- throw new Error("BETTER_AUTH_SECRET must be set in env or config.secret must be provided.");
48
+ throw new APIError({
49
+ status: 500,
50
+ title: "BETTER_AUTH_SECRET must be set in env or config.secret must be provided.",
51
+ });
48
52
  }
49
53
 
50
54
  const baseURL = config.baseURL || process.env.BETTER_AUTH_URL;
51
55
  if (!baseURL) {
52
- throw new Error("BETTER_AUTH_URL must be set in env or config.baseURL must be provided.");
56
+ throw new APIError({
57
+ status: 500,
58
+ title: "BETTER_AUTH_URL must be set in env or config.baseURL must be provided.",
59
+ });
53
60
  }
54
61
 
55
62
  const basePath = config.basePath ?? "/api/auth";
@@ -253,7 +260,10 @@ export const getMongoClientFromMongoose = (): MongoClientLike => {
253
260
  const connection = mongoose.connection;
254
261
  const client = (connection as unknown as {client?: MongoClientLike}).client;
255
262
  if (!client) {
256
- throw new Error("Mongoose is not connected. Ensure MongoDB connection is established first.");
263
+ throw new APIError({
264
+ status: 500,
265
+ title: "Mongoose is not connected. Ensure MongoDB connection is established first.",
266
+ });
257
267
  }
258
268
  return client;
259
269
  };
package/src/config.ts CHANGED
@@ -21,6 +21,8 @@
21
21
  * `envConfigurationPlugin` provides a drop-in Mongoose schema integration.
22
22
  */
23
23
 
24
+ import {APIError} from "./errors";
25
+
24
26
  const overrides = new Map<string, string | undefined>();
25
27
 
26
28
  let cachedEnv: Record<string, string> | null = null;
@@ -46,7 +48,7 @@ const REGISTRY: Record<string, ConfigRegistration> = Object.create(null);
46
48
  */
47
49
  const register = (key: string, registration: ConfigRegistration = {}): void => {
48
50
  if (REGISTRY[key]) {
49
- throw new Error(`Config key "${key}" registered more than once`);
51
+ throw new APIError({status: 500, title: `Config key "${key}" registered more than once`});
50
52
  }
51
53
  REGISTRY[key] = registration;
52
54
  };
@@ -88,7 +90,11 @@ const getNumber = (key: string): number | undefined => {
88
90
  // whereas parseFloat would silently truncate to 5000.
89
91
  const parsed = Number(raw);
90
92
  if (!Number.isFinite(parsed)) {
91
- throw new Error(`Config key "${key}" is not a valid number: ${JSON.stringify(raw)}`);
93
+ throw new APIError({
94
+ error: new Error(`Config key "${key}" is not a valid number: ${JSON.stringify(raw)}`),
95
+ status: 500,
96
+ title: `Config key "${key}" is not a valid number`,
97
+ });
92
98
  }
93
99
  return parsed;
94
100
  };
@@ -115,7 +121,11 @@ const getJSON = <T = unknown>(key: string): T | undefined => {
115
121
  try {
116
122
  return JSON.parse(raw) as T;
117
123
  } catch (error) {
118
- throw new Error(`Config key "${key}" is not valid JSON: ${(error as Error).message}`);
124
+ throw new APIError({
125
+ error: new Error(`Config key "${key}" is not valid JSON: ${(error as Error).message}`),
126
+ status: 500,
127
+ title: `Config key "${key}" is not valid JSON`,
128
+ });
119
129
  }
120
130
  };
121
131
 
package/src/errors.ts CHANGED
@@ -136,26 +136,32 @@ export class APIError extends Error {
136
136
  // Create an errors field for storing error information in a JSONAPI compatible form directly on a
137
137
  // model.
138
138
  export const errorsPlugin = (schema: Schema): void => {
139
- const errorSchema = new Schema({
140
- code: {description: "Application-specific error code", type: String},
141
- detail: {description: "Human-readable explanation of the error", type: String},
142
- id: {description: "Unique identifier for this error occurrence", type: String},
143
- links: {
144
- about: {description: "Link to documentation about this error", type: String},
145
- type: {description: "Link describing the error type", type: String},
146
- },
147
- meta: {description: "Non-standard meta information about the error", type: Schema.Types.Mixed},
148
- source: {
149
- header: {description: "HTTP header that caused the error", type: String},
150
- parameter: {description: "Query parameter that caused the error", type: String},
151
- pointer: {
152
- description: "JSON pointer to the request field that caused the error",
153
- 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
+ },
154
159
  },
160
+ status: {description: "HTTP status code for this error", type: Number},
161
+ title: {description: "Short summary of the error", required: true, type: String},
155
162
  },
156
- status: {description: "HTTP status code for this error", type: Number},
157
- title: {description: "Short summary of the error", required: true, type: String},
158
- });
163
+ {_id: false, strict: "throw"}
164
+ );
159
165
 
160
166
  schema.add({apiErrors: errorSchema});
161
167
  };
package/src/example.ts CHANGED
@@ -48,6 +48,9 @@ const userSchema = new Schema<User>(
48
48
  // biome-ignore lint/suspicious/noExplicitAny: passport-local-mongoose's plugin type is incompatible with mongoose Schema generics
49
49
  userSchema.plugin(passportLocalMongoose as any, {usernameField: "email"});
50
50
  userSchema.plugin(createdUpdatedPlugin);
51
+ userSchema.plugin(isDeletedPlugin);
52
+ userSchema.plugin(findOneOrNone);
53
+ userSchema.plugin(findExactlyOne);
51
54
  userSchema.plugin(baseUserPlugin);
52
55
  const UserModel = model<User>("User", userSchema);
53
56
 
@@ -770,6 +770,182 @@ describe("expressServer", () => {
770
770
 
771
771
  expect(func).toHaveBeenCalled();
772
772
  });
773
+
774
+ it("fires warning timeout callback when script is slow", async () => {
775
+ // terminateTimeout = 0.3s → warnTime = 150ms, closeTime = 300ms
776
+ // The func takes 200ms, so the warn callback fires at 150ms,
777
+ // but func completes at 200ms before the terminate callback at 300ms.
778
+ // afterEach clears the terminate timer.
779
+ const func = mock(
780
+ () => new Promise<string>((resolve) => originalSetTimeout(() => resolve("done"), 200))
781
+ );
782
+ try {
783
+ await wrapScript(func, {terminateTimeout: 0.3});
784
+ } catch {
785
+ // process.exit(0) throws
786
+ }
787
+ expect(process.exit).toHaveBeenCalledWith(0);
788
+ });
789
+ });
790
+
791
+ describe("setupServer with listen (skipListen false)", () => {
792
+ const originalEnv = process.env;
793
+
794
+ beforeEach(() => {
795
+ process.env = {
796
+ ...originalEnv,
797
+ PORT: "0", // Use port 0 to let OS assign a random free port
798
+ REFRESH_TOKEN_SECRET: "test-refresh-secret",
799
+ SESSION_SECRET: "test-session-secret",
800
+ TOKEN_EXPIRES_IN: "1h",
801
+ TOKEN_ISSUER: "test-issuer",
802
+ TOKEN_SECRET: "test-secret",
803
+ };
804
+ });
805
+
806
+ afterEach(() => {
807
+ process.env = originalEnv;
808
+ });
809
+
810
+ it("starts the server when skipListen is false", async () => {
811
+ const addRoutes = () => {};
812
+ // Mock app.listen on the Express prototype to avoid opening a real port
813
+ const express = await import("express");
814
+ const originalListen = express.default.application.listen;
815
+ // biome-ignore lint/suspicious/noExplicitAny: mocking Express internals requires type escape
816
+ express.default.application.listen = mock(function (this: unknown, ...args: unknown[]) {
817
+ const cb = args.find((a: unknown) => typeof a === "function") as (() => void) | undefined;
818
+ if (cb) {
819
+ cb();
820
+ }
821
+ return this;
822
+ }) as unknown as typeof originalListen;
823
+ try {
824
+ const app = setupServer({
825
+ addRoutes,
826
+ skipListen: false,
827
+ userModel: UserModel as any,
828
+ });
829
+ expect(app).toBeDefined();
830
+ } finally {
831
+ express.default.application.listen = originalListen;
832
+ }
833
+ });
834
+
835
+ it("handles listen error with invalid port", () => {
836
+ process.env.PORT = "-1";
837
+ const addRoutes = () => {};
838
+ // Using an invalid port should trigger the catch block and process.exit(1)
839
+ const originalExit = process.exit;
840
+ process.exit = (() => {}) as unknown as typeof process.exit;
841
+ try {
842
+ setupServer({
843
+ addRoutes,
844
+ skipListen: false,
845
+ userModel: UserModel as any,
846
+ });
847
+ } catch {
848
+ // May throw
849
+ }
850
+ process.exit = originalExit;
851
+ });
852
+ });
853
+
854
+ describe("setupServer with listen", () => {
855
+ const originalEnv = process.env;
856
+ const http = require("node:http");
857
+ let activeServer: any = null;
858
+ let originalListen: any = null;
859
+
860
+ beforeEach(() => {
861
+ process.env = {
862
+ ...originalEnv,
863
+ PORT: "0",
864
+ REFRESH_TOKEN_SECRET: "test-refresh-secret",
865
+ SESSION_SECRET: "test-session-secret",
866
+ TOKEN_EXPIRES_IN: "1h",
867
+ TOKEN_ISSUER: "test-issuer",
868
+ TOKEN_SECRET: "test-secret",
869
+ };
870
+
871
+ originalListen = http.Server.prototype.listen;
872
+ http.Server.prototype.listen = function (...args: any[]) {
873
+ activeServer = this;
874
+ return originalListen.apply(this, args);
875
+ };
876
+ });
877
+
878
+ afterEach(async () => {
879
+ process.env = originalEnv;
880
+ http.Server.prototype.listen = originalListen;
881
+ if (activeServer) {
882
+ await new Promise<void>((resolve) => activeServer.close(() => resolve()));
883
+ activeServer = null;
884
+ }
885
+ });
886
+
887
+ it("starts listening on a port when skipListen is false", async () => {
888
+ const addRoutes = () => {};
889
+
890
+ const app = setupServer({
891
+ addRoutes,
892
+ skipListen: false,
893
+ userModel: UserModel as any,
894
+ });
895
+
896
+ expect(app).toBeDefined();
897
+ await new Promise((resolve) => setTimeout(resolve, 100));
898
+ });
899
+ });
900
+
901
+ describe("wrapScript timeout callbacks", () => {
902
+ const originalEnv = process.env;
903
+ const originalExit = process.exit;
904
+ const originalSetTimeout = globalThis.setTimeout;
905
+ const timerIds: ReturnType<typeof setTimeout>[] = [];
906
+ const timerCallbacks: Array<{callback: () => void; delay: number}> = [];
907
+
908
+ beforeEach(() => {
909
+ process.env = {
910
+ ...process.env,
911
+ REFRESH_TOKEN_SECRET: "test-refresh-secret",
912
+ SESSION_SECRET: "test-session-secret",
913
+ TOKEN_EXPIRES_IN: "1h",
914
+ TOKEN_ISSUER: "test-issuer",
915
+ TOKEN_SECRET: "test-secret",
916
+ };
917
+ process.exit = mock(() => {
918
+ throw new Error("__EXIT__");
919
+ }) as unknown as typeof process.exit;
920
+
921
+ timerCallbacks.length = 0;
922
+ timerIds.length = 0;
923
+ globalThis.setTimeout = ((cb: () => void, delay: number) => {
924
+ timerCallbacks.push({callback: cb, delay});
925
+ const id = originalSetTimeout(cb, delay);
926
+ timerIds.push(id);
927
+ return id;
928
+ }) as typeof setTimeout;
929
+ });
930
+
931
+ afterEach(() => {
932
+ for (const id of timerIds) {
933
+ clearTimeout(id);
934
+ }
935
+ globalThis.setTimeout = originalSetTimeout;
936
+ process.exit = originalExit;
937
+ process.env = originalEnv;
938
+ });
939
+
940
+ it("registers warn and terminate timeouts with correct delays", async () => {
941
+ const func = async () => "ok";
942
+ await expect(wrapScript(func, {terminateTimeout: 100})).rejects.toThrow("__EXIT__");
943
+
944
+ const warnTimer = timerCallbacks.find((t) => t.delay === 50000);
945
+ const closeTimer = timerCallbacks.find((t) => t.delay === 100000);
946
+ expect(warnTimer).toBeDefined();
947
+ expect(closeTimer).toBeDefined();
948
+ });
773
949
  });
774
950
 
775
951
  describe("setupServer error handling", () => {
@@ -20,6 +20,7 @@ type WatchedChange = Extract<
20
20
  >;
21
21
 
22
22
  import type {User} from "../auth";
23
+ import {APIError} from "../errors";
23
24
  import {logger} from "../logger";
24
25
  import {checkPermissions} from "../permissions";
25
26
  import {matchesQuery} from "./queryMatcher";
@@ -402,7 +403,10 @@ export const startChangeStreamWatcher = (
402
403
 
403
404
  const nativeDb = mongoose.connection.db;
404
405
  if (!nativeDb) {
405
- throw new Error("MongoDB connection not available for change stream");
406
+ throw new APIError({
407
+ status: 500,
408
+ title: "MongoDB connection not available for change stream",
409
+ });
406
410
  }
407
411
 
408
412
  const options: ChangeStreamOptions = {
@@ -417,7 +421,7 @@ export const startChangeStreamWatcher = (
417
421
  changeWatcher = nativeDb.watch(pipeline, options);
418
422
 
419
423
  if (!changeWatcher) {
420
- throw new Error("Failed to create change stream watcher");
424
+ throw new APIError({status: 500, title: "Failed to create change stream watcher"});
421
425
  }
422
426
 
423
427
  changeWatcher.on("change", async (rawChange: ChangeStreamDocument) => {