Adds UserService (#10)
This commit is contained in:
1 parent
2236bde459
commit
bcaac913d2
4 files changed
+343
-1
No files matched your search
@@ -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 {
|
class UserModel extends BaseModel {
|
||||||
static modelName = "User"
|
static modelName = "User"
|
||||||
static schema = {
|
static schema = {
|
||||||
name: { type: String, required: true },
|
|
||||||
email: { type: String, required: true },
|
email: { type: String, required: true },
|
||||||
passwordHash: { type: String, required: true },
|
passwordHash: { type: String, required: true },
|
||||||
|
metadata: { type: mongoose.Schema.Types.Mixed, default: {} },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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-_.+/=]*$/
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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