Adds UserService (#10)
This commit is contained in:
committed by
GitHub
parent
2236bde459
commit
bcaac913d2
23
packages/medusa/src/models/__mocks__/user.js
Normal file
23
packages/medusa/src/models/__mocks__/user.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
|
||||
export const users = {
|
||||
testUser: {
|
||||
_id: IdMap.getId("test-user"),
|
||||
email: "oliver@medusa.test",
|
||||
passwordHash: "123456789",
|
||||
},
|
||||
}
|
||||
|
||||
export const UserModelMock = {
|
||||
create: jest.fn().mockReturnValue(Promise.resolve()),
|
||||
updateOne: jest.fn().mockImplementation((query, update) => {
|
||||
return Promise.resolve()
|
||||
}),
|
||||
deleteOne: jest.fn().mockReturnValue(Promise.resolve()),
|
||||
findOne: jest.fn().mockImplementation(query => {
|
||||
if (query._id === IdMap.getId("test-user")) {
|
||||
return Promise.resolve(users.testUser)
|
||||
}
|
||||
return Promise.resolve(undefined)
|
||||
}),
|
||||
}
|
||||
@@ -7,9 +7,9 @@ import { BaseModel } from "medusa-interfaces"
|
||||
class UserModel extends BaseModel {
|
||||
static modelName = "User"
|
||||
static schema = {
|
||||
name: { type: String, required: true },
|
||||
email: { type: String, required: true },
|
||||
passwordHash: { type: String, required: true },
|
||||
metadata: { type: mongoose.Schema.Types.Mixed, default: {} },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
110
packages/medusa/src/services/__tests__/user.js
Normal file
110
packages/medusa/src/services/__tests__/user.js
Normal file
@@ -0,0 +1,110 @@
|
||||
import mongoose from "mongoose"
|
||||
import jwt from "jsonwebtoken"
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
import UserService from "../user"
|
||||
import { UserModelMock, users } from "../../models/__mocks__/user"
|
||||
|
||||
describe("UserService", () => {
|
||||
describe("retrieve", () => {
|
||||
let result
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.clearAllMocks()
|
||||
const userService = new UserService({
|
||||
userModel: UserModelMock,
|
||||
})
|
||||
result = await userService.retrieve(IdMap.getId("test-user"))
|
||||
})
|
||||
|
||||
it("calls cart model functions", () => {
|
||||
expect(UserModelMock.findOne).toHaveBeenCalledTimes(1)
|
||||
expect(UserModelMock.findOne).toHaveBeenCalledWith({
|
||||
_id: IdMap.getId("test-user"),
|
||||
})
|
||||
})
|
||||
|
||||
it("returns the user", () => {
|
||||
expect(result).toEqual(users.testUser)
|
||||
})
|
||||
})
|
||||
|
||||
describe("setMetadata", () => {
|
||||
const userService = new UserService({
|
||||
userModel: UserModelMock,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("calls updateOne with correct params", async () => {
|
||||
const id = mongoose.Types.ObjectId()
|
||||
await userService.setMetadata(`${id}`, "metadata", "testMetadata")
|
||||
|
||||
expect(UserModelMock.updateOne).toBeCalledTimes(1)
|
||||
expect(UserModelMock.updateOne).toBeCalledWith(
|
||||
{ _id: `${id}` },
|
||||
{ $set: { "metadata.metadata": "testMetadata" } }
|
||||
)
|
||||
})
|
||||
|
||||
it("throw error on invalid key type", async () => {
|
||||
const id = mongoose.Types.ObjectId()
|
||||
|
||||
try {
|
||||
await userService.setMetadata(`${id}`, 1234, "nono")
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual(
|
||||
"Key type is invalid. Metadata keys must be strings"
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("setPassword", () => {
|
||||
const userService = new UserService({
|
||||
userModel: UserModelMock,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("calls updateOne with correct params", async () => {
|
||||
await userService.setPassword(IdMap.getId("test-user"), "123456789")
|
||||
expect(UserModelMock.updateOne).toBeCalledTimes(1)
|
||||
expect(UserModelMock.updateOne).toBeCalledWith(
|
||||
{ _id: IdMap.getId("test-user") },
|
||||
{
|
||||
$set: {
|
||||
// Since bcrypt hashing always varies, we are testing the password
|
||||
// match by using a regular expression.
|
||||
password: expect.stringMatching(
|
||||
/^\$2[aby]?\$[\d]+\$[./A-Za-z0-9]{53}$/
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("generateResetPasswordToken", () => {
|
||||
const userService = new UserService({
|
||||
userModel: UserModelMock,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("generates a token successfully", async () => {
|
||||
const token = await userService.generateResetPasswordToken(
|
||||
IdMap.getId("test-user")
|
||||
)
|
||||
|
||||
expect(token).toMatch(
|
||||
/^[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*$/
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
209
packages/medusa/src/services/user.js
Normal file
209
packages/medusa/src/services/user.js
Normal file
@@ -0,0 +1,209 @@
|
||||
import mongoose from "mongoose"
|
||||
import _ from "lodash"
|
||||
import bcrypt from "bcrypt"
|
||||
import jwt from "jsonwebtoken"
|
||||
import { Validator, MedusaError } from "medusa-core-utils"
|
||||
import { BaseService } from "medusa-interfaces"
|
||||
|
||||
/**
|
||||
* Provides layer to manipulate users.
|
||||
* @implements BaseService
|
||||
*/
|
||||
class UserService extends BaseService {
|
||||
constructor({ userModel, eventBusService }) {
|
||||
super()
|
||||
|
||||
/** @private @const {UserModel} */
|
||||
this.userModel_ = userModel
|
||||
|
||||
/** @private @const {EventBus} */
|
||||
this.eventBus_ = eventBusService
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to validate user ids. Throws an error if the cast fails
|
||||
* @param {string} rawId - the raw user id to validate.
|
||||
* @return {string} the validated id
|
||||
*/
|
||||
validateId_(rawId) {
|
||||
const schema = Validator.objectId()
|
||||
const { value, error } = schema.validate(rawId)
|
||||
if (error) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_ARGUMENT,
|
||||
"The userId could not be casted to an ObjectId"
|
||||
)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to validate user email.
|
||||
* @param {string} email - email to validate
|
||||
* @return {string} the validated email
|
||||
*/
|
||||
validateEmail_(email) {
|
||||
const schema = Validator.string()
|
||||
.email()
|
||||
.required()
|
||||
const { value, error } = schema.validate(email)
|
||||
if (error) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"The email is not valid"
|
||||
)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} selector - the query object for find
|
||||
* @return {Promise} the result of the find operation
|
||||
*/
|
||||
list(selector) {
|
||||
return this.userModel_.find(selector)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a user by id.
|
||||
* @param {string} userId - the id of the user to get.
|
||||
* @return {Promise<User>} the user document.
|
||||
*/
|
||||
retrieve(userId) {
|
||||
const validatedId = this.validateId_(userId)
|
||||
return this.userModel_.findOne({ _id: validatedId }).catch(err => {
|
||||
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a user with username being validated.
|
||||
* Fails if email is not a valid format.
|
||||
* @param {object} user - the user to create
|
||||
* @return {Promise} the result of create
|
||||
*/
|
||||
create(user) {
|
||||
const validatedEmail = this.validateEmail_(user.email)
|
||||
user.email = validatedEmail
|
||||
this.userModel_.create(user).catch(err => {
|
||||
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a user from a given user id.
|
||||
* @param {string} userId - the id of the user to delete. Must be
|
||||
* castable as an ObjectId
|
||||
* @return {Promise} the result of the delete operation.
|
||||
*/
|
||||
async delete(userId) {
|
||||
const user = await this.retrieve(userId)
|
||||
// Delete is idempotent, but we return a promise to allow then-chaining
|
||||
if (!user) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return this.userModel_.deleteOne({ _id: user._id }).catch(err => {
|
||||
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a password for a user
|
||||
* Fails if no user exists with userId and if the hashing of the new
|
||||
* password does not work.
|
||||
* @param {string} userId - the userId to set password for
|
||||
* @param {string} password - the old password to set
|
||||
* @returns {Promise} the result of the update operation
|
||||
*/
|
||||
async setPassword(userId, password) {
|
||||
const user = await this.retrieve(userId)
|
||||
if (!user) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`User with ${userId} was not found`
|
||||
)
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10)
|
||||
if (!hashedPassword) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`An error occured while hashing password`
|
||||
)
|
||||
}
|
||||
|
||||
return this.userModel_.updateOne(
|
||||
{ _id: userId },
|
||||
{ $set: { password: hashedPassword } }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a JSON Web token, that will be sent to a user, that wishes to
|
||||
* reset password.
|
||||
* The token will be signed with the users current password hash as a secret
|
||||
* a long side a payload with userId and the expiry time for the token, which
|
||||
* is always 15 minutes.
|
||||
* @param {User} user - the user to reset password for
|
||||
* @returns {string} the generated JSON web token
|
||||
*/
|
||||
async generateResetPasswordToken(userId) {
|
||||
const user = await this.retrieve(userId)
|
||||
if (!user) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`User with ${userId} was not found`
|
||||
)
|
||||
}
|
||||
const secret = user.passwordHash
|
||||
const expiry = Math.floor(Date.now() / 1000) + 60 * 15
|
||||
const payload = { userId: user._id, exp: expiry }
|
||||
const token = jwt.sign(payload, secret)
|
||||
return token
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorates a user.
|
||||
* @param {User} user - the cart to decorate.
|
||||
* @param {string[]} fields - the fields to include.
|
||||
* @param {string[]} expandFields - fields to expand.
|
||||
* @return {User} return the decorated user.
|
||||
*/
|
||||
async decorate(user, fields, expandFields = []) {
|
||||
const requiredFields = ["_id", "metadata"]
|
||||
const decorated = _.pick(user, fields.concat(requiredFields))
|
||||
return decorated
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated method to set metadata for a user.
|
||||
* To ensure that plugins does not overwrite each
|
||||
* others metadata fields, setMetadata is provided.
|
||||
* @param {string} userId - the user to apply metadata to.
|
||||
* @param {string} key - key for metadata field
|
||||
* @param {string} value - value for metadata field.
|
||||
* @return {Promise} resolves to the updated result.
|
||||
*/
|
||||
setMetadata(userId, key, value) {
|
||||
const validatedId = this.validateId_(userId)
|
||||
|
||||
if (typeof key !== "string") {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_ARGUMENT,
|
||||
"Key type is invalid. Metadata keys must be strings"
|
||||
)
|
||||
}
|
||||
|
||||
const keyPath = `metadata.${key}`
|
||||
return this.userModel_
|
||||
.updateOne({ _id: validatedId }, { $set: { [keyPath]: value } })
|
||||
.catch(err => {
|
||||
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default UserService
|
||||
Reference in New Issue
Block a user