@terreno/api 0.0.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.
- package/LICENSE +202 -0
- package/README.md +170 -0
- package/biome.jsonc +22 -0
- package/bunfig.toml +4 -0
- package/dist/api.d.ts +227 -0
- package/dist/api.js +1024 -0
- package/dist/api.test.d.ts +1 -0
- package/dist/api.test.js +2143 -0
- package/dist/auth.d.ts +50 -0
- package/dist/auth.js +512 -0
- package/dist/auth.test.d.ts +1 -0
- package/dist/auth.test.js +778 -0
- package/dist/errors.d.ts +75 -0
- package/dist/errors.js +216 -0
- package/dist/example.d.ts +1 -0
- package/dist/example.js +118 -0
- package/dist/expressServer.d.ts +35 -0
- package/dist/expressServer.js +436 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +30 -0
- package/dist/logger.d.ts +23 -0
- package/dist/logger.js +249 -0
- package/dist/middleware.d.ts +10 -0
- package/dist/middleware.js +52 -0
- package/dist/notifiers/googleChatNotifier.d.ts +5 -0
- package/dist/notifiers/googleChatNotifier.js +130 -0
- package/dist/notifiers/googleChatNotifier.test.d.ts +1 -0
- package/dist/notifiers/googleChatNotifier.test.js +260 -0
- package/dist/notifiers/index.d.ts +3 -0
- package/dist/notifiers/index.js +19 -0
- package/dist/notifiers/slackNotifier.d.ts +5 -0
- package/dist/notifiers/slackNotifier.js +130 -0
- package/dist/notifiers/slackNotifier.test.d.ts +1 -0
- package/dist/notifiers/slackNotifier.test.js +259 -0
- package/dist/notifiers/zoomNotifier.d.ts +34 -0
- package/dist/notifiers/zoomNotifier.js +181 -0
- package/dist/notifiers/zoomNotifier.test.d.ts +1 -0
- package/dist/notifiers/zoomNotifier.test.js +370 -0
- package/dist/openApi.d.ts +60 -0
- package/dist/openApi.js +441 -0
- package/dist/openApi.test.d.ts +1 -0
- package/dist/openApi.test.js +445 -0
- package/dist/openApiBuilder.d.ts +419 -0
- package/dist/openApiBuilder.js +424 -0
- package/dist/openApiBuilder.test.d.ts +1 -0
- package/dist/openApiBuilder.test.js +509 -0
- package/dist/openApiEtag.d.ts +7 -0
- package/dist/openApiEtag.js +38 -0
- package/dist/permissions.d.ts +26 -0
- package/dist/permissions.js +331 -0
- package/dist/permissions.test.d.ts +1 -0
- package/dist/permissions.test.js +413 -0
- package/dist/plugins.d.ts +67 -0
- package/dist/plugins.js +315 -0
- package/dist/plugins.test.d.ts +1 -0
- package/dist/plugins.test.js +639 -0
- package/dist/populate.d.ts +14 -0
- package/dist/populate.js +315 -0
- package/dist/populate.test.d.ts +1 -0
- package/dist/populate.test.js +133 -0
- package/dist/response.d.ts +0 -0
- package/dist/response.js +1 -0
- package/dist/tests/bunSetup.d.ts +1 -0
- package/dist/tests/bunSetup.js +297 -0
- package/dist/tests/index.d.ts +1 -0
- package/dist/tests/index.js +17 -0
- package/dist/tests.d.ts +99 -0
- package/dist/tests.js +273 -0
- package/dist/transformers.d.ts +25 -0
- package/dist/transformers.js +217 -0
- package/dist/transformers.test.d.ts +1 -0
- package/dist/transformers.test.js +370 -0
- package/dist/utils.d.ts +11 -0
- package/dist/utils.js +143 -0
- package/dist/utils.test.d.ts +1 -0
- package/dist/utils.test.js +14 -0
- package/index.ts +1 -0
- package/package.json +88 -0
- package/src/__snapshots__/openApi.test.ts.snap +4814 -0
- package/src/__snapshots__/openApiBuilder.test.ts.snap +1485 -0
- package/src/api.test.ts +1661 -0
- package/src/api.ts +1036 -0
- package/src/auth.test.ts +550 -0
- package/src/auth.ts +408 -0
- package/src/errors.ts +225 -0
- package/src/example.ts +99 -0
- package/src/express.d.ts +5 -0
- package/src/expressServer.ts +387 -0
- package/src/index.ts +14 -0
- package/src/logger.ts +190 -0
- package/src/middleware.ts +18 -0
- package/src/notifiers/googleChatNotifier.test.ts +114 -0
- package/src/notifiers/googleChatNotifier.ts +47 -0
- package/src/notifiers/index.ts +3 -0
- package/src/notifiers/slackNotifier.test.ts +113 -0
- package/src/notifiers/slackNotifier.ts +55 -0
- package/src/notifiers/zoomNotifier.test.ts +207 -0
- package/src/notifiers/zoomNotifier.ts +111 -0
- package/src/openApi.test.ts +331 -0
- package/src/openApi.ts +494 -0
- package/src/openApiBuilder.test.ts +442 -0
- package/src/openApiBuilder.ts +636 -0
- package/src/openApiEtag.ts +40 -0
- package/src/permissions.test.ts +219 -0
- package/src/permissions.ts +228 -0
- package/src/plugins.test.ts +390 -0
- package/src/plugins.ts +289 -0
- package/src/populate.test.ts +65 -0
- package/src/populate.ts +258 -0
- package/src/response.ts +0 -0
- package/src/tests/bunSetup.ts +234 -0
- package/src/tests/index.ts +1 -0
- package/src/tests.ts +218 -0
- package/src/transformers.test.ts +202 -0
- package/src/transformers.ts +170 -0
- package/src/utils.test.ts +14 -0
- package/src/utils.ts +47 -0
- package/tsconfig.json +60 -0
- package/types.d.ts +17 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import {beforeEach, describe, expect, it, setSystemTime} from "bun:test";
|
|
2
|
+
import type express from "express";
|
|
3
|
+
import {type Document, type Model, model, Schema} from "mongoose";
|
|
4
|
+
import supertest from "supertest";
|
|
5
|
+
import type TestAgent from "supertest/lib/agent";
|
|
6
|
+
import {modelRouter} from "./api";
|
|
7
|
+
import {addAuthRoutes, setupAuth} from "./auth";
|
|
8
|
+
import type {APIErrorConstructor} from "./errors";
|
|
9
|
+
import {Permissions} from "./permissions";
|
|
10
|
+
import {
|
|
11
|
+
createdUpdatedPlugin,
|
|
12
|
+
DateOnly,
|
|
13
|
+
findExactlyOne,
|
|
14
|
+
findOneOrNone,
|
|
15
|
+
type IsDeleted,
|
|
16
|
+
isDeletedPlugin,
|
|
17
|
+
upsertPlugin,
|
|
18
|
+
} from "./plugins";
|
|
19
|
+
import {authAsUser, getBaseServer, setupDb, UserModel} from "./tests";
|
|
20
|
+
|
|
21
|
+
interface Stuff extends IsDeleted {
|
|
22
|
+
_id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
ownerId: string;
|
|
25
|
+
date: Date;
|
|
26
|
+
created: Date;
|
|
27
|
+
updated?: Date;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface StuffModelType extends Model<Stuff> {
|
|
31
|
+
findOneOrNone(
|
|
32
|
+
query: Record<string, any>,
|
|
33
|
+
errorArgs?: Partial<APIErrorConstructor>
|
|
34
|
+
): Promise<(Document & Stuff) | null>;
|
|
35
|
+
findExactlyOne(
|
|
36
|
+
query: Record<string, any>,
|
|
37
|
+
errorArgs?: Partial<APIErrorConstructor>
|
|
38
|
+
): Promise<Document & Stuff>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const stuffSchema = new Schema<Stuff>({
|
|
42
|
+
date: DateOnly,
|
|
43
|
+
name: String,
|
|
44
|
+
ownerId: String,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
stuffSchema.plugin(isDeletedPlugin);
|
|
48
|
+
stuffSchema.plugin(findOneOrNone);
|
|
49
|
+
stuffSchema.plugin(findExactlyOne);
|
|
50
|
+
stuffSchema.plugin(upsertPlugin);
|
|
51
|
+
stuffSchema.plugin(createdUpdatedPlugin);
|
|
52
|
+
|
|
53
|
+
const StuffModel = model<Stuff>("Stuff", stuffSchema) as unknown as StuffModelType;
|
|
54
|
+
|
|
55
|
+
describe("createdUpdate", () => {
|
|
56
|
+
it("sets created and updated on save", async () => {
|
|
57
|
+
setSystemTime(new Date("2022-12-17T03:24:00.000Z"));
|
|
58
|
+
|
|
59
|
+
const stuff = await StuffModel.create({name: "Things", ownerId: "123"});
|
|
60
|
+
expect(stuff.created).not.toBeNull();
|
|
61
|
+
expect(stuff.updated).not.toBeNull();
|
|
62
|
+
expect(stuff.created.toISOString()).toBe("2022-12-17T03:24:00.000Z");
|
|
63
|
+
expect(stuff.updated?.toISOString()).toBe("2022-12-17T03:24:00.000Z");
|
|
64
|
+
|
|
65
|
+
stuff.name = "Thangs";
|
|
66
|
+
// Advance time by 10 seconds
|
|
67
|
+
setSystemTime(new Date("2022-12-17T03:24:10.000Z"));
|
|
68
|
+
await stuff.save();
|
|
69
|
+
expect(stuff.created.toISOString()).toBe("2022-12-17T03:24:00.000Z");
|
|
70
|
+
expect(stuff.updated && stuff.updated > stuff.created).toBe(true);
|
|
71
|
+
setSystemTime();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("isDeleted", () => {
|
|
76
|
+
beforeEach(async () => {
|
|
77
|
+
await StuffModel.deleteMany({});
|
|
78
|
+
await Promise.all([
|
|
79
|
+
StuffModel.create({
|
|
80
|
+
deleted: true,
|
|
81
|
+
name: "Things",
|
|
82
|
+
ownerId: "123",
|
|
83
|
+
}),
|
|
84
|
+
StuffModel.create({
|
|
85
|
+
name: "StuffNThings",
|
|
86
|
+
ownerId: "123",
|
|
87
|
+
}),
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('filters out deleted documents from "find"', async () => {
|
|
92
|
+
let stuff = await StuffModel.find({});
|
|
93
|
+
expect(stuff).toHaveLength(1);
|
|
94
|
+
expect(stuff[0].name).toBe("StuffNThings");
|
|
95
|
+
// Providing deleted in query should return deleted documents:
|
|
96
|
+
stuff = await StuffModel.find({deleted: true});
|
|
97
|
+
expect(stuff).toHaveLength(1);
|
|
98
|
+
expect(stuff[0].name).toBe("Things");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('filters out deleted documents from "findOne"', async () => {
|
|
102
|
+
let stuff = await StuffModel.findOne({});
|
|
103
|
+
expect(stuff?.name).toBe("StuffNThings");
|
|
104
|
+
// Providing deleted in query should return deleted document:
|
|
105
|
+
stuff = await StuffModel.findOne({deleted: true});
|
|
106
|
+
expect(stuff?.name).toBe("Things");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("findOneOrNone", () => {
|
|
111
|
+
let things: any;
|
|
112
|
+
|
|
113
|
+
beforeEach(async () => {
|
|
114
|
+
await StuffModel.deleteMany({});
|
|
115
|
+
await setupDb();
|
|
116
|
+
|
|
117
|
+
[things] = await Promise.all([
|
|
118
|
+
StuffModel.create({
|
|
119
|
+
name: "Things",
|
|
120
|
+
ownerId: "123",
|
|
121
|
+
}),
|
|
122
|
+
StuffModel.create({
|
|
123
|
+
name: "StuffNThings",
|
|
124
|
+
ownerId: "123",
|
|
125
|
+
}),
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("returns null with no matches.", async () => {
|
|
130
|
+
const result = await StuffModel.findOneOrNone({name: "OtherStuff"});
|
|
131
|
+
expect(result).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("returns a single match", async () => {
|
|
135
|
+
const result = await StuffModel.findOneOrNone({name: "Things"});
|
|
136
|
+
expect(result).not.toBeNull();
|
|
137
|
+
expect(result?._id.toString()).toBe(things._id.toString());
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("throws error with two matches.", async () => {
|
|
141
|
+
const fn = () => StuffModel.findOneOrNone({ownerId: "123"});
|
|
142
|
+
await expect(fn()).rejects.toThrow(/Stuff\.findOne query returned multiple documents/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("throws custom error with two matches.", async () => {
|
|
146
|
+
const fn = () => StuffModel.findOneOrNone({ownerId: "123"}, {status: 400, title: "Oh no!"});
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
await fn();
|
|
150
|
+
// If the promise doesn't reject, the test should fail
|
|
151
|
+
throw new Error("Expected promise to reject");
|
|
152
|
+
} catch (error: any) {
|
|
153
|
+
// Check if the error has title and status properties
|
|
154
|
+
expect(error.title).toBe("Oh no!");
|
|
155
|
+
expect(error.status).toBe(400);
|
|
156
|
+
expect(error.detail).toBe('query: {"ownerId":"123"}');
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("findExactlyOne", () => {
|
|
162
|
+
let things: any;
|
|
163
|
+
|
|
164
|
+
beforeEach(async () => {
|
|
165
|
+
await StuffModel.deleteMany({});
|
|
166
|
+
await setupDb();
|
|
167
|
+
|
|
168
|
+
[things] = await Promise.all([
|
|
169
|
+
StuffModel.create({
|
|
170
|
+
name: "Things",
|
|
171
|
+
ownerId: "123",
|
|
172
|
+
}),
|
|
173
|
+
StuffModel.create({
|
|
174
|
+
name: "StuffNThings",
|
|
175
|
+
ownerId: "123",
|
|
176
|
+
}),
|
|
177
|
+
]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("throws error with no matches.", async () => {
|
|
181
|
+
const fn = () => StuffModel.findExactlyOne({name: "OtherStuff"});
|
|
182
|
+
await expect(fn()).rejects.toThrow(/Stuff\.findExactlyOne query returned no documents/);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("returns a single match", async () => {
|
|
186
|
+
const result = await StuffModel.findExactlyOne({name: "Things"});
|
|
187
|
+
expect(result._id.toString()).toBe(things._id.toString());
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("throws error with two matches.", async () => {
|
|
191
|
+
const fn = () => StuffModel.findExactlyOne({ownerId: "123"});
|
|
192
|
+
await expect(fn()).rejects.toThrow(/Stuff\.findExactlyOne query returned multiple documents/);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("throws custom error with two matches.", async () => {
|
|
196
|
+
const fn = () => StuffModel.findExactlyOne({ownerId: "123"}, {status: 400, title: "Oh no!"});
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
await fn();
|
|
200
|
+
// If the promise doesn't reject, the test should fail
|
|
201
|
+
throw new Error("Expected promise to reject");
|
|
202
|
+
} catch (error: any) {
|
|
203
|
+
// Check if the error has title and status properties
|
|
204
|
+
expect(error.title).toBe("Oh no!");
|
|
205
|
+
expect(error.status).toBe(400);
|
|
206
|
+
expect(error.detail).toBe('query: {"ownerId":"123"}');
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("upsertPlugin", () => {
|
|
212
|
+
beforeEach(async () => {
|
|
213
|
+
await StuffModel.deleteMany({});
|
|
214
|
+
await setupDb();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("creates a new document when none exists", async () => {
|
|
218
|
+
const result = await (StuffModel as any).upsert({name: "NewThing"}, {ownerId: "456"});
|
|
219
|
+
expect(result.name).toBe("NewThing");
|
|
220
|
+
expect(result.ownerId).toBe("456");
|
|
221
|
+
|
|
222
|
+
const found = await StuffModel.findOne({name: "NewThing"});
|
|
223
|
+
expect(found).not.toBeNull();
|
|
224
|
+
expect(found?.ownerId).toBe("456");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("updates existing document when one exists", async () => {
|
|
228
|
+
const initial = await StuffModel.create({
|
|
229
|
+
name: "ExistingThing",
|
|
230
|
+
ownerId: "123",
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const result = await (StuffModel as any).upsert({name: "ExistingThing"}, {ownerId: "789"});
|
|
234
|
+
|
|
235
|
+
expect(result._id.toString()).toBe(initial._id.toString());
|
|
236
|
+
expect(result.ownerId).toBe("789");
|
|
237
|
+
|
|
238
|
+
const allDocs = await StuffModel.find({name: "ExistingThing"});
|
|
239
|
+
expect(allDocs).toHaveLength(1);
|
|
240
|
+
expect(allDocs[0].ownerId).toBe("789");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("throws error when multiple documents match conditions", async () => {
|
|
244
|
+
await Promise.all([
|
|
245
|
+
StuffModel.create({name: "Thing1", ownerId: "123"}),
|
|
246
|
+
StuffModel.create({name: "Thing2", ownerId: "123"}),
|
|
247
|
+
]);
|
|
248
|
+
|
|
249
|
+
const fn = () => (StuffModel as any).upsert({ownerId: "123"}, {name: "Updated"});
|
|
250
|
+
await expect(fn()).rejects.toThrow(/Stuff\.upsert find query returned multiple documents/);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("combines conditions and update data for new documents", async () => {
|
|
254
|
+
const result = await (StuffModel as any).upsert({name: "TestCondition"}, {ownerId: "999"});
|
|
255
|
+
|
|
256
|
+
expect(result.name).toBe("TestCondition");
|
|
257
|
+
expect(result.ownerId).toBe("999");
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("TypeScript return types", () => {
|
|
262
|
+
let _things: any;
|
|
263
|
+
|
|
264
|
+
beforeEach(async () => {
|
|
265
|
+
await StuffModel.deleteMany({});
|
|
266
|
+
await setupDb();
|
|
267
|
+
|
|
268
|
+
[_things] = await Promise.all([
|
|
269
|
+
StuffModel.create({
|
|
270
|
+
date: new Date("2023-01-01"),
|
|
271
|
+
name: "Things",
|
|
272
|
+
ownerId: "123",
|
|
273
|
+
}),
|
|
274
|
+
StuffModel.create({
|
|
275
|
+
date: new Date("2023-01-02"),
|
|
276
|
+
name: "StuffNThings",
|
|
277
|
+
ownerId: "123",
|
|
278
|
+
}),
|
|
279
|
+
]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("findOneOrNone returns properly typed document or null", async () => {
|
|
283
|
+
const result = await StuffModel.findOneOrNone({name: "Things"});
|
|
284
|
+
|
|
285
|
+
if (result) {
|
|
286
|
+
expect(typeof result._id.toString()).toBe("string");
|
|
287
|
+
expect(typeof result.name).toBe("string");
|
|
288
|
+
expect(typeof result.ownerId).toBe("string");
|
|
289
|
+
expect(result.date).toBeInstanceOf(Date);
|
|
290
|
+
} else {
|
|
291
|
+
expect(result).toBeNull();
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("findExactlyOne returns properly typed document", async () => {
|
|
296
|
+
const result = await StuffModel.findExactlyOne({name: "Things"});
|
|
297
|
+
|
|
298
|
+
expect(typeof result._id.toString()).toBe("string");
|
|
299
|
+
expect(typeof result.name).toBe("string");
|
|
300
|
+
expect(typeof result.ownerId).toBe("string");
|
|
301
|
+
expect(result.date).toBeInstanceOf(Date);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
describe("DateOnly", () => {
|
|
305
|
+
it("throws error with invalid date", async () => {
|
|
306
|
+
try {
|
|
307
|
+
await StuffModel.create({
|
|
308
|
+
date: "foo" as any,
|
|
309
|
+
name: "Things",
|
|
310
|
+
ownerId: "123",
|
|
311
|
+
});
|
|
312
|
+
} catch (error: any) {
|
|
313
|
+
expect(error.message).toMatch(/Cast to DateOnly failed/);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
throw new Error("Expected error was not thrown");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("adjusts date to date only", async () => {
|
|
320
|
+
const res = await StuffModel.create({
|
|
321
|
+
date: "2005-10-10T17:17:17.017Z",
|
|
322
|
+
name: "Things",
|
|
323
|
+
ownerId: "123",
|
|
324
|
+
});
|
|
325
|
+
expect(res.date.toISOString()).toBe("2005-10-10T00:00:00.000Z");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("filter on date only", async () => {
|
|
329
|
+
await StuffModel.create({
|
|
330
|
+
date: "2000-10-10T17:17:17.017Z",
|
|
331
|
+
name: "Things",
|
|
332
|
+
ownerId: "123",
|
|
333
|
+
});
|
|
334
|
+
let found = await StuffModel.findOne({
|
|
335
|
+
date: {
|
|
336
|
+
$gte: "2000-01-01T00:00:00.000Z",
|
|
337
|
+
$lt: "2001-01-01T00:00:00.000Z",
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
expect(found?.date.toISOString()).toBe("2000-10-10T00:00:00.000Z");
|
|
341
|
+
found = await StuffModel.findOne({
|
|
342
|
+
date: {
|
|
343
|
+
$gte: "2000-01-01T12:12:12.000Z",
|
|
344
|
+
$lt: "2001-01-01T12:12:12.000Z",
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
expect(found?.date.toISOString()).toBe("2000-10-10T00:00:00.000Z");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe("handle 404", () => {
|
|
351
|
+
let agent: TestAgent;
|
|
352
|
+
let app: express.Application;
|
|
353
|
+
|
|
354
|
+
beforeEach(async () => {
|
|
355
|
+
await setupDb();
|
|
356
|
+
app = getBaseServer();
|
|
357
|
+
setupAuth(app, UserModel as any);
|
|
358
|
+
addAuthRoutes(app, UserModel as any);
|
|
359
|
+
app.use(
|
|
360
|
+
"/stuff",
|
|
361
|
+
modelRouter(StuffModel, {
|
|
362
|
+
allowAnonymous: true,
|
|
363
|
+
permissions: {
|
|
364
|
+
create: [Permissions.IsAny],
|
|
365
|
+
delete: [Permissions.IsAny],
|
|
366
|
+
list: [Permissions.IsAny],
|
|
367
|
+
read: [Permissions.IsAny],
|
|
368
|
+
update: [Permissions.IsAny],
|
|
369
|
+
},
|
|
370
|
+
})
|
|
371
|
+
);
|
|
372
|
+
supertest(app);
|
|
373
|
+
agent = await authAsUser(app, "notAdmin");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("returns 404 with context for hidden document", async () => {
|
|
377
|
+
const doc = await StuffModel.create({deleted: true, name: "test"});
|
|
378
|
+
const res = await agent.get(`/stuff/${doc._id}`).expect(404);
|
|
379
|
+
expect(res.body.title).toBe(`Document ${doc._id} not found for model Stuff`);
|
|
380
|
+
expect(res.body.meta).toEqual({deleted: "true"});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("returns 404 without meta for missing document", async () => {
|
|
384
|
+
const nonExistentId = "507f1f77bcf86cd799439011";
|
|
385
|
+
const res = await agent.get(`/stuff/${nonExistentId}`).expect(404);
|
|
386
|
+
expect(res.body.title).toBe(`Document ${nonExistentId} not found for model Stuff`);
|
|
387
|
+
expect(res.body.meta).toBeUndefined();
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
});
|
package/src/plugins.ts
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import {DateTime} from "luxon";
|
|
2
|
+
import mongoose, {
|
|
3
|
+
type Document,
|
|
4
|
+
Error as MongooseError,
|
|
5
|
+
type Query,
|
|
6
|
+
type Schema,
|
|
7
|
+
SchemaType,
|
|
8
|
+
type SchemaTypeOptions,
|
|
9
|
+
} from "mongoose";
|
|
10
|
+
|
|
11
|
+
import {APIError, type APIErrorConstructor} from "./errors";
|
|
12
|
+
|
|
13
|
+
export interface BaseUser {
|
|
14
|
+
admin: boolean;
|
|
15
|
+
email: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function baseUserPlugin(schema: Schema<any, any, any, any>) {
|
|
19
|
+
schema.add({admin: {default: false, type: Boolean}});
|
|
20
|
+
schema.add({email: {index: true, type: String}});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** For models with the isDeletedPlugin, extend this interface to add the appropriate fields. */
|
|
24
|
+
export interface IsDeleted {
|
|
25
|
+
// Whether the model should be treated as deleted or not.
|
|
26
|
+
deleted: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isDeletedPlugin(schema: Schema<any, any, any, any>, defaultValue = false) {
|
|
30
|
+
schema.add({
|
|
31
|
+
deleted: {
|
|
32
|
+
default: defaultValue,
|
|
33
|
+
description:
|
|
34
|
+
"Deleted objects are not returned in any find() or findOne() by default. " +
|
|
35
|
+
"Add {deleted: true} to find them.",
|
|
36
|
+
index: true,
|
|
37
|
+
type: Boolean,
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
function applyDeleteFilter(q: Query<any, any>) {
|
|
41
|
+
const query = q.getQuery();
|
|
42
|
+
if (query && query.deleted === undefined) {
|
|
43
|
+
void q.where({deleted: {$ne: true}});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
schema.pre("find", function () {
|
|
47
|
+
applyDeleteFilter(this);
|
|
48
|
+
});
|
|
49
|
+
schema.pre("findOne", function () {
|
|
50
|
+
applyDeleteFilter(this);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isDisabledPlugin(schema: Schema<any, any, any, any>, defaultValue = false) {
|
|
55
|
+
schema.add({
|
|
56
|
+
disabled: {
|
|
57
|
+
default: defaultValue,
|
|
58
|
+
description: "When a user is set to disable, all requests will return a 401",
|
|
59
|
+
index: true,
|
|
60
|
+
type: Boolean,
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface CreatedDeleted {
|
|
66
|
+
updated: {type: Date; required: true};
|
|
67
|
+
created: {type: Date; required: true};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function createdUpdatedPlugin(schema: Schema<any, any, any, any>) {
|
|
71
|
+
schema.add({updated: {index: true, type: Date}});
|
|
72
|
+
schema.add({created: {index: true, type: Date}});
|
|
73
|
+
|
|
74
|
+
schema.pre("save", function () {
|
|
75
|
+
if (this.disableCreatedUpdatedPlugin === true) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// If we aren't specifying created, use now.
|
|
79
|
+
if (!this.created) {
|
|
80
|
+
this.created = new Date();
|
|
81
|
+
}
|
|
82
|
+
// All writes change the updated time.
|
|
83
|
+
this.updated = new Date();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
schema.pre(/save|updateOne|insertMany/, function () {
|
|
87
|
+
void this.updateOne({}, {$set: {updated: new Date()}});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function firebaseJWTPlugin(schema: Schema) {
|
|
92
|
+
schema.add({firebaseId: {index: true, type: String}});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* This adds a static method `Model.findOneOrNone` to the schema. This should replace `Model.findOne` in most instances.
|
|
97
|
+
* `Model.findOne` should only be used with a unique index, but that's not apparent from the docs. Otherwise you can wind
|
|
98
|
+
* up with a random document that matches the query. The returns either null if no document matches, the actual
|
|
99
|
+
* document, or throws an exception if multiple are found.
|
|
100
|
+
* @param schema Mongoose Schema
|
|
101
|
+
*/
|
|
102
|
+
export function findOneOrNone<T>(schema: Schema<T>) {
|
|
103
|
+
schema.statics.findOneOrNone = async function (
|
|
104
|
+
query: Record<string, any>,
|
|
105
|
+
errorArgs?: Partial<APIErrorConstructor>
|
|
106
|
+
): Promise<(Document & T) | null> {
|
|
107
|
+
const results = await this.find(query);
|
|
108
|
+
if (results.length === 0) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
if (results.length > 1) {
|
|
112
|
+
throw new APIError({
|
|
113
|
+
detail: `query: ${JSON.stringify(query)}`,
|
|
114
|
+
status: 500,
|
|
115
|
+
title: `${this.modelName}.findOne query returned multiple documents`,
|
|
116
|
+
...errorArgs,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return results[0];
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* This adds a static method `Model.findExactlyOne` to the schema. This or findOneOrNone should replace `Model.findOne`
|
|
125
|
+
* in most instances.
|
|
126
|
+
* `Model.findOne` should only be used with a unique index, but that's not apparent from the docs. Otherwise you can wind
|
|
127
|
+
* up with a random document that matches the query. The returns the one matching document, or throws an exception if
|
|
128
|
+
* multiple or none are found.
|
|
129
|
+
* @param schema Mongoose Schema
|
|
130
|
+
*/
|
|
131
|
+
export function findExactlyOne<T>(schema: Schema<T>) {
|
|
132
|
+
schema.statics.findExactlyOne = async function (
|
|
133
|
+
query: Record<string, any>,
|
|
134
|
+
errorArgs?: Partial<APIErrorConstructor>
|
|
135
|
+
): Promise<Document & T> {
|
|
136
|
+
const results = await this.find(query);
|
|
137
|
+
if (results.length === 0) {
|
|
138
|
+
throw new APIError({
|
|
139
|
+
detail: `query: ${JSON.stringify(query)}`,
|
|
140
|
+
status: 404,
|
|
141
|
+
title: `${this.modelName}.findExactlyOne query returned no documents`,
|
|
142
|
+
...errorArgs,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
if (results.length > 1) {
|
|
146
|
+
throw new APIError({
|
|
147
|
+
detail: `query: ${JSON.stringify(query)}`,
|
|
148
|
+
status: 500,
|
|
149
|
+
title: `${this.modelName}.findExactlyOne query returned multiple documents`,
|
|
150
|
+
...errorArgs,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return results[0];
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* This adds a static method `Model.upsert` to the schema. This method will either update an existing document
|
|
159
|
+
* that matches the conditions or create a new document if none exists. It throws an error if multiple documents
|
|
160
|
+
* match the conditions to prevent ambiguous updates.
|
|
161
|
+
* @param schema Mongoose Schema
|
|
162
|
+
*/
|
|
163
|
+
export function upsertPlugin<T>(schema: Schema<any, any, any, any>) {
|
|
164
|
+
schema.statics.upsert = async function (
|
|
165
|
+
conditions: Record<string, any>,
|
|
166
|
+
update: Record<string, any>
|
|
167
|
+
): Promise<T> {
|
|
168
|
+
// Try to find the document with the given conditions.
|
|
169
|
+
const docs = await this.find(conditions);
|
|
170
|
+
if (docs.length > 1) {
|
|
171
|
+
throw new APIError({
|
|
172
|
+
detail: `query: ${JSON.stringify(conditions)}`,
|
|
173
|
+
status: 500,
|
|
174
|
+
title: `${this.modelName}.upsert find query returned multiple documents`,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
const doc = docs[0];
|
|
178
|
+
|
|
179
|
+
if (doc) {
|
|
180
|
+
// If the document exists, update it with the provided update values.
|
|
181
|
+
Object.assign(doc, update);
|
|
182
|
+
return doc.save();
|
|
183
|
+
}
|
|
184
|
+
// If the document doesn't exist, create a new one with the combined conditions and update
|
|
185
|
+
// values.
|
|
186
|
+
const combinedData = {...conditions, ...update};
|
|
187
|
+
const newDoc = new this(combinedData);
|
|
188
|
+
return newDoc.save();
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** For models with the upsertPlugin, extend this interface to add the upsert static method. */
|
|
193
|
+
export interface HasUpsert<T> {
|
|
194
|
+
upsert(conditions: Record<string, any>, update: Record<string, any>): Promise<T>;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface FindOneOrNonePlugin<T> {
|
|
198
|
+
findOneOrNone(
|
|
199
|
+
query: Record<string, any>,
|
|
200
|
+
errorArgs?: Partial<APIErrorConstructor>
|
|
201
|
+
): Promise<(Document & T) | null>;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface FindExactlyOnePlugin<T> {
|
|
205
|
+
findExactlyOne(
|
|
206
|
+
query: Record<string, any>,
|
|
207
|
+
errorArgs?: Partial<APIErrorConstructor>
|
|
208
|
+
): Promise<Document & T>;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export class DateOnly extends SchemaType {
|
|
212
|
+
constructor(key: string, options: SchemaTypeOptions<any>) {
|
|
213
|
+
super(key, options, "DateOnly");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
handleSingle(val) {
|
|
217
|
+
return this.cast(val);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
$conditionalHandlers = {
|
|
221
|
+
...(SchemaType as any).prototype.$conditionalHandlers,
|
|
222
|
+
$gt: this.handleSingle,
|
|
223
|
+
$gte: this.handleSingle,
|
|
224
|
+
$lt: this.handleSingle,
|
|
225
|
+
$lte: this.handleSingle,
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// Based on castForQuery in mongoose/lib/schema/date.js
|
|
229
|
+
// When using $gt, $gte, $lt, $lte, etc, we need to cast the value to a Date
|
|
230
|
+
castForQuery($conditional, val, context): Date | undefined {
|
|
231
|
+
if ($conditional == null) {
|
|
232
|
+
return (this as any).applySetters(val, context);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const handler = this.$conditionalHandlers[$conditional];
|
|
236
|
+
|
|
237
|
+
if (!handler) {
|
|
238
|
+
throw new Error(`Can't use ${$conditional} with DateOnly.`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return handler.call(this, val);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// When either setting a value to a DateOnly or fetching from the DB,
|
|
245
|
+
// we want to strip off the time portion.
|
|
246
|
+
cast(val: any): Date | undefined {
|
|
247
|
+
if (val instanceof Date) {
|
|
248
|
+
const date = DateTime.fromJSDate(val).toUTC().startOf("day");
|
|
249
|
+
if (!date.isValid) {
|
|
250
|
+
throw new MongooseError.CastError(
|
|
251
|
+
"DateOnly",
|
|
252
|
+
val,
|
|
253
|
+
this.path,
|
|
254
|
+
new Error("Value is not a valid date")
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
return date.toJSDate();
|
|
258
|
+
}
|
|
259
|
+
if (typeof val === "string" || typeof val === "number") {
|
|
260
|
+
const date = DateTime.fromJSDate(new Date(val)).toUTC().startOf("day");
|
|
261
|
+
if (!date.isValid) {
|
|
262
|
+
throw new MongooseError.CastError(
|
|
263
|
+
"DateOnly",
|
|
264
|
+
val,
|
|
265
|
+
this.path,
|
|
266
|
+
new Error("Value is not a valid date")
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
return date.toJSDate();
|
|
270
|
+
}
|
|
271
|
+
// Handle $gte, $lte, etc
|
|
272
|
+
if (typeof val === "object") {
|
|
273
|
+
return val;
|
|
274
|
+
}
|
|
275
|
+
throw new MongooseError.CastError(
|
|
276
|
+
"DateOnly",
|
|
277
|
+
val,
|
|
278
|
+
this.path,
|
|
279
|
+
new Error("Value is not a valid date")
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
get(val: any): this {
|
|
284
|
+
return (val instanceof Date ? DateTime.fromJSDate(val).startOf("day").toJSDate() : val) as any;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Register DateOnly with Mongoose's Schema.Types
|
|
289
|
+
(mongoose.Schema.Types as any).DateOnly = DateOnly;
|