@terreno/api 0.20.2 → 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.
- package/.ai/guidelines/core.md +71 -0
- package/.ai/skills/mongoose-schema-safety/SKILL.md +143 -0
- package/README.md +54 -1
- package/bunfig.toml +1 -1
- package/dist/__tests__/versionCheckPlugin.test.js +29 -7
- package/dist/actions.openApi.test.js +13 -11
- package/dist/api.js +98 -11
- package/dist/api.query.test.js +31 -1
- package/dist/api.test.js +211 -0
- package/dist/auth.test.js +418 -43
- package/dist/betterAuth.d.ts +1 -1
- package/dist/consentApp.test.js +1 -0
- package/dist/example.js +4 -4
- package/dist/expressServer.d.ts +0 -22
- package/dist/expressServer.js +1 -125
- package/dist/expressServer.test.js +90 -91
- package/dist/githubAuth.test.js +22 -22
- package/dist/logger.d.ts +154 -0
- package/dist/logger.js +445 -26
- package/dist/logger.test.js +435 -0
- package/dist/middleware.d.ts +7 -0
- package/dist/middleware.js +58 -1
- package/dist/middleware.test.js +159 -0
- package/dist/models/consentForm.js +2 -1
- package/dist/models/consentResponse.js +2 -1
- package/dist/models/versionConfig.js +2 -1
- package/dist/openApi.test.js +10 -17
- package/dist/openApiBuilder.d.ts +18 -0
- package/dist/openApiBuilder.js +21 -0
- package/dist/openApiBuilder.test.js +34 -10
- package/dist/permissions.test.js +10 -43
- package/dist/populate.test.js +10 -42
- package/dist/realtime/changeStreamWatcher.d.ts +4 -4
- package/dist/realtime/changeStreamWatcher.js +2 -4
- package/dist/realtime/queryMatcher.d.ts +1 -1
- package/dist/realtime/queryMatcher.js +39 -14
- package/dist/realtime/types.d.ts +3 -3
- package/dist/requestContext.d.ts +61 -0
- package/dist/requestContext.js +74 -0
- package/dist/secretProviders.test.js +335 -0
- package/dist/syncConsents.test.js +2 -2
- package/dist/terrenoApp.d.ts +27 -15
- package/dist/terrenoApp.js +24 -14
- package/dist/terrenoApp.test.js +52 -0
- package/dist/tests/bunSetup.js +66 -262
- package/dist/tests/createTestData.d.ts +9 -0
- package/dist/tests/createTestData.js +272 -0
- package/dist/tests/models.d.ts +71 -0
- package/dist/tests/models.js +134 -0
- package/dist/tests/mongoTestSetup.d.ts +7 -0
- package/dist/tests/mongoTestSetup.js +150 -0
- package/dist/tests/testEnv.d.ts +0 -0
- package/dist/tests/testEnv.js +6 -0
- package/dist/tests/testHelper.d.ts +22 -0
- package/dist/tests/testHelper.js +115 -0
- package/dist/tests/types.d.ts +29 -0
- package/dist/tests/types.js +2 -0
- package/dist/tests.d.ts +10 -78
- package/dist/tests.js +24 -241
- package/dist/transformers.test.js +14 -50
- package/package.json +18 -4
- package/src/__snapshots__/openApiBuilder.test.ts.snap +1 -0
- package/src/__tests__/versionCheckPlugin.test.ts +43 -15
- package/src/actions.openApi.test.ts +12 -10
- package/src/api.query.test.ts +24 -1
- package/src/api.test.ts +169 -0
- package/src/api.ts +71 -0
- package/src/auth.test.ts +287 -39
- package/src/betterAuth.ts +1 -1
- package/src/consentApp.test.ts +1 -0
- package/src/example.ts +4 -4
- package/src/expressServer.test.ts +82 -85
- package/src/expressServer.ts +1 -213
- package/src/githubAuth.test.ts +22 -22
- package/src/logger.test.ts +466 -1
- package/src/logger.ts +477 -14
- package/src/middleware.test.ts +74 -2
- package/src/middleware.ts +57 -0
- package/src/models/consentForm.ts +3 -4
- package/src/models/consentResponse.ts +6 -4
- package/src/models/versionConfig.ts +3 -4
- package/src/openApi.test.ts +10 -17
- package/src/openApiBuilder.test.ts +27 -10
- package/src/openApiBuilder.ts +24 -0
- package/src/permissions.test.ts +8 -23
- package/src/populate.test.ts +7 -22
- package/src/realtime/changeStreamWatcher.ts +15 -10
- package/src/realtime/queryMatcher.ts +54 -27
- package/src/realtime/types.ts +4 -4
- package/src/requestContext.ts +86 -0
- package/src/secretProviders.test.ts +219 -1
- package/src/syncConsents.test.ts +1 -1
- package/src/terrenoApp.test.ts +38 -0
- package/src/terrenoApp.ts +37 -15
- package/src/tests/bunSetup.ts +22 -236
- package/src/tests/createTestData.ts +176 -0
- package/src/tests/models.ts +164 -0
- package/src/tests/mongoTestSetup.ts +69 -0
- package/src/tests/testEnv.ts +4 -0
- package/src/tests/testHelper.ts +57 -0
- package/src/tests/types.ts +35 -0
- package/src/tests.ts +40 -231
- package/src/transformers.test.ts +11 -30
- package/tsconfig.typedoc.json +4 -0
- package/dist/tests/index.d.ts +0 -1
- package/dist/tests/index.js +0 -17
- package/src/tests/index.ts +0 -1
package/src/middleware.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as Sentry from "@sentry/bun";
|
|
2
2
|
import type {NextFunction, Request, Response} from "express";
|
|
3
3
|
|
|
4
|
+
import {getCurrentRequestContext} from "./requestContext";
|
|
5
|
+
|
|
4
6
|
/**
|
|
5
7
|
* Express middleware that captures the app version from the request header
|
|
6
8
|
* and adds it as a tag to the current Sentry scope.
|
|
@@ -20,3 +22,58 @@ export const sentryAppVersionMiddleware = (
|
|
|
20
22
|
}
|
|
21
23
|
next();
|
|
22
24
|
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* OpenAPI vendor routes that must return pristine JSON (no injected requestId).
|
|
28
|
+
* Matches @wesleytodd/openapi: main spec, per-component JSON, and validate payload.
|
|
29
|
+
*/
|
|
30
|
+
const isOpenApiToolingJsonRequest = (req: Request): boolean => {
|
|
31
|
+
if (req.method !== "GET") {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
const {path} = req;
|
|
35
|
+
if (path === "/openapi.json") {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
if (path === "/openapi/validate") {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
if (path.startsWith("/openapi/components/") && path.endsWith(".json")) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* TerrenoApp middleware: augments `res.json` so plain-object payloads include
|
|
49
|
+
* `requestId` for client correlation. Skips OpenAPI tooling GET JSON routes
|
|
50
|
+
* (`/openapi.json`, `/openapi/components/...json`, `/openapi/validate`) so
|
|
51
|
+
* machine-consumed payloads stay valid. Does not wrap arrays or primitives.
|
|
52
|
+
*/
|
|
53
|
+
export const jsonResponseRequestIdMiddleware = (
|
|
54
|
+
req: Request,
|
|
55
|
+
res: Response,
|
|
56
|
+
next: NextFunction
|
|
57
|
+
): void => {
|
|
58
|
+
const originalJson = res.json.bind(res);
|
|
59
|
+
res.json = (body?: unknown): Response => {
|
|
60
|
+
if (isOpenApiToolingJsonRequest(req)) {
|
|
61
|
+
return originalJson(body);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const requestId =
|
|
65
|
+
(req as Request & {requestId?: string}).requestId ?? getCurrentRequestContext()?.requestId;
|
|
66
|
+
|
|
67
|
+
if (!requestId) {
|
|
68
|
+
return originalJson(body);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (body !== null && body !== undefined && typeof body === "object" && !Array.isArray(body)) {
|
|
72
|
+
return originalJson({...(body as Record<string, unknown>), requestId});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return originalJson(body);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
next();
|
|
79
|
+
};
|
|
@@ -126,7 +126,6 @@ consentFormSchema.plugin(isDeletedPlugin);
|
|
|
126
126
|
consentFormSchema.plugin(findOneOrNone);
|
|
127
127
|
consentFormSchema.plugin(findExactlyOne);
|
|
128
128
|
|
|
129
|
-
export const ConsentForm =
|
|
130
|
-
|
|
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 =
|
|
76
|
-
|
|
77
|
-
|
|
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 =
|
|
101
|
-
|
|
102
|
-
versionConfigSchema
|
|
103
|
-
);
|
|
100
|
+
export const VersionConfig =
|
|
101
|
+
(mongoose.models.VersionConfig as VersionConfigModel | undefined) ??
|
|
102
|
+
mongoose.model<VersionConfigDocument, VersionConfigModel>("VersionConfig", versionConfigSchema);
|
package/src/openApi.test.ts
CHANGED
|
@@ -6,8 +6,6 @@ import supertest from "supertest";
|
|
|
6
6
|
import type TestAgent from "supertest/lib/agent";
|
|
7
7
|
|
|
8
8
|
import {type ModelRouterOptions, modelRouter} from "./api";
|
|
9
|
-
import {addAuthRoutes, setupAuth} from "./auth";
|
|
10
|
-
import {setupServer} from "./expressServer";
|
|
11
9
|
import {
|
|
12
10
|
createOpenApiMiddleware,
|
|
13
11
|
deleteOpenApiMiddleware,
|
|
@@ -17,6 +15,7 @@ import {
|
|
|
17
15
|
readOpenApiMiddleware,
|
|
18
16
|
} from "./openApi";
|
|
19
17
|
import {Permissions} from "./permissions";
|
|
18
|
+
import {TerrenoApp} from "./terrenoApp";
|
|
20
19
|
import {FoodModel, setupDb, UserModel} from "./tests";
|
|
21
20
|
|
|
22
21
|
function getMessageSummaryOpenApiMiddleware(options: Partial<ModelRouterOptions<any>>): any {
|
|
@@ -90,13 +89,11 @@ describe("openApi", () => {
|
|
|
90
89
|
process.env.REFRESH_TOKEN_SECRET = "testsecret1234";
|
|
91
90
|
process.env.ENABLE_SWAGGER = "true";
|
|
92
91
|
|
|
93
|
-
app =
|
|
94
|
-
addRoutes,
|
|
92
|
+
app = new TerrenoApp({
|
|
93
|
+
configureApp: addRoutes,
|
|
95
94
|
skipListen: true,
|
|
96
95
|
userModel: UserModel as any,
|
|
97
|
-
});
|
|
98
|
-
setupAuth(app, UserModel as any);
|
|
99
|
-
addAuthRoutes(app, UserModel as any);
|
|
96
|
+
}).build();
|
|
100
97
|
});
|
|
101
98
|
|
|
102
99
|
it("gets the openapi.json", async () => {
|
|
@@ -246,13 +243,11 @@ describe("openApi without swagger", () => {
|
|
|
246
243
|
process.env.REFRESH_TOKEN_SECRET = "testsecret1234";
|
|
247
244
|
process.env.ENABLE_SWAGGER = "false";
|
|
248
245
|
|
|
249
|
-
app =
|
|
250
|
-
addRoutes,
|
|
246
|
+
app = new TerrenoApp({
|
|
247
|
+
configureApp: addRoutes,
|
|
251
248
|
skipListen: true,
|
|
252
249
|
userModel: UserModel as any,
|
|
253
|
-
});
|
|
254
|
-
setupAuth(app, UserModel as any);
|
|
255
|
-
addAuthRoutes(app, UserModel as any);
|
|
250
|
+
}).build();
|
|
256
251
|
});
|
|
257
252
|
|
|
258
253
|
it("does not have the swagger ui", async () => {
|
|
@@ -268,13 +263,11 @@ describe("openApi populate", () => {
|
|
|
268
263
|
beforeEach(async () => {
|
|
269
264
|
process.env.REFRESH_TOKEN_SECRET = "testsecret1234";
|
|
270
265
|
|
|
271
|
-
app =
|
|
272
|
-
|
|
266
|
+
app = new TerrenoApp({
|
|
267
|
+
configureApp: addRoutesPopulate,
|
|
273
268
|
skipListen: true,
|
|
274
269
|
userModel: UserModel as any,
|
|
275
|
-
});
|
|
276
|
-
setupAuth(app, UserModel as any);
|
|
277
|
-
addAuthRoutes(app, UserModel as any);
|
|
270
|
+
}).build();
|
|
278
271
|
});
|
|
279
272
|
|
|
280
273
|
it("gets the openapi.json with populate", async () => {
|
|
@@ -6,10 +6,9 @@ import supertest from "supertest";
|
|
|
6
6
|
import type TestAgent from "supertest/lib/agent";
|
|
7
7
|
|
|
8
8
|
import {type ModelRouterOptions, modelRouter} from "./api";
|
|
9
|
-
import {addAuthRoutes, setupAuth} from "./auth";
|
|
10
|
-
import {setupServer} from "./expressServer";
|
|
11
9
|
import {createOpenApiBuilder, OpenApiMiddlewareBuilder} from "./openApiBuilder";
|
|
12
10
|
import {Permissions} from "./permissions";
|
|
11
|
+
import {TerrenoApp} from "./terrenoApp";
|
|
13
12
|
import {FoodModel, UserModel} from "./tests";
|
|
14
13
|
|
|
15
14
|
function addRoutesWithBuilder(router: Router, options?: Partial<ModelRouterOptions<any>>): void {
|
|
@@ -17,6 +16,7 @@ function addRoutesWithBuilder(router: Router, options?: Partial<ModelRouterOptio
|
|
|
17
16
|
const statsMiddleware = createOpenApiBuilder(options ?? {})
|
|
18
17
|
.withTags(["Stats"])
|
|
19
18
|
.withSummary("Get food statistics")
|
|
19
|
+
.withOperationId("getFoodStats")
|
|
20
20
|
.withDescription("Returns aggregated statistics about food items")
|
|
21
21
|
.withQueryParameter(
|
|
22
22
|
"category",
|
|
@@ -145,13 +145,11 @@ describe("OpenApiMiddlewareBuilder", () => {
|
|
|
145
145
|
process.env.REFRESH_TOKEN_SECRET = "testsecret1234";
|
|
146
146
|
process.env.ENABLE_SWAGGER = "true";
|
|
147
147
|
|
|
148
|
-
app =
|
|
149
|
-
|
|
148
|
+
app = new TerrenoApp({
|
|
149
|
+
configureApp: addRoutesWithBuilder,
|
|
150
150
|
skipListen: true,
|
|
151
151
|
userModel: UserModel as any,
|
|
152
|
-
});
|
|
153
|
-
setupAuth(app, UserModel as any);
|
|
154
|
-
addAuthRoutes(app, UserModel as any);
|
|
152
|
+
}).build();
|
|
155
153
|
});
|
|
156
154
|
|
|
157
155
|
describe("builder pattern", () => {
|
|
@@ -198,6 +196,14 @@ describe("OpenApiMiddlewareBuilder", () => {
|
|
|
198
196
|
expect(categoryParam.required).toBe(false);
|
|
199
197
|
});
|
|
200
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
|
+
|
|
201
207
|
it("includes request body schema in OpenAPI spec", async () => {
|
|
202
208
|
server = supertest(app);
|
|
203
209
|
const res = await server.get("/openapi.json").expect(200);
|
|
@@ -302,7 +308,11 @@ describe("OpenApiMiddlewareBuilder", () => {
|
|
|
302
308
|
it("stats endpoint returns correct data", async () => {
|
|
303
309
|
server = supertest(app);
|
|
304
310
|
const res = await server.get("/food/stats").expect(200);
|
|
305
|
-
expect(res.body).toEqual({
|
|
311
|
+
expect(res.body).toEqual({
|
|
312
|
+
avgCalories: 250,
|
|
313
|
+
count: 10,
|
|
314
|
+
requestId: res.headers["x-request-id"],
|
|
315
|
+
});
|
|
306
316
|
});
|
|
307
317
|
|
|
308
318
|
it("reports endpoint returns correct data", async () => {
|
|
@@ -311,7 +321,10 @@ describe("OpenApiMiddlewareBuilder", () => {
|
|
|
311
321
|
.post("/food/reports")
|
|
312
322
|
.send({endDate: "2024-12-31", startDate: "2024-01-01"})
|
|
313
323
|
.expect(201);
|
|
314
|
-
expect(res.body).toEqual({
|
|
324
|
+
expect(res.body).toEqual({
|
|
325
|
+
reportId: "report-123",
|
|
326
|
+
requestId: res.headers["x-request-id"],
|
|
327
|
+
});
|
|
315
328
|
});
|
|
316
329
|
|
|
317
330
|
it("categories endpoint returns array data", async () => {
|
|
@@ -325,7 +338,11 @@ describe("OpenApiMiddlewareBuilder", () => {
|
|
|
325
338
|
it("category by id endpoint returns correct data", async () => {
|
|
326
339
|
server = supertest(app);
|
|
327
340
|
const res = await server.get("/food/categories/cat-123").expect(200);
|
|
328
|
-
expect(res.body).toEqual({
|
|
341
|
+
expect(res.body).toEqual({
|
|
342
|
+
id: "cat-123",
|
|
343
|
+
name: "Fruits",
|
|
344
|
+
requestId: res.headers["x-request-id"],
|
|
345
|
+
});
|
|
329
346
|
});
|
|
330
347
|
});
|
|
331
348
|
|
package/src/openApiBuilder.ts
CHANGED
|
@@ -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
|
*
|
package/src/permissions.test.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
FoodModel,
|
|
14
14
|
getBaseServer,
|
|
15
15
|
RequiredModel,
|
|
16
|
-
|
|
16
|
+
setupTestData,
|
|
17
17
|
UserModel,
|
|
18
18
|
} from "./tests";
|
|
19
19
|
|
|
@@ -24,22 +24,7 @@ describe("permissions", () => {
|
|
|
24
24
|
beforeEach(async () => {
|
|
25
25
|
process.env.REFRESH_TOKEN_SECRET = "testsecret1234";
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
await Promise.all([
|
|
30
|
-
FoodModel.create({
|
|
31
|
-
calories: 1,
|
|
32
|
-
created: new Date(),
|
|
33
|
-
name: "Spinach",
|
|
34
|
-
ownerId: notAdmin._id,
|
|
35
|
-
}),
|
|
36
|
-
FoodModel.create({
|
|
37
|
-
calories: 100,
|
|
38
|
-
created: Date.now() - 10,
|
|
39
|
-
name: "Apple",
|
|
40
|
-
ownerId: admin._id,
|
|
41
|
-
}),
|
|
42
|
-
]);
|
|
27
|
+
await setupTestData();
|
|
43
28
|
app = getBaseServer();
|
|
44
29
|
setupAuth(app, UserModel as any);
|
|
45
30
|
addAuthRoutes(app, UserModel as any);
|
|
@@ -74,12 +59,12 @@ describe("permissions", () => {
|
|
|
74
59
|
describe("anonymous food", () => {
|
|
75
60
|
it("list", async () => {
|
|
76
61
|
const res = await server.get("/food").expect(200);
|
|
77
|
-
expect(res.body.data).toHaveLength(
|
|
62
|
+
expect(res.body.data).toHaveLength(4);
|
|
78
63
|
});
|
|
79
64
|
|
|
80
65
|
it("get", async () => {
|
|
81
66
|
const res = await server.get("/food").expect(200);
|
|
82
|
-
expect(res.body.data).toHaveLength(
|
|
67
|
+
expect(res.body.data).toHaveLength(4);
|
|
83
68
|
const res2 = await server.get(`/food/${res.body.data[0]._id}`).expect(200);
|
|
84
69
|
expect(res.body.data[0]._id).toBe(res2.body.data._id);
|
|
85
70
|
});
|
|
@@ -116,12 +101,12 @@ describe("permissions", () => {
|
|
|
116
101
|
|
|
117
102
|
it("list", async () => {
|
|
118
103
|
const res = await agent.get("/food").expect(200);
|
|
119
|
-
expect(res.body.data).toHaveLength(
|
|
104
|
+
expect(res.body.data).toHaveLength(4);
|
|
120
105
|
});
|
|
121
106
|
|
|
122
107
|
it("get", async () => {
|
|
123
108
|
const res = await agent.get("/food").expect(200);
|
|
124
|
-
expect(res.body.data).toHaveLength(
|
|
109
|
+
expect(res.body.data).toHaveLength(4);
|
|
125
110
|
const res2 = await server.get(`/food/${res.body.data[0]._id}`).expect(200);
|
|
126
111
|
expect(res.body.data[0]._id).toBe(res2.body.data._id);
|
|
127
112
|
});
|
|
@@ -175,12 +160,12 @@ describe("permissions", () => {
|
|
|
175
160
|
|
|
176
161
|
it("list", async () => {
|
|
177
162
|
const res = await agent.get("/food");
|
|
178
|
-
expect(res.body.data).toHaveLength(
|
|
163
|
+
expect(res.body.data).toHaveLength(4);
|
|
179
164
|
});
|
|
180
165
|
|
|
181
166
|
it("get", async () => {
|
|
182
167
|
const res = await agent.get("/food");
|
|
183
|
-
expect(res.body.data).toHaveLength(
|
|
168
|
+
expect(res.body.data).toHaveLength(4);
|
|
184
169
|
const res2 = await agent.get(`/food/${res.body.data[0]._id}`);
|
|
185
170
|
expect(res.body.data[0]._id).toBe(res2.body.data._id);
|
|
186
171
|
});
|
package/src/populate.test.ts
CHANGED
|
@@ -3,7 +3,7 @@ import {beforeEach, describe, expect, it} from "bun:test";
|
|
|
3
3
|
import mongoose, {type Document, type HydratedDocument, Schema} from "mongoose";
|
|
4
4
|
|
|
5
5
|
import {fixMixedFields, getOpenApiSpecForModel, unpopulate} from "./populate";
|
|
6
|
-
import {FoodModel,
|
|
6
|
+
import {FoodModel, setupTestData, type User, UserModel} from "./tests";
|
|
7
7
|
|
|
8
8
|
describe("populate functions", () => {
|
|
9
9
|
let admin: HydratedDocument<User>;
|
|
@@ -13,32 +13,17 @@ describe("populate functions", () => {
|
|
|
13
13
|
let spinach: any;
|
|
14
14
|
|
|
15
15
|
beforeEach(async () => {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
calories: 1,
|
|
21
|
-
created: new Date("2021-12-03T00:00:20.000Z"),
|
|
22
|
-
eatenBy: [admin._id],
|
|
23
|
-
hidden: false,
|
|
24
|
-
likesIds: [
|
|
25
|
-
{likes: true, userId: admin._id},
|
|
26
|
-
{likes: false, userId: notAdmin._id},
|
|
27
|
-
],
|
|
28
|
-
name: "Spinach",
|
|
29
|
-
ownerId: admin._id,
|
|
30
|
-
source: {
|
|
31
|
-
name: "Brand",
|
|
32
|
-
},
|
|
33
|
-
}),
|
|
34
|
-
]);
|
|
16
|
+
const testData = await setupTestData();
|
|
17
|
+
admin = testData.users.admin;
|
|
18
|
+
notAdmin = testData.users.notAdmin;
|
|
19
|
+
spinach = testData.foods.spinach;
|
|
35
20
|
});
|
|
36
21
|
|
|
37
22
|
it("unpopulate", async () => {
|
|
38
23
|
let populated = await spinach.populate("ownerId");
|
|
39
24
|
populated = await populated.populate("eatenBy");
|
|
40
25
|
populated = await populated.populate("likesIds.userId");
|
|
41
|
-
expect(populated.ownerId.name).toBe("Admin");
|
|
26
|
+
expect(populated.ownerId.name).toBe("Not Admin");
|
|
42
27
|
expect(populated.eatenBy[0].id).toBe(admin.id);
|
|
43
28
|
expect(populated.eatenBy[0].name).toBe("Admin");
|
|
44
29
|
expect(populated.likesIds[0].userId.id).toBe(admin.id);
|
|
@@ -49,7 +34,7 @@ describe("populate functions", () => {
|
|
|
49
34
|
// noExplicitAny: unpopulate returns Document<T> which doesn't expose model properties; would require refactoring the return type
|
|
50
35
|
let unpopulated: any = unpopulate(populated, "ownerId");
|
|
51
36
|
expect(spinach.ownerId.name).toBeUndefined();
|
|
52
|
-
expect(unpopulated.ownerId.toString()).toBe(
|
|
37
|
+
expect(unpopulated.ownerId.toString()).toBe(notAdmin.id);
|
|
53
38
|
// Ensure nothing else was touched.
|
|
54
39
|
expect(populated.likesIds[0].userId.id).toBe(admin.id);
|
|
55
40
|
expect(populated.likesIds[0].userId.name).toBe("Admin");
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// biome-ignore-all lint/suspicious/noExplicitAny: change stream and socket handlers use dynamic document shapes
|
|
2
1
|
import * as Sentry from "@sentry/bun";
|
|
3
2
|
import type express from "express";
|
|
4
3
|
import {DateTime} from "luxon";
|
|
@@ -98,7 +97,7 @@ const getSocketsInRoom = (io: Server, room: string): RealtimeSocketWithAuth[] =>
|
|
|
98
97
|
const canReadDocument = async (
|
|
99
98
|
entry: RealtimeRegistryEntry,
|
|
100
99
|
user?: User,
|
|
101
|
-
doc?:
|
|
100
|
+
doc?: Record<string, unknown>
|
|
102
101
|
): Promise<boolean> => {
|
|
103
102
|
return checkPermissions("read", entry.options.permissions.read, user, doc);
|
|
104
103
|
};
|
|
@@ -107,7 +106,11 @@ const canReadDocument = async (
|
|
|
107
106
|
* Determine which Socket.io rooms to emit to based on the room strategy.
|
|
108
107
|
* Exported for testing.
|
|
109
108
|
*/
|
|
110
|
-
export const resolveRooms = (
|
|
109
|
+
export const resolveRooms = (
|
|
110
|
+
entry: RealtimeRegistryEntry,
|
|
111
|
+
doc: Record<string, unknown>,
|
|
112
|
+
method: string
|
|
113
|
+
): string[] => {
|
|
111
114
|
const {roomStrategy} = entry.config;
|
|
112
115
|
// Use the collection tag (e.g. "todos") for model rooms, matching what the frontend subscribes to
|
|
113
116
|
const collectionTag = getCollectionTag(entry.routePath);
|
|
@@ -120,7 +123,7 @@ export const resolveRooms = (entry: RealtimeRegistryEntry, doc: any, method: str
|
|
|
120
123
|
|
|
121
124
|
switch (roomStrategy) {
|
|
122
125
|
case "owner": {
|
|
123
|
-
const ownerId = doc?.ownerId
|
|
126
|
+
const ownerId = doc?.ownerId != null ? String(doc.ownerId) : undefined;
|
|
124
127
|
if (ownerId) {
|
|
125
128
|
return [`user:${ownerId}`];
|
|
126
129
|
}
|
|
@@ -169,10 +172,10 @@ export const ensureApiId = (data: unknown): unknown => {
|
|
|
169
172
|
*/
|
|
170
173
|
export const serializeDoc = async (
|
|
171
174
|
entry: RealtimeRegistryEntry,
|
|
172
|
-
doc:
|
|
175
|
+
doc: Record<string, unknown>,
|
|
173
176
|
method: "create" | "update" | "delete",
|
|
174
177
|
user?: User
|
|
175
|
-
): Promise<
|
|
178
|
+
): Promise<unknown> => {
|
|
176
179
|
if (entry.config.realtimeResponseHandler) {
|
|
177
180
|
try {
|
|
178
181
|
return ensureApiId(await entry.config.realtimeResponseHandler(doc, method));
|
|
@@ -203,7 +206,9 @@ export const serializeDoc = async (
|
|
|
203
206
|
}
|
|
204
207
|
}
|
|
205
208
|
|
|
206
|
-
return ensureApiId(
|
|
209
|
+
return ensureApiId(
|
|
210
|
+
typeof doc.toJSON === "function" ? (doc as {toJSON: () => unknown}).toJSON() : doc
|
|
211
|
+
);
|
|
207
212
|
};
|
|
208
213
|
|
|
209
214
|
export const emitToAuthorizedRoom = async (
|
|
@@ -211,7 +216,7 @@ export const emitToAuthorizedRoom = async (
|
|
|
211
216
|
room: string,
|
|
212
217
|
event: RealtimeEvent,
|
|
213
218
|
entry: RealtimeRegistryEntry,
|
|
214
|
-
fullDocument:
|
|
219
|
+
fullDocument: Record<string, unknown> | undefined,
|
|
215
220
|
logDebug: (msg: string) => void
|
|
216
221
|
): Promise<void> => {
|
|
217
222
|
const sockets = getSocketsInRoom(io, room);
|
|
@@ -263,7 +268,7 @@ export const emitToDocumentAndQueryRooms = async (
|
|
|
263
268
|
io: Server,
|
|
264
269
|
collection: string,
|
|
265
270
|
event: RealtimeEvent,
|
|
266
|
-
fullDocument:
|
|
271
|
+
fullDocument: Record<string, unknown> | undefined,
|
|
267
272
|
logDebug: (msg: string) => void,
|
|
268
273
|
entry?: RealtimeRegistryEntry
|
|
269
274
|
): Promise<void> => {
|
|
@@ -495,7 +500,7 @@ export const startChangeStreamWatcher = (
|
|
|
495
500
|
rooms = [`model:${collectionTag}`];
|
|
496
501
|
}
|
|
497
502
|
} else {
|
|
498
|
-
rooms = resolveRooms(entry, fullDocument, method);
|
|
503
|
+
rooms = resolveRooms(entry, fullDocument ?? {}, method);
|
|
499
504
|
}
|
|
500
505
|
|
|
501
506
|
const collection = getCollectionTag(entry.routePath);
|