@slango/mangusta 1.0.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/.turbo/turbo-build.log +4 -0
- package/CHANGELOG.md +7 -0
- package/LICENSE +201 -0
- package/README.md +1 -0
- package/dist/middleware/compactId.d.ts +13 -0
- package/dist/middleware/compactId.d.ts.map +1 -0
- package/dist/middleware/compactId.js +16 -0
- package/dist/middleware/compactId.js.map +1 -0
- package/dist/middleware/email.d.ts +18 -0
- package/dist/middleware/email.d.ts.map +1 -0
- package/dist/middleware/email.js +17 -0
- package/dist/middleware/email.js.map +1 -0
- package/dist/middleware/owner.d.ts +14 -0
- package/dist/middleware/owner.d.ts.map +1 -0
- package/dist/middleware/owner.js +14 -0
- package/dist/middleware/owner.js.map +1 -0
- package/dist/middleware/password.d.ts +18 -0
- package/dist/middleware/password.d.ts.map +1 -0
- package/dist/middleware/password.js +37 -0
- package/dist/middleware/password.js.map +1 -0
- package/dist/middleware/tags.d.ts +13 -0
- package/dist/middleware/tags.d.ts.map +1 -0
- package/dist/middleware/tags.js +27 -0
- package/dist/middleware/tags.js.map +1 -0
- package/dist/middleware/timestamps.d.ts +20 -0
- package/dist/middleware/timestamps.d.ts.map +1 -0
- package/dist/middleware/timestamps.js +48 -0
- package/dist/middleware/timestamps.js.map +1 -0
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/eslint.config.js +1 -0
- package/lint-staged.config.js +1 -0
- package/package.json +44 -0
- package/src/middleware/compactId.spec.ts +98 -0
- package/src/middleware/compactId.ts +42 -0
- package/src/middleware/email.spec.ts +84 -0
- package/src/middleware/email.ts +49 -0
- package/src/middleware/owner.spec.ts +88 -0
- package/src/middleware/owner.ts +39 -0
- package/src/middleware/password.ts +84 -0
- package/src/middleware/tags.spec.ts +111 -0
- package/src/middleware/tags.ts +56 -0
- package/src/middleware/timestamps.ts +93 -0
- package/src/types.ts +4 -0
- package/tsconfig.build.json +10 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +9 -0
- package/vitest.config.js +1 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { setupMongoTestEnvironment } from '@slango.configs/vitest/helpers/mongooseTestEnvironment';
|
|
2
|
+
import mongoose, { Document, model, Schema } from 'mongoose';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { PluginFunction } from '../types.js';
|
|
6
|
+
import compactIdMiddleware, { CompactIdMiddlewareOptions, WithCompactId } from './compactId.js';
|
|
7
|
+
|
|
8
|
+
setupMongoTestEnvironment();
|
|
9
|
+
|
|
10
|
+
type TestDoc<Field extends string = 'shortId'> = Document & WithCompactId<Field>;
|
|
11
|
+
|
|
12
|
+
const createTestModel = <Field extends string = 'shortId'>(
|
|
13
|
+
options?: CompactIdMiddlewareOptions<Field>,
|
|
14
|
+
) => {
|
|
15
|
+
const modelName = 'TestDoc';
|
|
16
|
+
|
|
17
|
+
if (mongoose.models[modelName]) {
|
|
18
|
+
delete mongoose.models[modelName];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const TestSchema = new Schema<TestDoc<Field>>({});
|
|
22
|
+
if (options) {
|
|
23
|
+
TestSchema.plugin(
|
|
24
|
+
compactIdMiddleware as PluginFunction<CompactIdMiddlewareOptions<Field>>,
|
|
25
|
+
options,
|
|
26
|
+
);
|
|
27
|
+
} else {
|
|
28
|
+
TestSchema.plugin(compactIdMiddleware);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return model<TestDoc<Field>>(modelName, TestSchema);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe('compactIdMiddleware', () => {
|
|
35
|
+
it('should add a "shortId" field by default with correct options', async () => {
|
|
36
|
+
const TestModel = createTestModel();
|
|
37
|
+
const doc = new TestModel();
|
|
38
|
+
|
|
39
|
+
await expect(doc.save()).resolves.not.toThrow();
|
|
40
|
+
const savedDoc = await TestModel.findOne({ _id: doc._id });
|
|
41
|
+
expect(savedDoc).toBeDefined();
|
|
42
|
+
expect(savedDoc?.shortId).toBeDefined();
|
|
43
|
+
expect(savedDoc?.shortId.length).toBe(9);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should generate a compactId of specified length', async () => {
|
|
47
|
+
const TestModel = createTestModel({ length: 12 });
|
|
48
|
+
const doc = new TestModel();
|
|
49
|
+
|
|
50
|
+
await expect(doc.save()).resolves.not.toThrow();
|
|
51
|
+
const savedDoc = await TestModel.findOne({ _id: doc._id });
|
|
52
|
+
expect(savedDoc?.shortId).toHaveLength(12);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should allow configuring the field name', async () => {
|
|
56
|
+
const TestModel = createTestModel<'customId'>({ field: 'customId' });
|
|
57
|
+
const doc = new TestModel();
|
|
58
|
+
|
|
59
|
+
await expect(doc.save()).resolves.not.toThrow();
|
|
60
|
+
const savedDoc = await TestModel.findOne({ _id: doc._id });
|
|
61
|
+
expect(savedDoc).toBeDefined();
|
|
62
|
+
expect(savedDoc?.customId).toBeDefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should not generate "index" if not requested', () => {
|
|
66
|
+
const TestModel = createTestModel({ index: false });
|
|
67
|
+
const indexes = TestModel.schema.indexes();
|
|
68
|
+
const customIdIndex = indexes.find(([fields]) =>
|
|
69
|
+
Object.prototype.hasOwnProperty.call(fields, 'shortId'),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(customIdIndex).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should apply "index" and not "unique" options to the field', () => {
|
|
76
|
+
const TestModel = createTestModel({ index: true, unique: false });
|
|
77
|
+
const indexes = TestModel.schema.indexes();
|
|
78
|
+
const customIdIndex = indexes.find(([fields]) =>
|
|
79
|
+
Object.prototype.hasOwnProperty.call(fields, 'shortId'),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(customIdIndex).toBeDefined();
|
|
83
|
+
expect(customIdIndex![0].shortId).toBe(1);
|
|
84
|
+
expect(customIdIndex![1].unique).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should apply "unique" and "unique" options to the field', () => {
|
|
88
|
+
const TestModel = createTestModel({ index: true, unique: true });
|
|
89
|
+
const indexes = TestModel.schema.indexes();
|
|
90
|
+
const customIdIndex = indexes.find(([fields]) =>
|
|
91
|
+
Object.prototype.hasOwnProperty.call(fields, 'shortId'),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
expect(customIdIndex).toBeDefined();
|
|
95
|
+
expect(customIdIndex![0].shortId).toBe(1);
|
|
96
|
+
expect(customIdIndex![1].unique).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Document, Model, Schema } from 'mongoose';
|
|
2
|
+
import { nanoid } from 'nanoid';
|
|
3
|
+
|
|
4
|
+
import { PluginFunction } from '../types.js';
|
|
5
|
+
|
|
6
|
+
export interface CompactIdMiddlewareOptions<Field extends string = 'shortId'> {
|
|
7
|
+
field?: Field;
|
|
8
|
+
index?: boolean;
|
|
9
|
+
length?: number;
|
|
10
|
+
unique?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type WithCompactId<Field extends string = 'shortId'> = {
|
|
14
|
+
[key in Field]: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const compactIdMiddleware: PluginFunction<CompactIdMiddlewareOptions> = <
|
|
18
|
+
Field extends string = 'shortId',
|
|
19
|
+
DocType extends Document & WithCompactId<Field> = Document & WithCompactId<Field>,
|
|
20
|
+
>(
|
|
21
|
+
schema: Schema<DocType, Model<DocType>>,
|
|
22
|
+
{
|
|
23
|
+
field = 'shortId' as Field,
|
|
24
|
+
index = true,
|
|
25
|
+
length = 9,
|
|
26
|
+
unique = true,
|
|
27
|
+
}: CompactIdMiddlewareOptions<Field> = {},
|
|
28
|
+
): void => {
|
|
29
|
+
const fieldDescription = new Schema<WithCompactId<Field>>({
|
|
30
|
+
[field]: {
|
|
31
|
+
default: () => nanoid(length),
|
|
32
|
+
index,
|
|
33
|
+
required: true,
|
|
34
|
+
unique: index && unique,
|
|
35
|
+
type: String,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
schema.add(fieldDescription);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export default compactIdMiddleware;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { setupMongoTestEnvironment } from '@slango.configs/vitest/helpers/mongooseTestEnvironment';
|
|
2
|
+
import mongoose, { Document, model, Schema } from 'mongoose';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import emailMiddleware from './email.js';
|
|
6
|
+
|
|
7
|
+
setupMongoTestEnvironment();
|
|
8
|
+
|
|
9
|
+
interface TestUser extends Document {
|
|
10
|
+
email: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const TestUserSchema = new Schema<TestUser>({});
|
|
14
|
+
TestUserSchema.plugin(emailMiddleware);
|
|
15
|
+
|
|
16
|
+
const TestUserModel = model<TestUser>('TestUser', TestUserSchema);
|
|
17
|
+
|
|
18
|
+
const validEmail = 'valid.email@example.com';
|
|
19
|
+
const invalidEmail = 'invalid-email';
|
|
20
|
+
const customErrorMessage = 'Custom error message for invalid email';
|
|
21
|
+
|
|
22
|
+
describe('emailMiddleware', () => {
|
|
23
|
+
it('should allow saving a user with a valid email', async () => {
|
|
24
|
+
const user = new TestUserModel({ email: validEmail });
|
|
25
|
+
|
|
26
|
+
await expect(user.save()).resolves.not.toThrow();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should fail when saving a user with an invalid email', async () => {
|
|
30
|
+
const user = new TestUserModel({ email: invalidEmail });
|
|
31
|
+
|
|
32
|
+
await expect(user.save()).rejects.toThrowError(mongoose.Error.ValidationError);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should enforce uniqueness for email', async () => {
|
|
36
|
+
const user1 = new TestUserModel({ email: validEmail });
|
|
37
|
+
const user2 = new TestUserModel({ email: validEmail });
|
|
38
|
+
|
|
39
|
+
await user1.save();
|
|
40
|
+
await expect(user2.save()).rejects.toThrowError(Error);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should allow saving a user without an email if "required" is set to false', async () => {
|
|
44
|
+
const OptionalEmailUserSchema = new Schema<TestUser>({});
|
|
45
|
+
OptionalEmailUserSchema.plugin(emailMiddleware, { required: false });
|
|
46
|
+
const OptionalEmailUserModel = model<TestUser>('OptionalEmailUser', OptionalEmailUserSchema);
|
|
47
|
+
|
|
48
|
+
const user = new OptionalEmailUserModel({});
|
|
49
|
+
|
|
50
|
+
await expect(user.save()).resolves.not.toThrow();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should display custom error message for invalid email', async () => {
|
|
54
|
+
const CustomMessageSchema = new Schema<TestUser>({});
|
|
55
|
+
CustomMessageSchema.plugin(emailMiddleware, { doesNotMatchMessage: customErrorMessage });
|
|
56
|
+
const CustomMessageUserModel = model<TestUser>('CustomMessageUser', CustomMessageSchema);
|
|
57
|
+
|
|
58
|
+
const user = new CustomMessageUserModel({ email: invalidEmail });
|
|
59
|
+
|
|
60
|
+
await expect(user.save()).rejects.toThrowError(customErrorMessage);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should allow saving a user with a non-unique email if "unique" is set to false', async () => {
|
|
64
|
+
const NonUniqueEmailSchema = new Schema<TestUser>({});
|
|
65
|
+
NonUniqueEmailSchema.plugin(emailMiddleware, { unique: false });
|
|
66
|
+
const NonUniqueEmailUserModel = model<TestUser>('NonUniqueEmailUser', NonUniqueEmailSchema);
|
|
67
|
+
|
|
68
|
+
const user1 = new NonUniqueEmailUserModel({ email: validEmail });
|
|
69
|
+
const user2 = new NonUniqueEmailUserModel({ email: validEmail });
|
|
70
|
+
|
|
71
|
+
await expect(user1.save()).resolves.not.toThrow();
|
|
72
|
+
await expect(user2.save()).resolves.not.toThrow(); // Should pass without unique constraint
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should trim email addresses before saving', async () => {
|
|
76
|
+
const user = new TestUserModel({ email: ` ${validEmail} ` });
|
|
77
|
+
|
|
78
|
+
await user.save();
|
|
79
|
+
const savedUser = await TestUserModel.findOne({ email: validEmail });
|
|
80
|
+
|
|
81
|
+
expect(savedUser).toBeDefined();
|
|
82
|
+
expect(savedUser?.email).toBe(validEmail);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Document, Model, Schema } from 'mongoose';
|
|
2
|
+
|
|
3
|
+
import { PluginFunction } from '../types.js';
|
|
4
|
+
|
|
5
|
+
export const emailRegexp = /^[\w.%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i;
|
|
6
|
+
|
|
7
|
+
export type WithEmail<
|
|
8
|
+
Field extends string = 'email',
|
|
9
|
+
Required extends boolean = true,
|
|
10
|
+
> = Required extends true ? { [key in Field]: string } : { [key in Field]?: string };
|
|
11
|
+
|
|
12
|
+
interface EmailMiddlewareOptions<Field extends string = 'email'> {
|
|
13
|
+
doesNotMatchMessage?: string;
|
|
14
|
+
field?: Field;
|
|
15
|
+
index?: boolean;
|
|
16
|
+
regExp?: RegExp;
|
|
17
|
+
required?: boolean;
|
|
18
|
+
unique?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const emailMiddleware: PluginFunction<EmailMiddlewareOptions> = <
|
|
22
|
+
Field extends string = 'email',
|
|
23
|
+
DocType extends Document & WithEmail<Field> = Document & WithEmail<Field>,
|
|
24
|
+
>(
|
|
25
|
+
schema: Schema<DocType, Model<DocType>>,
|
|
26
|
+
{
|
|
27
|
+
doesNotMatchMessage,
|
|
28
|
+
field = 'email' as Field,
|
|
29
|
+
index = true,
|
|
30
|
+
regExp = emailRegexp,
|
|
31
|
+
required = true,
|
|
32
|
+
unique = true,
|
|
33
|
+
}: EmailMiddlewareOptions<Field> = {},
|
|
34
|
+
): void => {
|
|
35
|
+
const emailSchema = new Schema<WithEmail<Field>>({
|
|
36
|
+
[field]: {
|
|
37
|
+
index,
|
|
38
|
+
match: doesNotMatchMessage ? [regExp, doesNotMatchMessage] : regExp,
|
|
39
|
+
required,
|
|
40
|
+
trim: true,
|
|
41
|
+
type: String,
|
|
42
|
+
unique,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
schema.add(emailSchema);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export default emailMiddleware;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { setupMongoTestEnvironment } from '@slango.configs/vitest/helpers/mongooseTestEnvironment';
|
|
2
|
+
import mongoose, { Document, model, Schema, Types } from 'mongoose';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { PluginFunction } from '../types.js';
|
|
6
|
+
import ownerMiddleware, { OwnerMiddlewareOptions, WithOwner } from './owner.js';
|
|
7
|
+
|
|
8
|
+
setupMongoTestEnvironment();
|
|
9
|
+
|
|
10
|
+
type TestDoc<Field extends string = 'user'> = Document<Types.ObjectId> &
|
|
11
|
+
WithOwner<Types.ObjectId, Field>;
|
|
12
|
+
|
|
13
|
+
const createTestModel = <Field extends string = 'user'>(
|
|
14
|
+
options?: OwnerMiddlewareOptions<Field>,
|
|
15
|
+
) => {
|
|
16
|
+
const modelName = 'TestDoc';
|
|
17
|
+
|
|
18
|
+
if (mongoose.models[modelName]) {
|
|
19
|
+
delete mongoose.models[modelName];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const TestSchema = new Schema<TestDoc<Field>>({});
|
|
23
|
+
if (options) {
|
|
24
|
+
TestSchema.plugin(ownerMiddleware as PluginFunction<OwnerMiddlewareOptions<Field>>, options);
|
|
25
|
+
} else {
|
|
26
|
+
TestSchema.plugin(ownerMiddleware);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return model<TestDoc<Field>>(modelName, TestSchema);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
describe('ownerMiddleware', () => {
|
|
33
|
+
it('should add a "user" field by default with correct options', async () => {
|
|
34
|
+
const TestModel = createTestModel();
|
|
35
|
+
const doc = new TestModel({ user: new Types.ObjectId() });
|
|
36
|
+
|
|
37
|
+
await expect(doc.save()).resolves.not.toThrow();
|
|
38
|
+
const savedDoc = await TestModel.findOne({ _id: doc._id });
|
|
39
|
+
expect(savedDoc).toBeDefined();
|
|
40
|
+
expect(savedDoc?.user?.toString()).toBe(doc.user.toString());
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should not allow saving without user field if required', async () => {
|
|
44
|
+
const TestModel = createTestModel();
|
|
45
|
+
const doc = new TestModel({});
|
|
46
|
+
|
|
47
|
+
await expect(doc.save()).rejects.toThrowError(mongoose.Error.ValidationError);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should allow configuring the field name', async () => {
|
|
51
|
+
const TestModel = createTestModel<'owner'>({ field: 'owner' });
|
|
52
|
+
const doc = new TestModel({ owner: new Types.ObjectId() });
|
|
53
|
+
|
|
54
|
+
await expect(doc.save()).resolves.not.toThrow();
|
|
55
|
+
const savedDoc = await TestModel.findOne({ _id: doc._id });
|
|
56
|
+
|
|
57
|
+
expect(savedDoc).toBeDefined();
|
|
58
|
+
expect(savedDoc?.owner?.toString()).toBe(doc.owner.toString());
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should allow optional "user" field if "required" is set to false', async () => {
|
|
62
|
+
const TestModel = createTestModel({ required: false });
|
|
63
|
+
const doc = new TestModel({});
|
|
64
|
+
|
|
65
|
+
await expect(doc.save()).resolves.not.toThrow();
|
|
66
|
+
const savedDoc = await TestModel.findOne({ _id: doc._id });
|
|
67
|
+
expect(savedDoc).toBeDefined();
|
|
68
|
+
expect(savedDoc?.user).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should apply "index" option to the field', () => {
|
|
72
|
+
const TestModel = createTestModel({ index: true });
|
|
73
|
+
const indexes = TestModel.schema.indexes();
|
|
74
|
+
const userIndex = indexes.find(([fields]) =>
|
|
75
|
+
Object.prototype.hasOwnProperty.call(fields, 'user'),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
expect(userIndex).toBeDefined();
|
|
79
|
+
expect(userIndex![0].user).toBe(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should reference the specified model in "ref"', () => {
|
|
83
|
+
const TestModel = createTestModel({ ref: 'CustomUser' });
|
|
84
|
+
const refField = TestModel.schema.path('user');
|
|
85
|
+
|
|
86
|
+
expect(refField.options.ref).toBe('CustomUser');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Document, Schema, Types } from 'mongoose';
|
|
2
|
+
|
|
3
|
+
import { PluginFunction } from '../types.js';
|
|
4
|
+
|
|
5
|
+
export interface OwnerMiddlewareOptions<Field extends string = 'user'> {
|
|
6
|
+
field?: Field;
|
|
7
|
+
index?: boolean;
|
|
8
|
+
ref?: string;
|
|
9
|
+
required?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type WithOwner<T = Types.ObjectId, Field extends string = 'user'> = {
|
|
13
|
+
[key in Field]: T;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const ownerMiddleware: PluginFunction<OwnerMiddlewareOptions> = <
|
|
17
|
+
Field extends string = 'user',
|
|
18
|
+
DocType extends Document & WithOwner<Field> = Document & WithOwner<Field>,
|
|
19
|
+
>(
|
|
20
|
+
schema: Schema<DocType>,
|
|
21
|
+
{
|
|
22
|
+
field = 'user' as Field,
|
|
23
|
+
index = true,
|
|
24
|
+
ref = 'User',
|
|
25
|
+
required = true,
|
|
26
|
+
}: OwnerMiddlewareOptions<Field> = {},
|
|
27
|
+
): void => {
|
|
28
|
+
const fieldDescription = new Schema<WithOwner<Field>>({
|
|
29
|
+
[field]: {
|
|
30
|
+
index,
|
|
31
|
+
ref,
|
|
32
|
+
required,
|
|
33
|
+
type: Types.ObjectId,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
schema.add(fieldDescription);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export default ownerMiddleware;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { compare, hash } from 'bcrypt';
|
|
2
|
+
import { Document, Model, Schema } from 'mongoose';
|
|
3
|
+
|
|
4
|
+
import { PluginFunction } from '../types.js';
|
|
5
|
+
|
|
6
|
+
export type WithPassword<
|
|
7
|
+
Field extends string = 'password',
|
|
8
|
+
ComparisonFunction extends string = 'comparePassword',
|
|
9
|
+
> = {
|
|
10
|
+
[key in ComparisonFunction]: (password: string) => Promise<boolean>;
|
|
11
|
+
} & {
|
|
12
|
+
[key in Field]: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const passwordRegExp = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&*)(\][+=.,_-]).{8,}$/;
|
|
16
|
+
|
|
17
|
+
interface PasswordMiddlewareOptions<
|
|
18
|
+
Field extends string = 'password',
|
|
19
|
+
ComparisonFunction extends string = 'comparePassword',
|
|
20
|
+
> {
|
|
21
|
+
comparisonFunction?: ComparisonFunction;
|
|
22
|
+
doesNotMatchMessage?: string;
|
|
23
|
+
field?: Field;
|
|
24
|
+
regExp?: RegExp;
|
|
25
|
+
required?: boolean;
|
|
26
|
+
saltingRounds?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const passwordMiddleware: PluginFunction<PasswordMiddlewareOptions> = <
|
|
30
|
+
Field extends string = 'password',
|
|
31
|
+
ComparisonFunction extends string = 'comparePassword',
|
|
32
|
+
DocType extends Document & WithPassword<Field, ComparisonFunction> = Document &
|
|
33
|
+
WithPassword<Field, ComparisonFunction>,
|
|
34
|
+
>(
|
|
35
|
+
schema: Schema<DocType, Model<DocType>>,
|
|
36
|
+
{
|
|
37
|
+
comparisonFunction = 'comparePassword' as ComparisonFunction,
|
|
38
|
+
doesNotMatchMessage,
|
|
39
|
+
field = 'password' as Field,
|
|
40
|
+
regExp = passwordRegExp,
|
|
41
|
+
required = true,
|
|
42
|
+
saltingRounds = 10,
|
|
43
|
+
}: PasswordMiddlewareOptions<Field, ComparisonFunction> = {},
|
|
44
|
+
): void => {
|
|
45
|
+
const fieldDescription = new Schema<WithPassword<Field, ComparisonFunction>>({
|
|
46
|
+
[field]: {
|
|
47
|
+
match: doesNotMatchMessage ? [regExp, doesNotMatchMessage] : regExp,
|
|
48
|
+
required,
|
|
49
|
+
type: String,
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
schema.add(fieldDescription);
|
|
54
|
+
|
|
55
|
+
schema.pre<DocType>('save', async function schemaWithPasswordPreSave(next) {
|
|
56
|
+
// only hash the password if it has been modified (or is new)
|
|
57
|
+
if (!this.isModified(field)) {
|
|
58
|
+
return next();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
// generate a hash and override the clear text password with the hashed one
|
|
63
|
+
this.set(field, await hash(this.get(field) as DocType[Field], saltingRounds));
|
|
64
|
+
next();
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (err instanceof Error) {
|
|
67
|
+
next(err);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
schema.methods[comparisonFunction] = async function comparePassword(
|
|
73
|
+
this: DocType & Document,
|
|
74
|
+
candidate: string,
|
|
75
|
+
): Promise<boolean> {
|
|
76
|
+
try {
|
|
77
|
+
return await compare(candidate, this.get(field) as DocType[Field]);
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export default passwordMiddleware;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { setupMongoTestEnvironment } from '@slango.configs/vitest/helpers/mongooseTestEnvironment';
|
|
2
|
+
import mongoose, { Document, model, Schema } from 'mongoose';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import tagsMiddleware, { TagsMiddlewareOptions, WithTags } from './tags.js';
|
|
6
|
+
|
|
7
|
+
setupMongoTestEnvironment();
|
|
8
|
+
|
|
9
|
+
type TestDoc<Field extends string = 'tags'> = Document & WithTags<Field>;
|
|
10
|
+
|
|
11
|
+
const createTestModel = <Field extends string = 'tags'>(options?: TagsMiddlewareOptions<Field>) => {
|
|
12
|
+
const modelName = 'TestDoc';
|
|
13
|
+
|
|
14
|
+
if (mongoose.models[modelName]) {
|
|
15
|
+
delete mongoose.models[modelName];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const TestSchema = new Schema<TestDoc<Field>>({});
|
|
19
|
+
if (options) {
|
|
20
|
+
TestSchema.plugin(tagsMiddleware, options as TagsMiddlewareOptions);
|
|
21
|
+
} else {
|
|
22
|
+
TestSchema.plugin(tagsMiddleware);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return model<TestDoc<Field>>(modelName, TestSchema);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe('tagsMiddleware', () => {
|
|
29
|
+
it('should add a "tags" field with correct content if tags are provided', async () => {
|
|
30
|
+
const TestModel = createTestModel();
|
|
31
|
+
const doc = new TestModel({ tags: ['tag1', 'tag2'] });
|
|
32
|
+
|
|
33
|
+
await expect(doc.save()).resolves.not.toThrow();
|
|
34
|
+
const savedDoc = await TestModel.findOne({ _id: doc._id });
|
|
35
|
+
|
|
36
|
+
expect(savedDoc).toBeDefined();
|
|
37
|
+
expect(savedDoc?.tags).toEqual(['tag1', 'tag2']);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should add a "tags" field with empty array if no tag is provided', async () => {
|
|
41
|
+
const TestModel = createTestModel();
|
|
42
|
+
const doc = new TestModel({});
|
|
43
|
+
|
|
44
|
+
await expect(doc.save()).resolves.not.toThrow();
|
|
45
|
+
const savedDoc = await TestModel.findOne({ _id: doc._id });
|
|
46
|
+
|
|
47
|
+
expect(savedDoc).toBeDefined();
|
|
48
|
+
expect(savedDoc?.tags).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should ensure uniqueness of tags if "unique" is set to true', async () => {
|
|
52
|
+
const TestModel = createTestModel({ unique: true });
|
|
53
|
+
const doc = new TestModel({ tags: ['tag1', 'tag2', 'tag1'] });
|
|
54
|
+
|
|
55
|
+
await expect(doc.save()).resolves.not.toThrow();
|
|
56
|
+
const savedDoc = await TestModel.findOne({ _id: doc._id });
|
|
57
|
+
|
|
58
|
+
expect(savedDoc).toBeDefined();
|
|
59
|
+
expect(savedDoc?.tags).toEqual(['tag1', 'tag2']); // Duplicates removed
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should not enforce uniqueness if "unique" is set to false', async () => {
|
|
63
|
+
const TestModel = createTestModel({ unique: false });
|
|
64
|
+
const doc = new TestModel({ tags: ['tag1', 'tag2', 'tag1'] });
|
|
65
|
+
|
|
66
|
+
await expect(doc.save()).resolves.not.toThrow();
|
|
67
|
+
const savedDoc = await TestModel.findOne({ _id: doc._id });
|
|
68
|
+
|
|
69
|
+
expect(savedDoc).toBeDefined();
|
|
70
|
+
expect(savedDoc?.tags).toEqual(['tag1', 'tag2', 'tag1']);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should validate tags with custom validation function', async () => {
|
|
74
|
+
const TestModel = createTestModel({
|
|
75
|
+
validate: (tags) => tags.every((tag) => tag.startsWith('valid')),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const validDoc = new TestModel({ tags: ['validTag1', 'validTag2'] });
|
|
79
|
+
await expect(validDoc.save()).resolves.not.toThrow();
|
|
80
|
+
|
|
81
|
+
const invalidDoc = new TestModel({ tags: ['validTag', 'invalidTag'] });
|
|
82
|
+
await expect(invalidDoc.save()).rejects.toThrowError(mongoose.Error.ValidationError);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should allow configuring the field name', async () => {
|
|
86
|
+
const TestModel = createTestModel<'customTags'>({ field: 'customTags' });
|
|
87
|
+
const doc = new TestModel({ customTags: ['tag1', 'tag2'] });
|
|
88
|
+
|
|
89
|
+
await expect(doc.save()).resolves.not.toThrow();
|
|
90
|
+
const savedDoc = await TestModel.findOne({ _id: doc._id });
|
|
91
|
+
|
|
92
|
+
expect(savedDoc).toBeDefined();
|
|
93
|
+
expect(savedDoc?.customTags).toEqual(['tag1', 'tag2']);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should add a text index when index is set to "text"', () => {
|
|
97
|
+
const TestModel = createTestModel({ index: 'text' });
|
|
98
|
+
const indexes = TestModel.schema.indexes();
|
|
99
|
+
|
|
100
|
+
const textIndex = indexes.find(([fields]) => fields.tags === 'text');
|
|
101
|
+
expect(textIndex).toBeDefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should add a hashed index when index is set to "hashed"', () => {
|
|
105
|
+
const TestModel = createTestModel({ index: 'hashed' });
|
|
106
|
+
const indexes = TestModel.schema.indexes();
|
|
107
|
+
|
|
108
|
+
const textIndex = indexes.find(([fields]) => fields.tags === 'hashed');
|
|
109
|
+
expect(textIndex).toBeDefined();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Document, Model, Schema } from 'mongoose';
|
|
2
|
+
|
|
3
|
+
import { PluginFunction } from '../types.js';
|
|
4
|
+
|
|
5
|
+
export interface TagsMiddlewareOptions<Field extends string = 'tags'> {
|
|
6
|
+
field?: Field;
|
|
7
|
+
index?: 'hashed' | 'text' | false;
|
|
8
|
+
unique?: boolean;
|
|
9
|
+
validate?: (tags: string[]) => boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type WithTags<Field extends string = 'tags'> = {
|
|
13
|
+
[key in Field]: string[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const tagsMiddleware: PluginFunction<TagsMiddlewareOptions> = <
|
|
17
|
+
Field extends string = 'tags',
|
|
18
|
+
DocType extends Document & WithTags<Field> = Document & WithTags<Field>,
|
|
19
|
+
>(
|
|
20
|
+
schema: Schema<DocType, Model<DocType>>,
|
|
21
|
+
{
|
|
22
|
+
field = 'tags' as Field,
|
|
23
|
+
index = false,
|
|
24
|
+
unique = true,
|
|
25
|
+
validate,
|
|
26
|
+
}: TagsMiddlewareOptions<Field> = {},
|
|
27
|
+
): void => {
|
|
28
|
+
const tagsSchema = new Schema<WithTags<Field>>({
|
|
29
|
+
[field]: {
|
|
30
|
+
type: [String],
|
|
31
|
+
...(validate && {
|
|
32
|
+
validate: {
|
|
33
|
+
message: 'Tags failed custom validation.',
|
|
34
|
+
validator: (tags: string[]) => validate(tags),
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
schema.add(tagsSchema);
|
|
41
|
+
|
|
42
|
+
if (index) {
|
|
43
|
+
schema.index({ [field]: index });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
schema.pre<DocType>('save', function schemaWithTagsPreSave(next) {
|
|
47
|
+
const tags = this.get(field) as DocType[Field];
|
|
48
|
+
if (unique && tags) {
|
|
49
|
+
this.set(field, Array.from(new Set(tags)));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
next();
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export default tagsMiddleware;
|