@terreno/api 0.16.1 → 0.18.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.
@@ -70,7 +70,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
70
70
  var bun_test_1 = require("bun:test");
71
71
  var mongoose_1 = require("mongoose");
72
72
  var supertest_1 = __importDefault(require("supertest"));
73
- var zod_1 = require("zod");
74
73
  var actions_1 = require("./actions");
75
74
  var api_1 = require("./api");
76
75
  var auth_1 = require("./auth");
@@ -78,6 +77,7 @@ var errors_1 = require("./errors");
78
77
  var permissions_1 = require("./permissions");
79
78
  var plugins_1 = require("./plugins");
80
79
  var tests_1 = require("./tests");
80
+ var zodOpenApi_1 = require("./zodOpenApi");
81
81
  var stuffSchema = new mongoose_1.Schema({
82
82
  name: { type: String },
83
83
  ownerId: { type: String },
@@ -561,7 +561,7 @@ var allPermissions = {
561
561
  mountFoodRouter({
562
562
  collectionActions: {
563
563
  notify: {
564
- body: zod_1.z.object({ email: zod_1.z.string().email() }),
564
+ body: zodOpenApi_1.z.object({ email: zodOpenApi_1.z.string().email() }),
565
565
  handler: function (_a) { return __awaiter(void 0, [_a], void 0, function (_b) {
566
566
  var body = _b.body;
567
567
  return __generator(this, function (_c) {
@@ -591,7 +591,7 @@ var allPermissions = {
591
591
  mountFoodRouter({
592
592
  collectionActions: {
593
593
  notify: {
594
- body: zod_1.z.object({ email: zod_1.z.string().email() }),
594
+ body: zodOpenApi_1.z.object({ email: zodOpenApi_1.z.string().email() }),
595
595
  handler: function () { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
596
596
  return [2 /*return*/, ({ sent: true })];
597
597
  }); }); },
@@ -628,7 +628,7 @@ var allPermissions = {
628
628
  }); },
629
629
  method: "GET",
630
630
  permissions: [permissions_1.Permissions.IsAny],
631
- query: zod_1.z.object({ count: zod_1.z.coerce.number() }),
631
+ query: zodOpenApi_1.z.object({ count: zodOpenApi_1.z.coerce.number() }),
632
632
  },
633
633
  },
634
634
  permissions: allPermissions,
@@ -642,6 +642,33 @@ var allPermissions = {
642
642
  }
643
643
  });
644
644
  }); });
645
+ (0, bun_test_1.it)("returns 400 with meta.fields for invalid query", function () { return __awaiter(void 0, void 0, void 0, function () {
646
+ var res;
647
+ return __generator(this, function (_a) {
648
+ switch (_a.label) {
649
+ case 0:
650
+ mountFoodRouter({
651
+ collectionActions: {
652
+ lookup: {
653
+ handler: function () { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
654
+ return [2 /*return*/, ({ ok: true })];
655
+ }); }); },
656
+ method: "GET",
657
+ permissions: [permissions_1.Permissions.IsAny],
658
+ query: zodOpenApi_1.z.object({ email: zodOpenApi_1.z.string().email() }),
659
+ },
660
+ },
661
+ permissions: allPermissions,
662
+ });
663
+ return [4 /*yield*/, server.get("/food/lookup?email=bad").expect(400)];
664
+ case 1:
665
+ res = _a.sent();
666
+ (0, bun_test_1.expect)(res.body.title).toBe("Validation failed");
667
+ (0, bun_test_1.expect)(res.body.meta.fields.email).toBeDefined();
668
+ return [2 /*return*/];
669
+ }
670
+ });
671
+ }); });
645
672
  (0, bun_test_1.it)("coerces body values via zod in ctx", function () { return __awaiter(void 0, void 0, void 0, function () {
646
673
  var seenCount, res;
647
674
  return __generator(this, function (_a) {
@@ -650,7 +677,7 @@ var allPermissions = {
650
677
  mountFoodRouter({
651
678
  collectionActions: {
652
679
  tally: {
653
- body: zod_1.z.object({ count: zod_1.z.coerce.number() }),
680
+ body: zodOpenApi_1.z.object({ count: zodOpenApi_1.z.coerce.number() }),
654
681
  handler: function (_a) { return __awaiter(void 0, [_a], void 0, function (_b) {
655
682
  var parsed;
656
683
  var body = _b.body;
@@ -684,7 +711,7 @@ var allPermissions = {
684
711
  mountFoodRouter({
685
712
  collectionActions: {
686
713
  strictish: {
687
- body: zod_1.z.object({ known: zod_1.z.string() }),
714
+ body: zodOpenApi_1.z.object({ known: zodOpenApi_1.z.string() }),
688
715
  handler: function (_a) { return __awaiter(void 0, [_a], void 0, function (_b) {
689
716
  var body = _b.body;
690
717
  return __generator(this, function (_c) {
@@ -910,7 +937,7 @@ var allPermissions = {
910
937
  (0, bun_test_1.describe)("defineInstanceAction type ergonomics", function () {
911
938
  (0, bun_test_1.it)("preserves handler types at compile time", function () {
912
939
  var action = (0, actions_1.defineInstanceAction)({
913
- body: zod_1.z.object({ notifyUsers: zod_1.z.boolean() }),
940
+ body: zodOpenApi_1.z.object({ notifyUsers: zodOpenApi_1.z.boolean() }),
914
941
  handler: function (_a) { return __awaiter(void 0, [_a], void 0, function (_b) {
915
942
  var _doc, _notify;
916
943
  var _c, _d;
@@ -928,7 +955,7 @@ var allPermissions = {
928
955
  });
929
956
  (0, bun_test_1.it)("defineCollectionAction preserves body types", function () {
930
957
  var action = (0, actions_1.defineCollectionAction)({
931
- body: zod_1.z.object({ ids: zod_1.z.array(zod_1.z.string()) }),
958
+ body: zodOpenApi_1.z.object({ ids: zodOpenApi_1.z.array(zodOpenApi_1.z.string()) }),
932
959
  handler: function (_a) { return __awaiter(void 0, [_a], void 0, function (_b) {
933
960
  var _ids;
934
961
  var body = _b.body;
@@ -943,4 +970,113 @@ var allPermissions = {
943
970
  (0, bun_test_1.expect)(action.method).toBe("POST");
944
971
  });
945
972
  });
973
+ (0, bun_test_1.describe)("createActionOpenApiMiddleware", function () {
974
+ var createMockOpenApi = function () {
975
+ var captured;
976
+ var pathFn = function (op) {
977
+ captured = op;
978
+ return function (_req, _res, next) {
979
+ return next();
980
+ };
981
+ };
982
+ return {
983
+ get captured() {
984
+ return captured;
985
+ },
986
+ middleware: { path: pathFn },
987
+ };
988
+ };
989
+ (0, bun_test_1.it)("returns no-op middleware when openApi is not configured", function () {
990
+ var handler = (0, actions_1.createActionOpenApiMiddleware)({
991
+ action: { method: "GET", permissions: [] },
992
+ actionName: "test",
993
+ model: tests_1.FoodModel,
994
+ options: {},
995
+ scope: "collection",
996
+ });
997
+ (0, bun_test_1.expect)(typeof handler).toBe("function");
998
+ });
999
+ (0, bun_test_1.it)("generates query parameters from action.query schema", function () {
1000
+ var _a;
1001
+ var mock = createMockOpenApi();
1002
+ (0, actions_1.createActionOpenApiMiddleware)({
1003
+ action: {
1004
+ method: "GET",
1005
+ permissions: [],
1006
+ query: zodOpenApi_1.z.object({ page: zodOpenApi_1.z.coerce.number(), search: zodOpenApi_1.z.string() }),
1007
+ },
1008
+ actionName: "search",
1009
+ model: tests_1.FoodModel,
1010
+ options: { openApi: mock.middleware },
1011
+ scope: "collection",
1012
+ });
1013
+ var params = (_a = mock.captured) === null || _a === void 0 ? void 0 : _a.parameters;
1014
+ (0, bun_test_1.expect)(params.length).toBe(2);
1015
+ (0, bun_test_1.expect)(params.some(function (p) { return p.name === "search"; })).toBe(true);
1016
+ (0, bun_test_1.expect)(params.some(function (p) { return p.name === "page"; })).toBe(true);
1017
+ });
1018
+ (0, bun_test_1.it)("includes id path parameter for instance-scoped actions", function () {
1019
+ var _a;
1020
+ var mock = createMockOpenApi();
1021
+ (0, actions_1.createActionOpenApiMiddleware)({
1022
+ action: { method: "GET", permissions: [] },
1023
+ actionName: "peek",
1024
+ model: tests_1.FoodModel,
1025
+ options: { openApi: mock.middleware },
1026
+ scope: "instance",
1027
+ });
1028
+ var params = (_a = mock.captured) === null || _a === void 0 ? void 0 : _a.parameters;
1029
+ (0, bun_test_1.expect)(params.some(function (p) { return p.name === "id" && p.in === "path"; })).toBe(true);
1030
+ });
1031
+ (0, bun_test_1.it)("uses fallback data schema when response is not provided", function () {
1032
+ var _a;
1033
+ var mock = createMockOpenApi();
1034
+ (0, actions_1.createActionOpenApiMiddleware)({
1035
+ action: { method: "POST", permissions: [] },
1036
+ actionName: "noop",
1037
+ model: tests_1.FoodModel,
1038
+ options: { openApi: mock.middleware },
1039
+ scope: "collection",
1040
+ });
1041
+ var responses = (_a = mock.captured) === null || _a === void 0 ? void 0 : _a.responses;
1042
+ var ok = responses["200"];
1043
+ var schema = ok.content["application/json"].schema;
1044
+ (0, bun_test_1.expect)(schema.properties.data).toEqual({ type: "object" });
1045
+ });
1046
+ (0, bun_test_1.it)("uses zod response schema when provided", function () {
1047
+ var _a;
1048
+ var mock = createMockOpenApi();
1049
+ (0, actions_1.createActionOpenApiMiddleware)({
1050
+ action: {
1051
+ method: "POST",
1052
+ permissions: [],
1053
+ response: zodOpenApi_1.z.object({ count: zodOpenApi_1.z.number() }),
1054
+ },
1055
+ actionName: "tally",
1056
+ model: tests_1.FoodModel,
1057
+ options: { openApi: mock.middleware },
1058
+ scope: "collection",
1059
+ });
1060
+ var responses = (_a = mock.captured) === null || _a === void 0 ? void 0 : _a.responses;
1061
+ var ok = responses["200"];
1062
+ var schema = ok.content["application/json"].schema;
1063
+ (0, bun_test_1.expect)(schema.properties.data.properties).toBeDefined();
1064
+ });
1065
+ (0, bun_test_1.it)("includes requestBody when action.body is defined", function () {
1066
+ var _a;
1067
+ var mock = createMockOpenApi();
1068
+ (0, actions_1.createActionOpenApiMiddleware)({
1069
+ action: {
1070
+ body: zodOpenApi_1.z.object({ name: zodOpenApi_1.z.string() }),
1071
+ method: "POST",
1072
+ permissions: [],
1073
+ },
1074
+ actionName: "create",
1075
+ model: tests_1.FoodModel,
1076
+ options: { openApi: mock.middleware },
1077
+ scope: "collection",
1078
+ });
1079
+ (0, bun_test_1.expect)((_a = mock.captured) === null || _a === void 0 ? void 0 : _a.requestBody).toBeDefined();
1080
+ });
1081
+ });
946
1082
  });
@@ -319,4 +319,53 @@ var setupLoader = function () {
319
319
  }
320
320
  });
321
321
  }); });
322
+ (0, bun_test_1.it)("mapToObject handles a plain Record (non-Map) env field", function () { return __awaiter(void 0, void 0, void 0, function () {
323
+ var col;
324
+ var _a;
325
+ return __generator(this, function (_b) {
326
+ switch (_b.label) {
327
+ case 0:
328
+ col = (_a = mongoose_1.default.connection.db) === null || _a === void 0 ? void 0 : _a.collection("testenvconfigs");
329
+ return [4 /*yield*/, (col === null || col === void 0 ? void 0 : col.insertOne({ env: { TERRENO_PLUGIN_KEY: "plainObj" } }))];
330
+ case 1:
331
+ _b.sent();
332
+ // Trigger refresh via findOneAndUpdate hook
333
+ return [4 /*yield*/, TestEnvConfig.findOneAndUpdate({}, { $set: { __v: 1 } })];
334
+ case 2:
335
+ // Trigger refresh via findOneAndUpdate hook
336
+ _b.sent();
337
+ (0, bun_test_1.expect)(config_1.Config.get("TERRENO_PLUGIN_KEY")).toBe("plainObj");
338
+ return [2 /*return*/];
339
+ }
340
+ });
341
+ }); });
342
+ (0, bun_test_1.it)("refreshFromDoc logs a warning and does not throw when the model query fails", function () { return __awaiter(void 0, void 0, void 0, function () {
343
+ var doc, originalFind;
344
+ return __generator(this, function (_a) {
345
+ switch (_a.label) {
346
+ case 0:
347
+ doc = new TestEnvConfig();
348
+ doc.env.set("TERRENO_PLUGIN_KEY", "initial");
349
+ return [4 /*yield*/, doc.save()];
350
+ case 1:
351
+ _a.sent();
352
+ // Pre-set cache to verify it is NOT overwritten when the hook errors
353
+ config_1.Config.setCachedEnv({ TERRENO_PLUGIN_KEY: "cached" });
354
+ originalFind = TestEnvConfig.find;
355
+ TestEnvConfig.find = function () {
356
+ throw new Error("Simulated DB error");
357
+ };
358
+ // Trigger the post-findOneAndUpdate hook → refreshFromDoc → findOneOrNoneFor → throws
359
+ return [4 /*yield*/, TestEnvConfig.findOneAndUpdate({ _id: doc._id }, { $set: { __v: 2 } })];
360
+ case 2:
361
+ // Trigger the post-findOneAndUpdate hook → refreshFromDoc → findOneOrNoneFor → throws
362
+ _a.sent();
363
+ // Restore immediately
364
+ TestEnvConfig.find = originalFind;
365
+ // The catch block should have swallowed the error; cache keeps old value
366
+ (0, bun_test_1.expect)(config_1.Config.get("TERRENO_PLUGIN_KEY")).toBe("cached");
367
+ return [2 /*return*/];
368
+ }
369
+ });
370
+ }); });
322
371
  });
package/package.json CHANGED
@@ -109,5 +109,5 @@
109
109
  "updateSnapshot": "bun test --update-snapshots"
110
110
  },
111
111
  "types": "dist/index.d.ts",
112
- "version": "0.16.1"
112
+ "version": "0.18.0"
113
113
  }
@@ -4,14 +4,19 @@ import type express from "express";
4
4
  import {type Model, model, Schema} from "mongoose";
5
5
  import supertest from "supertest";
6
6
  import type TestAgent from "supertest/lib/agent";
7
- import {z} from "zod";
8
- import {ACTION_NAME_PATTERN, defineCollectionAction, defineInstanceAction} from "./actions";
9
- import {modelRouter} from "./api";
7
+ import {
8
+ ACTION_NAME_PATTERN,
9
+ createActionOpenApiMiddleware,
10
+ defineCollectionAction,
11
+ defineInstanceAction,
12
+ } from "./actions";
13
+ import {modelRouter, type OpenApiMiddleware} from "./api";
10
14
  import {addAuthRoutes, setupAuth} from "./auth";
11
15
  import {apiUnauthorizedMiddleware} from "./errors";
12
16
  import {Permissions} from "./permissions";
13
17
  import {type IsDeleted, isDeletedPlugin} from "./plugins";
14
18
  import {authAsUser, type Food, FoodModel, getBaseServer, setupDb, UserModel} from "./tests";
19
+ import {z} from "./zodOpenApi";
15
20
 
16
21
  interface Stuff extends IsDeleted {
17
22
  _id: string;
@@ -430,6 +435,23 @@ describe("modelRouter actions", () => {
430
435
  expect(originalQ).toBe("5");
431
436
  });
432
437
 
438
+ it("returns 400 with meta.fields for invalid query", async () => {
439
+ mountFoodRouter({
440
+ collectionActions: {
441
+ lookup: {
442
+ handler: async () => ({ok: true}),
443
+ method: "GET",
444
+ permissions: [Permissions.IsAny],
445
+ query: z.object({email: z.string().email()}),
446
+ },
447
+ },
448
+ permissions: allPermissions,
449
+ });
450
+ const res = await server.get("/food/lookup?email=bad").expect(400);
451
+ expect(res.body.title).toBe("Validation failed");
452
+ expect(res.body.meta.fields.email).toBeDefined();
453
+ });
454
+
433
455
  it("coerces body values via zod in ctx", async () => {
434
456
  let seenCount: number | undefined;
435
457
  mountFoodRouter({
@@ -633,4 +655,129 @@ describe("modelRouter actions", () => {
633
655
  expect(action.method).toBe("POST");
634
656
  });
635
657
  });
658
+
659
+ describe("createActionOpenApiMiddleware", () => {
660
+ const createMockOpenApi = (): {
661
+ captured: Record<string, unknown> | undefined;
662
+ middleware: OpenApiMiddleware;
663
+ } => {
664
+ let captured: Record<string, unknown> | undefined;
665
+ const pathFn = (op: Record<string, unknown>): express.RequestHandler => {
666
+ captured = op;
667
+ return (_req: express.Request, _res: express.Response, next: express.NextFunction) =>
668
+ next();
669
+ };
670
+ return {
671
+ get captured() {
672
+ return captured;
673
+ },
674
+ middleware: {path: pathFn} as unknown as OpenApiMiddleware,
675
+ };
676
+ };
677
+
678
+ it("returns no-op middleware when openApi is not configured", () => {
679
+ const handler = createActionOpenApiMiddleware({
680
+ action: {method: "GET", permissions: []},
681
+ actionName: "test",
682
+ model: FoodModel as Model<Food>,
683
+ options: {},
684
+ scope: "collection",
685
+ });
686
+ expect(typeof handler).toBe("function");
687
+ });
688
+
689
+ it("generates query parameters from action.query schema", () => {
690
+ const mock = createMockOpenApi();
691
+ createActionOpenApiMiddleware({
692
+ action: {
693
+ method: "GET",
694
+ permissions: [],
695
+ query: z.object({page: z.coerce.number(), search: z.string()}),
696
+ },
697
+ actionName: "search",
698
+ model: FoodModel as Model<Food>,
699
+ options: {openApi: mock.middleware},
700
+ scope: "collection",
701
+ });
702
+ const params = mock.captured?.parameters as Record<string, unknown>[];
703
+ expect(params.length).toBe(2);
704
+ expect(params.some((p) => p.name === "search")).toBe(true);
705
+ expect(params.some((p) => p.name === "page")).toBe(true);
706
+ });
707
+
708
+ it("includes id path parameter for instance-scoped actions", () => {
709
+ const mock = createMockOpenApi();
710
+ createActionOpenApiMiddleware({
711
+ action: {method: "GET", permissions: []},
712
+ actionName: "peek",
713
+ model: FoodModel as Model<Food>,
714
+ options: {openApi: mock.middleware},
715
+ scope: "instance",
716
+ });
717
+ const params = mock.captured?.parameters as Record<string, unknown>[];
718
+ expect(params.some((p) => p.name === "id" && p.in === "path")).toBe(true);
719
+ });
720
+
721
+ it("uses fallback data schema when response is not provided", () => {
722
+ const mock = createMockOpenApi();
723
+ createActionOpenApiMiddleware({
724
+ action: {method: "POST", permissions: []},
725
+ actionName: "noop",
726
+ model: FoodModel as Model<Food>,
727
+ options: {openApi: mock.middleware},
728
+ scope: "collection",
729
+ });
730
+ const responses = mock.captured?.responses as Record<string, Record<string, unknown>>;
731
+ const ok = responses["200"] as Record<
732
+ string,
733
+ Record<string, Record<string, Record<string, unknown>>>
734
+ >;
735
+ const schema = ok.content["application/json"].schema as Record<
736
+ string,
737
+ Record<string, unknown>
738
+ >;
739
+ expect(schema.properties.data).toEqual({type: "object"});
740
+ });
741
+
742
+ it("uses zod response schema when provided", () => {
743
+ const mock = createMockOpenApi();
744
+ createActionOpenApiMiddleware({
745
+ action: {
746
+ method: "POST",
747
+ permissions: [],
748
+ response: z.object({count: z.number()}),
749
+ },
750
+ actionName: "tally",
751
+ model: FoodModel as Model<Food>,
752
+ options: {openApi: mock.middleware},
753
+ scope: "collection",
754
+ });
755
+ const responses = mock.captured?.responses as Record<string, Record<string, unknown>>;
756
+ const ok = responses["200"] as Record<
757
+ string,
758
+ Record<string, Record<string, Record<string, unknown>>>
759
+ >;
760
+ const schema = ok.content["application/json"].schema as Record<
761
+ string,
762
+ Record<string, unknown>
763
+ >;
764
+ expect((schema.properties.data as Record<string, unknown>).properties).toBeDefined();
765
+ });
766
+
767
+ it("includes requestBody when action.body is defined", () => {
768
+ const mock = createMockOpenApi();
769
+ createActionOpenApiMiddleware({
770
+ action: {
771
+ body: z.object({name: z.string()}),
772
+ method: "POST",
773
+ permissions: [],
774
+ },
775
+ actionName: "create",
776
+ model: FoodModel as Model<Food>,
777
+ options: {openApi: mock.middleware},
778
+ scope: "collection",
779
+ });
780
+ expect(mock.captured?.requestBody).toBeDefined();
781
+ });
782
+ });
636
783
  });
@@ -140,4 +140,41 @@ describe("envConfigurationPlugin", () => {
140
140
  // mapToObject(undefined) returns {}, so Config falls back to the registered default
141
141
  expect(Config.get("TERRENO_PLUGIN_KEY")).toBe("fallback");
142
142
  });
143
+
144
+ it("mapToObject handles a plain Record (non-Map) env field", async () => {
145
+ // Insert a document with env as a plain object via raw DB operation
146
+ const col = mongoose.connection.db?.collection("testenvconfigs");
147
+ await col?.insertOne({env: {TERRENO_PLUGIN_KEY: "plainObj"}});
148
+
149
+ // Trigger refresh via findOneAndUpdate hook
150
+ await TestEnvConfig.findOneAndUpdate({}, {$set: {__v: 1}});
151
+
152
+ expect(Config.get("TERRENO_PLUGIN_KEY")).toBe("plainObj");
153
+ });
154
+
155
+ it("refreshFromDoc logs a warning and does not throw when the model query fails", async () => {
156
+ // Seed a document so findOneAndUpdate has a target
157
+ const doc = new TestEnvConfig();
158
+ doc.env.set("TERRENO_PLUGIN_KEY", "initial");
159
+ await doc.save();
160
+
161
+ // Pre-set cache to verify it is NOT overwritten when the hook errors
162
+ Config.setCachedEnv({TERRENO_PLUGIN_KEY: "cached"});
163
+
164
+ // Override Model.find to simulate a DB error inside refreshFromDoc
165
+ // (findOneOrNoneFor falls back to model.find when the findOneOrNone static is absent)
166
+ const originalFind = TestEnvConfig.find;
167
+ (TestEnvConfig as any).find = () => {
168
+ throw new Error("Simulated DB error");
169
+ };
170
+
171
+ // Trigger the post-findOneAndUpdate hook → refreshFromDoc → findOneOrNoneFor → throws
172
+ await TestEnvConfig.findOneAndUpdate({_id: doc._id}, {$set: {__v: 2}});
173
+
174
+ // Restore immediately
175
+ (TestEnvConfig as any).find = originalFind;
176
+
177
+ // The catch block should have swallowed the error; cache keeps old value
178
+ expect(Config.get("TERRENO_PLUGIN_KEY")).toBe("cached");
179
+ });
143
180
  });