Add core
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
import { Router } from "express"
|
||||
import admin from "./routes/admin"
|
||||
import store from "./routes/store"
|
||||
import users from "./routes/users"
|
||||
|
||||
// guaranteed to get dependencies
|
||||
export default () => {
|
||||
const app = Router()
|
||||
|
||||
users(app)
|
||||
admin(app)
|
||||
store(app)
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import passport from "passport"
|
||||
|
||||
export default () => {
|
||||
return passport.authenticate("jwt", { session: false })
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default fn => (...args) => fn(...args).catch(args[2])
|
||||
@@ -0,0 +1,7 @@
|
||||
import { default as authenticate } from "./authenticate"
|
||||
import { default as wrap } from "./await-middleware"
|
||||
|
||||
export default {
|
||||
authenticate,
|
||||
wrap
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Router } from "express"
|
||||
import middlewares from "../../middlewares"
|
||||
|
||||
const route = Router()
|
||||
|
||||
export default app => {
|
||||
app.use("/admin", route)
|
||||
|
||||
// Unauthenticated routes
|
||||
// route.use("/auth", require("./auth").default)
|
||||
|
||||
// Authenticated routes
|
||||
route.use(middlewares.authenticate())
|
||||
route.use("/products", require("./products").default)
|
||||
route.use("/product-variants", require("./product-variants").default)
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Router } from "express"
|
||||
import middlewares from "../../../middlewares"
|
||||
|
||||
const route = Router()
|
||||
|
||||
export default app => {
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Router } from "express"
|
||||
import middlewares from "../../../middlewares"
|
||||
|
||||
const route = Router()
|
||||
|
||||
export default app => {
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Router } from "express"
|
||||
import productRoutes from './products'
|
||||
import middlewares from "../../middlewares"
|
||||
|
||||
const route = Router()
|
||||
|
||||
export default app => {
|
||||
app.use("/store", route)
|
||||
|
||||
productRoutes(route)
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import mongoose from "mongoose"
|
||||
import getProduct from "../get-product"
|
||||
|
||||
describe("Get product by id", () => {
|
||||
const testId = `${mongoose.Types.ObjectId("56cb91bdc3464f14678934ca")}`
|
||||
const productServiceMock = {
|
||||
getProduct: jest.fn().mockImplementation(id => {
|
||||
if (id === testId) {
|
||||
return Promise.resolve({ _id: id, title: "test" })
|
||||
}
|
||||
return Promise.resolve(undefined)
|
||||
}),
|
||||
}
|
||||
const reqMock = id => {
|
||||
return {
|
||||
params: {
|
||||
productId: id,
|
||||
},
|
||||
scope: {
|
||||
resolve: jest.fn().mockImplementation(name => {
|
||||
if (name === "productService") {
|
||||
return productServiceMock
|
||||
}
|
||||
return undefined
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const resMock = {
|
||||
sendStatus: jest.fn().mockReturnValue(),
|
||||
json: jest.fn().mockReturnValue(),
|
||||
}
|
||||
|
||||
describe("get product by id successfull", () => {
|
||||
beforeAll(async () => {
|
||||
await getProduct(reqMock(testId), resMock)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("calls get product from productSerice", () => {
|
||||
expect(productServiceMock.getProduct).toHaveBeenCalledTimes(1)
|
||||
expect(productServiceMock.getProduct).toHaveBeenCalledWith(testId)
|
||||
})
|
||||
|
||||
it("calls res.json", () => {
|
||||
expect(resMock.json).toHaveBeenCalledTimes(1)
|
||||
expect(resMock.json).toHaveBeenCalledWith({
|
||||
_id: testId,
|
||||
title: "test",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("returns 404 when product not found", () => {
|
||||
beforeAll(async () => {
|
||||
const id = mongoose.Types.ObjectId()
|
||||
await getProduct(reqMock(`${id}`), resMock)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("return 404", () => {
|
||||
expect(resMock.sendStatus).toHaveBeenCalledTimes(1)
|
||||
expect(resMock.json).toHaveBeenCalledTimes(0)
|
||||
expect(resMock.sendStatus).toHaveBeenCalledWith(404)
|
||||
})
|
||||
})
|
||||
|
||||
describe("fails when validation fails", () => {
|
||||
let res
|
||||
beforeAll(async () => {
|
||||
try {
|
||||
await getProduct(reqMock(`not object id`), resMock)
|
||||
} catch (err) {
|
||||
res = err
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("return 404", () => {
|
||||
expect(res.name).toEqual("ValidationError")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import validator from "../../../../utils/validator"
|
||||
|
||||
export default async (req, res) => {
|
||||
const { productId } = req.params
|
||||
|
||||
const schema = validator.objectId()
|
||||
const { value, error } = schema.validate(productId)
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const productService = req.scope.resolve("productService")
|
||||
const product = await productService.getProduct(value)
|
||||
|
||||
if (!product) {
|
||||
res.sendStatus(404)
|
||||
return
|
||||
}
|
||||
|
||||
res.json(product)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Router } from "express"
|
||||
import middlewares from "../../../middlewares"
|
||||
|
||||
const route = Router()
|
||||
|
||||
export default app => {
|
||||
app.use("/products", route)
|
||||
route.get("/:productId", middlewares.wrap(require("./get-product").default))
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Router } from "express"
|
||||
import middlewares from "../../middlewares"
|
||||
|
||||
const route = Router()
|
||||
|
||||
export default app => {
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import "core-js/stable"
|
||||
import "regenerator-runtime/runtime"
|
||||
import express from "express"
|
||||
import loaders from "./loaders"
|
||||
import Logger from "./loaders/logger"
|
||||
import { MedusaErrorTypes } from "./utils/errors"
|
||||
|
||||
const PORT = process.env.PORT || 80
|
||||
|
||||
const startServer = async () => {
|
||||
const app = express()
|
||||
|
||||
await loaders({ expressApp: app })
|
||||
|
||||
app.use((err, req, res, next) => {
|
||||
const logger = req.scope.resolve("logger")
|
||||
logger.error(err.message)
|
||||
|
||||
let statusCode = 500
|
||||
switch (err.name) {
|
||||
case "ValidationError":
|
||||
statusCode = 400
|
||||
break
|
||||
case MedusaErrorTypes.INVALID_DATA:
|
||||
statusCode = 400
|
||||
break
|
||||
case MedusaErrorTypes.DB_ERROR:
|
||||
statusCode = 500
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
res.json(err).status(statusCode)
|
||||
})
|
||||
|
||||
app.listen(PORT, err => {
|
||||
if (err) {
|
||||
console.log(err)
|
||||
return
|
||||
}
|
||||
Logger.info(`Server is ready on port: ${PORT}!`)
|
||||
})
|
||||
}
|
||||
|
||||
startServer()
|
||||
@@ -0,0 +1,41 @@
|
||||
import dotenv from "dotenv"
|
||||
|
||||
// Set the NODE_ENV to 'development' by default
|
||||
process.env.NODE_ENV = process.env.NODE_ENV || "development"
|
||||
|
||||
const envFound = dotenv.config()
|
||||
if (!envFound) {
|
||||
// This error should crash whole process
|
||||
throw new Error("⚠️ Couldn't find .env file ⚠️")
|
||||
}
|
||||
|
||||
export default {
|
||||
/**
|
||||
* Your favorite port
|
||||
*/
|
||||
port: parseInt(process.env.PORT, 10),
|
||||
|
||||
databaseURL: process.env.MONGODB_URI,
|
||||
redisURI: process.env.REDIS_URI,
|
||||
|
||||
/**
|
||||
* Your secret sauce
|
||||
*/
|
||||
jwtSecret: process.env.JWT_SECRET,
|
||||
|
||||
cookieSecret: process.env.COOKIE_SECRET,
|
||||
|
||||
/**
|
||||
* Used by winston logger
|
||||
*/
|
||||
logs: {
|
||||
level: process.env.LOG_LEVEL || "silly",
|
||||
},
|
||||
|
||||
/**
|
||||
* API configs
|
||||
*/
|
||||
api: {
|
||||
prefix: "/api",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import mongoose from "mongoose"
|
||||
|
||||
class IdMap {
|
||||
ids = {}
|
||||
|
||||
getId(key) {
|
||||
if (this.ids[key]) {
|
||||
return this.ids[key]
|
||||
}
|
||||
const id = `${mongoose.Types.ObjectId()}`
|
||||
this.ids[key] = id
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new IdMap()
|
||||
export default instance
|
||||
@@ -0,0 +1,121 @@
|
||||
import mongoose from "mongoose"
|
||||
|
||||
/**
|
||||
* Interface for data models. The default data layer uses an internal mongoose
|
||||
* model and is as such compatible with MongoDB.
|
||||
* @interface
|
||||
*/
|
||||
class BaseModel {
|
||||
constructor() {
|
||||
/** @const the underlying mongoose model used for queries */
|
||||
this.mongooseModel_ = this.createMongooseModel_(this.schema)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the model schema. The child class must implement the static schema
|
||||
* property.
|
||||
* @return {string} the models schema
|
||||
*/
|
||||
getSchema() {
|
||||
if (!this.constructor.schema) {
|
||||
throw new Error("Schema not defined")
|
||||
}
|
||||
return this.constructor.schema
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the model name. The child class must implement the static modelName
|
||||
* property.
|
||||
* @return {string} the name of the model
|
||||
*/
|
||||
getModelName() {
|
||||
if (!this.constructor.modelName) {
|
||||
throw new Error("Every model must have a static modelName property")
|
||||
}
|
||||
return this.constructor.modelName
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Creates a mongoose model based on schema and model name.
|
||||
* @return {Mongooose.Model} the mongoose model
|
||||
*/
|
||||
createMongooseModel_() {
|
||||
return mongoose.model(this.getModelName(), this.getSchema())
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the mongoose model via the mongoose's findOne.
|
||||
* @param query {object} a mongoose selector query
|
||||
* @param options {?object=} mongoose options
|
||||
* @return {?mongoose.Document} the retreived mongoose document or null.
|
||||
*/
|
||||
findOne(query, options = {}) {
|
||||
return this.mongooseModel_.findOne(query, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the mongoose model via the mongoose's find.
|
||||
* @param query {object} a mongoose selector query
|
||||
* @param options {?object=} mongoose options
|
||||
* @return {Array<mongoose.Document>} the retreived mongoose documents or
|
||||
* an empty array
|
||||
*/
|
||||
find(query, options) {
|
||||
return this.mongooseModel_.find(query, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a model via the mongoose model's updateOne.
|
||||
* @param query {object} a mongoose selector query
|
||||
* @param update {object} mongoose update object
|
||||
* @param options {?object=} mongoose options
|
||||
* @return {object} mongoose result
|
||||
*/
|
||||
updateOne(query, update, options) {
|
||||
return this.mongooseModel_.updateOne(query, update, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a model via the mongoose model's update.
|
||||
* @param query {object} a mongoose selector query
|
||||
* @param update {object} mongoose update object
|
||||
* @param options {?object=} mongoose options
|
||||
* @return {object} mongoose result
|
||||
*/
|
||||
update(query, update, options) {
|
||||
return this.mongooseModel_.update(query, update, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a document in the mongoose model's collection via create.
|
||||
* @param object {object} the value of the document to be created
|
||||
* @param options {?object=} mongoose options
|
||||
* @return {object} mongoose result
|
||||
*/
|
||||
create(object, options) {
|
||||
return this.mongooseModel_.create(object, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a document in the mongoose model's collection
|
||||
* @param query {object} the value of the document to be created
|
||||
* @param options {?object=} mongoose options
|
||||
* @return {object} mongoose result
|
||||
*/
|
||||
deleteOne(query, options) {
|
||||
return this.mongooseModel_.deleteOne(query, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes many document in the mongoose model's collection
|
||||
* @param query {object} the value of the document to be created
|
||||
* @param options {?object=} mongoose options
|
||||
* @return {object} mongoose result
|
||||
*/
|
||||
delete(query, options) {
|
||||
return this.mongooseModel_.deleteMany(query, options)
|
||||
}
|
||||
}
|
||||
|
||||
export default BaseModel
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Common functionality for Services
|
||||
* @interface
|
||||
*/
|
||||
class BaseService {}
|
||||
export default BaseService
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as BaseService } from "./base-service"
|
||||
export { default as BaseModel } from "./base-model"
|
||||
@@ -0,0 +1,62 @@
|
||||
import BaseService from "./base-service"
|
||||
|
||||
/**
|
||||
* The interface that all payment services must inherit from. The intercace
|
||||
* provides the necessary methods for creating, authorizing and managing
|
||||
* payments.
|
||||
* @interface
|
||||
*/
|
||||
class BasePaymentService extends BaseService {
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to create a payment to be processed with the service's payment gateway.
|
||||
* @param cart {object} - the cart that the payment should cover.
|
||||
* @return {Promise<{object}>} - returns a promise that resolves to an object
|
||||
* containing the payment data. This data will be saved to the cart for later
|
||||
* use.
|
||||
*/
|
||||
createPayment(cart) {
|
||||
throw Error("createPayment must be overridden by the child class")
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to retrieve a payment.
|
||||
* @param cart {object} - the cart whose payment should be retrieved.
|
||||
* @return {Promise<{object}>} - returns a promise that resolves to the
|
||||
* payment object as stored with the provider.
|
||||
*/
|
||||
retrievePayment(cart) {
|
||||
throw Error("getPayment must be overridden by the child class")
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to update a payment. This method is called when the cart is updated.
|
||||
* @param cart {object} - the cart whose payment should be updated.
|
||||
* @return {Promise<{object}>} - returns a promise that resolves to the
|
||||
* payment object as stored with the provider.
|
||||
*/
|
||||
updatePayment(cart) {
|
||||
throw Error("updatePayment must be overridden by the child class")
|
||||
}
|
||||
|
||||
authorizePayment() {
|
||||
throw Error("authorizePayment must be overridden by the child class")
|
||||
}
|
||||
|
||||
capturePayment() {
|
||||
throw Error("capturePayment must be overridden by the child class")
|
||||
}
|
||||
|
||||
refundPayment() {
|
||||
throw Error("refundPayment must be overridden by the child class")
|
||||
}
|
||||
|
||||
deletePayment() {
|
||||
throw Error("deletePayment must be overridden by the child class")
|
||||
}
|
||||
}
|
||||
|
||||
export default BasePaymentService
|
||||
@@ -0,0 +1,6 @@
|
||||
import routes from "../api"
|
||||
|
||||
export default async ({ app }) => {
|
||||
app.use("/", routes())
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import express from "express"
|
||||
import bodyParser from "body-parser"
|
||||
import session from "express-session"
|
||||
import cookieParser from "cookie-parser"
|
||||
import cors from "cors"
|
||||
import morgan from "morgan"
|
||||
|
||||
import config from "../config"
|
||||
|
||||
export default async ({ app }) => {
|
||||
app.enable("trust proxy")
|
||||
|
||||
app.use(cors())
|
||||
app.use(morgan("combined"))
|
||||
app.use(cookieParser())
|
||||
app.use(bodyParser.json())
|
||||
app.use(
|
||||
session({
|
||||
secret: config.cookieSecret,
|
||||
resave: false,
|
||||
saveUninitialized: true,
|
||||
cookie: { secure: true },
|
||||
})
|
||||
)
|
||||
|
||||
app.get("/health", (req, res) => {
|
||||
res.status(200).send("OK")
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createContainer, asValue } from "awilix"
|
||||
import expressLoader from "./express"
|
||||
import mongooseLoader from "./mongoose"
|
||||
import apiLoader from "./api"
|
||||
import modelsLoader from "./models"
|
||||
import servicesLoader from "./services"
|
||||
import passportLoader from "./passport"
|
||||
import pluginsLoader from "./plugins"
|
||||
import Logger from "./logger"
|
||||
|
||||
export default async ({ expressApp }) => {
|
||||
const container = createContainer()
|
||||
container.registerAdd = function(name, registration) {
|
||||
let storeKey = name + "_STORE"
|
||||
|
||||
if (this.registrations[storeKey] === undefined) {
|
||||
this.register(storeKey, asValue([]))
|
||||
}
|
||||
let store = this.resolve(storeKey)
|
||||
|
||||
if (this.registrations[name] === undefined) {
|
||||
this.register(name, asArray(store))
|
||||
}
|
||||
store.unshift(registration)
|
||||
|
||||
return this
|
||||
}.bind(container)
|
||||
|
||||
container.register({
|
||||
logger: asValue(Logger),
|
||||
})
|
||||
|
||||
await modelsLoader({ container })
|
||||
Logger.info("Models initialized")
|
||||
|
||||
await servicesLoader({ container })
|
||||
Logger.info("Services initialized")
|
||||
|
||||
await pluginsLoader({ container })
|
||||
Logger.info("Plugins Intialized")
|
||||
|
||||
await mongooseLoader()
|
||||
Logger.info("MongoDB Intialized")
|
||||
|
||||
await expressLoader({ app: expressApp })
|
||||
Logger.info("Express Intialized")
|
||||
|
||||
await passportLoader({ app: expressApp, container })
|
||||
Logger.info("Passport initialized")
|
||||
|
||||
// Add the registered services to the request scope
|
||||
expressApp.use((req, res, next) => {
|
||||
req.scope = container.createScope()
|
||||
next()
|
||||
})
|
||||
|
||||
await apiLoader({ app: expressApp })
|
||||
Logger.info("API initialized")
|
||||
}
|
||||
|
||||
function asArray(resolvers) {
|
||||
return {
|
||||
resolve: (container, opts) => resolvers.map(r => container.build(r, opts)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import winston from "winston"
|
||||
import config from "../config"
|
||||
|
||||
const transports = []
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
transports.push(new winston.transports.Console())
|
||||
} else {
|
||||
transports.push(
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.cli(),
|
||||
winston.format.splat()
|
||||
),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const LoggerInstance = winston.createLogger({
|
||||
level: config.logs.level,
|
||||
levels: winston.config.npm.levels,
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({
|
||||
format: "YYYY-MM-DD HH:mm:ss",
|
||||
}),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.splat(),
|
||||
winston.format.json()
|
||||
),
|
||||
transports,
|
||||
})
|
||||
|
||||
export default LoggerInstance
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Lifetime } from "awilix"
|
||||
|
||||
/**
|
||||
* Registers all models in the model directory
|
||||
*/
|
||||
export default ({ container }) => {
|
||||
// service/auth.js -> authService
|
||||
container.loadModules(["src/models/*.js"], {
|
||||
resolverOptions: {
|
||||
lifetime: Lifetime.SINGLETON,
|
||||
},
|
||||
formatName: (rawname, descriptor) => {
|
||||
const parts = rawname.split("-").map((n, index) => {
|
||||
if (index !== 0) {
|
||||
return n.charAt(0).toUpperCase() + n.slice(1)
|
||||
}
|
||||
return n
|
||||
})
|
||||
const name = parts.join("")
|
||||
|
||||
const splat = descriptor.path.split("/")
|
||||
const namespace = splat[splat.length - 2]
|
||||
const upperNamespace =
|
||||
namespace.charAt(0).toUpperCase() + namespace.slice(1, -1)
|
||||
return name + upperNamespace
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import mongoose from "mongoose"
|
||||
import config from "../config"
|
||||
|
||||
export default async () => {
|
||||
const connection = await mongoose.connect(config.databaseURL, {
|
||||
useNewUrlParser: true,
|
||||
useCreateIndex: true,
|
||||
useUnifiedTopology: true,
|
||||
})
|
||||
return connection.connection.db
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import passport from "passport"
|
||||
import { Strategy as LocalStrategy } from "passport-local"
|
||||
import { Strategy as JWTStrategy } from "passport-jwt"
|
||||
import config from "../config"
|
||||
|
||||
export default async ({ app, container }) => {
|
||||
const authService = container.cradle.authService
|
||||
|
||||
passport.use(
|
||||
new LocalStrategy(
|
||||
{
|
||||
usernameField: "email",
|
||||
passwordField: "password",
|
||||
},
|
||||
async (email, password, done) => {
|
||||
try {
|
||||
const { success, user } = authService.authenticate(email, password)
|
||||
if (success) {
|
||||
return done(null, user)
|
||||
} else {
|
||||
return done("Incorrect Username / Password")
|
||||
}
|
||||
} catch (error) {
|
||||
return done(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
passport.use(
|
||||
new JWTStrategy(
|
||||
{
|
||||
jwtFromRequest: req => req.cookies.jwt,
|
||||
secretOrKey: config.jwtSecret,
|
||||
},
|
||||
(jwtPayload, done) => {
|
||||
if (Date.now() > jwtPayload.expires) {
|
||||
return done("jwt expired")
|
||||
}
|
||||
|
||||
return done(null, jwtPayload)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import glob from "glob"
|
||||
import _ from "lodash"
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
import { asFunction } from "awilix"
|
||||
import { sync as existsSync } from "fs-exists-cached"
|
||||
import { plugins } from "../../medusa-config.js"
|
||||
import PaymentService from "../interfaces/payment-service"
|
||||
import BaseModel from "../interfaces/base-model"
|
||||
import BaseService from "../interfaces/base-service"
|
||||
|
||||
/**
|
||||
* Registers all services in the services directory
|
||||
*/
|
||||
export default ({ container }) => {
|
||||
const resolved = plugins.map(plugin => {
|
||||
if (_.isString(plugin)) {
|
||||
return resolvePlugin(plugin)
|
||||
}
|
||||
|
||||
const details = resolvePlugin(plugin.resolve)
|
||||
details.options = plugin.options
|
||||
|
||||
return details
|
||||
})
|
||||
|
||||
resolved.forEach(pluginDetails => {
|
||||
registerServices(pluginDetails, container)
|
||||
registerModels(pluginDetails, container)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a service at the right location in our container. If the service is
|
||||
* a BaseService instance it will be available directly from the container.
|
||||
* PaymentService instances are added to the paymentProviders array in the
|
||||
* container. Names are camelCase formatted and namespaced by the folder i.e:
|
||||
* services/example-payments -> examplePaymentsService
|
||||
* @param {object} pluginDetails - the plugin details including plugin options,
|
||||
* version, id, resolved path, etc. See resolvePlugin
|
||||
* @param {object} container - the container where the services will be
|
||||
* registered
|
||||
* @return {void}
|
||||
*/
|
||||
function registerServices(pluginDetails, container) {
|
||||
const files = glob.sync(`${pluginDetails.resolve}/services/*`, {})
|
||||
files.forEach(fn => {
|
||||
const loaded = require(fn).default
|
||||
|
||||
if (!(loaded.prototype instanceof BaseService)) {
|
||||
const logger = container.resolve("logger")
|
||||
const message = `Models must inherit from BaseModel, please check ${fn}`
|
||||
logger.error(message)
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
if (loaded.prototype instanceof PaymentService) {
|
||||
// Register our payment providers to paymentProviders
|
||||
container.registerAdd(
|
||||
"paymentProviders",
|
||||
asFunction(cradle => new loaded(cradle, pluginDetails.options))
|
||||
)
|
||||
|
||||
// Add the service directly to the container in order to make simple
|
||||
// resolution if we already know which payment provider we need to use
|
||||
container.register({
|
||||
[`pp_${loaded.identifier}`]: asFunction(
|
||||
cradle => new loaded(cradle, pluginDetails.options)
|
||||
),
|
||||
})
|
||||
} else {
|
||||
const name = formatRegistrationName(fn)
|
||||
container.register({
|
||||
[name]: asFunction(cradle => new loaded(cradle, pluginDetails.options)),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a plugin's models at the right location in our container. Models
|
||||
* must inherit from BaseModel. Models are registered directly in the container.
|
||||
* Names are camelCase formatted and namespaced by the folder i.e:
|
||||
* models/example-person -> examplePersonModel
|
||||
* @param {object} pluginDetails - the plugin details including plugin options,
|
||||
* version, id, resolved path, etc. See resolvePlugin
|
||||
* @param {object} container - the container where the services will be
|
||||
* registered
|
||||
* @return {void}
|
||||
*/
|
||||
function registerModels(pluginDetails, container) {
|
||||
const files = glob.sync(`${pluginDetails.resolve}/models/*`, {})
|
||||
files.forEach(fn => {
|
||||
const loaded = require(fn).default
|
||||
if (!(loaded.prototype instanceof BaseModel)) {
|
||||
const logger = container.resolve("logger")
|
||||
const message = `Models must inherit from BaseModel, please check ${fn}`
|
||||
logger.error(message)
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const name = formatRegistrationName(fn)
|
||||
container.register({
|
||||
[name]: asFunction(cradle => new loaded(cradle, pluginDetails.options)),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a filename into the correct container resolution name.
|
||||
* Names are camelCase formatted and namespaced by the folder i.e:
|
||||
* models/example-person -> examplePersonModel
|
||||
* @param {string} fn - the full path of the file
|
||||
* @return {string} the formatted name
|
||||
*/
|
||||
function formatRegistrationName(fn) {
|
||||
const descriptor = fn.split(".")[0]
|
||||
const splat = descriptor.split("/")
|
||||
const rawname = splat[splat.length - 1]
|
||||
const namespace = splat[splat.length - 2]
|
||||
const upperNamespace =
|
||||
namespace.charAt(0).toUpperCase() + namespace.slice(1, -1)
|
||||
const parts = rawname.split("-").map((n, index) => {
|
||||
if (index !== 0) {
|
||||
return n.charAt(0).toUpperCase() + n.slice(1)
|
||||
}
|
||||
return n
|
||||
})
|
||||
return parts.join("") + upperNamespace
|
||||
}
|
||||
|
||||
// TODO: Create unique id for each plugin
|
||||
function createPluginId(name) {
|
||||
return name
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the correct path for the plugin. If it is a local plugin it will be
|
||||
* found in the plugins folder. Otherwise we will look for the plugin in the
|
||||
* installed npm packages.
|
||||
* @param {string} pluginName - the name of the plugin to find. Should match
|
||||
* the name of the folder where the plugin is contained.
|
||||
* @return {object} the plugin details
|
||||
*/
|
||||
function resolvePlugin(pluginName) {
|
||||
// Only find plugins when we're not given an absolute path
|
||||
if (!existsSync(pluginName)) {
|
||||
// Find the plugin in the local plugins folder
|
||||
const resolvedPath = path.resolve(`./plugins/${pluginName}`)
|
||||
|
||||
if (existsSync(resolvedPath)) {
|
||||
if (existsSync(`${resolvedPath}/package.json`)) {
|
||||
const packageJSON = JSON.parse(
|
||||
fs.readFileSync(`${resolvedPath}/package.json`, `utf-8`)
|
||||
)
|
||||
const name = packageJSON.name || pluginName
|
||||
//warnOnIncompatiblePeerDependency(name, packageJSON)
|
||||
|
||||
return {
|
||||
resolve: resolvedPath,
|
||||
name,
|
||||
id: createPluginId(name),
|
||||
options: {},
|
||||
version:
|
||||
packageJSON.version || createFileContentHash(resolvedPath, `**`),
|
||||
}
|
||||
} else {
|
||||
// Make package.json a requirement for local plugins too
|
||||
throw new Error(`Plugin ${pluginName} requires a package.json file`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Here we have an absolute path to an internal plugin, or a name of a module
|
||||
* which should be located in node_modules.
|
||||
*/
|
||||
try {
|
||||
// If the path is absolute, resolve the directory of the internal plugin,
|
||||
// otherwise resolve the directory containing the package.json
|
||||
const resolvedPath = path.dirname(
|
||||
require.resolve(`${pluginName}/package.json`)
|
||||
)
|
||||
|
||||
const packageJSON = JSON.parse(
|
||||
fs.readFileSync(`${resolvedPath}/package.json`, `utf-8`)
|
||||
)
|
||||
// warnOnIncompatiblePeerDependency(packageJSON.name, packageJSON)
|
||||
|
||||
return {
|
||||
resolve: resolvedPath,
|
||||
id: createPluginId(packageJSON.name),
|
||||
name: packageJSON.name,
|
||||
version: packageJSON.version,
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Unable to find plugin "${pluginName}". Perhaps you need to install its package?`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Lifetime } from "awilix"
|
||||
|
||||
/**
|
||||
* Registers all services in the services directory
|
||||
*/
|
||||
export default ({ container }) => {
|
||||
// service/auth.js -> authService
|
||||
container.loadModules(["src/services/*.js"], {
|
||||
resolverOptions: {
|
||||
lifetime: Lifetime.SINGLETON,
|
||||
},
|
||||
formatName: (rawname, descriptor) => {
|
||||
const parts = rawname.split("-").map((n, index) => {
|
||||
if (index !== 0) {
|
||||
return n.charAt(0).toUpperCase() + n.slice(1)
|
||||
}
|
||||
return n
|
||||
})
|
||||
const name = parts.join("")
|
||||
|
||||
const splat = descriptor.path.split("/")
|
||||
const namespace = splat[splat.length - 2]
|
||||
const upperNamespace =
|
||||
namespace.charAt(0).toUpperCase() + namespace.slice(1, -1)
|
||||
return name + upperNamespace
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*******************************************************************************
|
||||
* models/cart-item.js
|
||||
*
|
||||
******************************************************************************/
|
||||
import { BaseModel } from "../interfaces"
|
||||
|
||||
class CartItemModel extends BaseModel {
|
||||
static modelName = "CartItem"
|
||||
static schema = {
|
||||
type: {
|
||||
type: String,
|
||||
enum: ["product", "bundle"],
|
||||
default: "product",
|
||||
required: true,
|
||||
},
|
||||
quantity: { type: Number, min: 0, required: true },
|
||||
}
|
||||
}
|
||||
|
||||
export default CartItemModel
|
||||
@@ -0,0 +1,21 @@
|
||||
/*******************************************************************************
|
||||
* models/product-variant.js
|
||||
*
|
||||
******************************************************************************/
|
||||
import mongoose from "mongoose"
|
||||
import { BaseModel } from "../interfaces"
|
||||
|
||||
import MoneyAmountSchema from "./schemas/money-amount"
|
||||
import OptionValueSchema from "./schemas/option-value"
|
||||
|
||||
class ProductVariantModel extends BaseModel {
|
||||
static modelName = "ProductVariant"
|
||||
static schema = {
|
||||
title: { type: String, required: true },
|
||||
prices: { type: [MoneyAmountSchema], default: [], required: true },
|
||||
options: { type: [OptionValueSchema], default: [] },
|
||||
image: { type: String, default: "" },
|
||||
}
|
||||
}
|
||||
|
||||
export default ProductVariantModel
|
||||
@@ -0,0 +1,25 @@
|
||||
/*******************************************************************************
|
||||
* models/product.js
|
||||
*
|
||||
******************************************************************************/
|
||||
import mongoose from "mongoose"
|
||||
import { BaseModel } from "../interfaces"
|
||||
|
||||
import OptionSchema from "./schemas/option"
|
||||
|
||||
class ProductModel extends BaseModel {
|
||||
static modelName = "Product"
|
||||
static schema = {
|
||||
title: { type: String, required: true },
|
||||
description: { type: String, default: "" },
|
||||
tags: { type: String, default: "" },
|
||||
handle: { type: String, required: true, unique: true },
|
||||
images: { type: [String], default: [] },
|
||||
options: { type: [OptionSchema], default: [] },
|
||||
variants: { type: [String], default: [] },
|
||||
metadata: { type: mongoose.Schema.Types.Mixed, default: {} },
|
||||
published: { type: Boolean, default: false },
|
||||
}
|
||||
}
|
||||
|
||||
export default ProductModel
|
||||
@@ -0,0 +1,10 @@
|
||||
/*******************************************************************************
|
||||
* models/money-amount.js
|
||||
*
|
||||
******************************************************************************/
|
||||
import mongoose from "mongoose"
|
||||
|
||||
export default new mongoose.Schema({
|
||||
currency_code: { type: String, required: true },
|
||||
amount: { type: Number, required: true, min: 0 },
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import mongoose from "mongoose"
|
||||
|
||||
export default new mongoose.Schema({
|
||||
option_id: { type: mongoose.Types.ObjectId, required: true },
|
||||
value: { type: String, required: true },
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
/*******************************************************************************
|
||||
* models/option.js
|
||||
*
|
||||
******************************************************************************/
|
||||
import mongoose from "mongoose"
|
||||
|
||||
export default new mongoose.Schema({
|
||||
product_id: { type: mongoose.Types.ObjectId, required: true },
|
||||
title: { type: String, required: true },
|
||||
values: { type: [String], default: [] },
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
/*******************************************************************************
|
||||
* models/user.js
|
||||
*
|
||||
******************************************************************************/
|
||||
import { BaseModel } from "../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 },
|
||||
}
|
||||
}
|
||||
|
||||
export default UserModel
|
||||
@@ -0,0 +1,44 @@
|
||||
import bcrypt from "bcrypt"
|
||||
import AuthService from "../auth"
|
||||
|
||||
const UserModelMock = {
|
||||
findOne: opt => {
|
||||
return bcrypt
|
||||
.hash("123456", 10)
|
||||
.then(hash => ({ email: "email@mail.com", passwordHash: hash }))
|
||||
},
|
||||
}
|
||||
|
||||
describe("AuthService", () => {
|
||||
describe("constructor", () => {
|
||||
let authService
|
||||
beforeAll(() => {
|
||||
authService = new AuthService({ userModel: UserModelMock })
|
||||
})
|
||||
|
||||
it("assigns userModel", () => {
|
||||
expect(authService.userModel_).toEqual(UserModelMock)
|
||||
})
|
||||
})
|
||||
|
||||
describe("authenticate", () => {
|
||||
let authService
|
||||
beforeEach(() => {
|
||||
authService = new AuthService({ userModel: UserModelMock })
|
||||
})
|
||||
|
||||
it("returns success when passwords match", async () => {
|
||||
const result = await authService.authenticate("email@mail.com", "123456")
|
||||
|
||||
expect(result.success).toEqual(true)
|
||||
expect(result.user.email).toEqual("email@mail.com")
|
||||
})
|
||||
|
||||
it("returns failure when passwords don't match", async () => {
|
||||
const result = await authService.authenticate("email@mail.com", "not")
|
||||
|
||||
expect(result.success).toEqual(false)
|
||||
expect(result.user).toEqual(undefined)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
import Bull from "bull"
|
||||
import EventBusService from "../event-bus"
|
||||
import config from "../../config"
|
||||
|
||||
jest.genMockFromModule("bull")
|
||||
jest.mock("bull")
|
||||
jest.mock("../../config")
|
||||
|
||||
config.redisURI = "testhost"
|
||||
|
||||
const loggerMock = {
|
||||
info: jest.fn().mockReturnValue(console.log),
|
||||
warn: jest.fn().mockReturnValue(console.log),
|
||||
error: jest.fn().mockReturnValue(console.log),
|
||||
}
|
||||
|
||||
describe("EventBusService", () => {
|
||||
describe("constructor", () => {
|
||||
beforeAll(() => {
|
||||
jest.resetAllMocks()
|
||||
const eventBus = new EventBusService({ logger: loggerMock })
|
||||
})
|
||||
|
||||
it("creates bull queue", () => {
|
||||
expect(Bull).toHaveBeenCalledTimes(1)
|
||||
expect(Bull).toHaveBeenCalledWith("EventBusService:queue", "testhost")
|
||||
})
|
||||
})
|
||||
|
||||
describe("subscribe", () => {
|
||||
let eventBus
|
||||
describe("successfully adds subscriber", () => {
|
||||
beforeAll(() => {
|
||||
jest.resetAllMocks()
|
||||
eventBus = new EventBusService({ logger: loggerMock })
|
||||
eventBus.subscribe("eventName", () => "test")
|
||||
})
|
||||
|
||||
it("added the subscriber to the queue", () => {
|
||||
expect(eventBus.observers_["eventName"].length).toEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("fails when adding non-function subscriber", () => {
|
||||
beforeAll(() => {
|
||||
jest.resetAllMocks()
|
||||
eventBus = new EventBusService({ logger: loggerMock })
|
||||
})
|
||||
|
||||
it("rejects subscriber with error", () => {
|
||||
try {
|
||||
eventBus.subscribe("eventName", 1234)
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual("Subscriber must be a function")
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("emit", () => {
|
||||
let eventBus, job
|
||||
describe("successfully adds job to queue", () => {
|
||||
beforeAll(() => {
|
||||
jest.resetAllMocks()
|
||||
eventBus = new EventBusService({ logger: loggerMock })
|
||||
|
||||
eventBus.queue_.add.mockImplementationOnce(() => "hi")
|
||||
|
||||
job = eventBus.emit("eventName", { hi: "1234" })
|
||||
})
|
||||
|
||||
it("calls queue.add", () => {
|
||||
expect(eventBus.queue_.add).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("returns the job", () => {
|
||||
expect(job).toEqual("hi")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("worker", () => {
|
||||
let eventBus, result
|
||||
describe("successfully runs the worker", () => {
|
||||
beforeAll(async () => {
|
||||
jest.resetAllMocks()
|
||||
eventBus = new EventBusService({ logger: loggerMock })
|
||||
eventBus.subscribe("eventName", () => Promise.resolve("hi"))
|
||||
result = await eventBus.worker_({ eventName: "eventName", data: {} })
|
||||
})
|
||||
|
||||
it("calls logger", () => {
|
||||
expect(loggerMock.info).toHaveBeenCalled()
|
||||
expect(loggerMock.info).toHaveBeenCalledWith(
|
||||
"Processing eventName which has 1 subscribers"
|
||||
)
|
||||
})
|
||||
|
||||
it("returns array with hi", async () => {
|
||||
expect(result).toEqual(["hi"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("continue if errors occur", () => {
|
||||
beforeAll(async () => {
|
||||
jest.resetAllMocks()
|
||||
eventBus = new EventBusService({ logger: loggerMock })
|
||||
eventBus.subscribe("eventName", () => Promise.resolve("hi"))
|
||||
eventBus.subscribe("eventName", () => Promise.resolve("hi2"))
|
||||
eventBus.subscribe("eventName", () => Promise.resolve("hi3"))
|
||||
eventBus.subscribe("eventName", () => Promise.reject("fail1"))
|
||||
eventBus.subscribe("eventName", () => Promise.reject("fail2"))
|
||||
eventBus.subscribe("eventName", () => Promise.reject("fail3"))
|
||||
|
||||
result = await eventBus.worker_({ eventName: "eventName", data: {} })
|
||||
})
|
||||
|
||||
it("calls logger warn on rejections", () => {
|
||||
expect(loggerMock.warn).toHaveBeenCalledTimes(3)
|
||||
expect(loggerMock.warn).toHaveBeenCalledWith(
|
||||
"An error occured while processing eventName: fail1"
|
||||
)
|
||||
expect(loggerMock.warn).toHaveBeenCalledWith(
|
||||
"An error occured while processing eventName: fail2"
|
||||
)
|
||||
expect(loggerMock.warn).toHaveBeenCalledWith(
|
||||
"An error occured while processing eventName: fail3"
|
||||
)
|
||||
})
|
||||
|
||||
it("returns result from all subscribers", async () => {
|
||||
expect(result.length).toEqual(6)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import IdMap from "../../../helpers/id-map"
|
||||
|
||||
export const ProductModelMock = {
|
||||
create: jest.fn().mockReturnValue(Promise.resolve()),
|
||||
updateOne: jest.fn().mockImplementation((query, update) => {
|
||||
if (query._id === IdMap.getId("productWithVariantsFail")) {
|
||||
return Promise.reject()
|
||||
}
|
||||
return Promise.resolve()
|
||||
}),
|
||||
deleteOne: jest.fn().mockReturnValue(Promise.resolve()),
|
||||
findOne: jest.fn().mockImplementation(query => {
|
||||
if (query._id === IdMap.getId("productWithFourVariants")) {
|
||||
return Promise.resolve({
|
||||
_id: IdMap.getId("productWithFourVariants"),
|
||||
title: "Product With Variants",
|
||||
variants: ["1", "2", "3", "4"],
|
||||
options: [
|
||||
{
|
||||
_id: IdMap.getId("color_id"),
|
||||
title: "Color",
|
||||
},
|
||||
{
|
||||
_id: IdMap.getId("size_id"),
|
||||
title: "Size",
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
if (query._id === IdMap.getId("productWithVariantsFail")) {
|
||||
return Promise.resolve({
|
||||
_id: IdMap.getId("productWithVariantsFail"),
|
||||
title: "Product With Variants",
|
||||
variants: ["1", "3", "4"],
|
||||
options: [
|
||||
{
|
||||
_id: IdMap.getId("color_id"),
|
||||
title: "Color",
|
||||
},
|
||||
{
|
||||
_id: IdMap.getId("size_id"),
|
||||
title: "Size",
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
if (query._id === IdMap.getId("productWithVariants")) {
|
||||
return Promise.resolve({
|
||||
_id: IdMap.getId("productWithVariants"),
|
||||
title: "Product With Variants",
|
||||
variants: ["1", "3", "4"],
|
||||
options: [
|
||||
{
|
||||
_id: IdMap.getId("color_id"),
|
||||
title: "Color",
|
||||
},
|
||||
{
|
||||
_id: IdMap.getId("size_id"),
|
||||
title: "Size",
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
if (query._id === IdMap.getId("variantProductId")) {
|
||||
return Promise.resolve({
|
||||
_id: IdMap.getId("variantProductId"),
|
||||
title: "testtitle",
|
||||
options: [
|
||||
{
|
||||
_id: IdMap.getId("color_id"),
|
||||
title: "Color",
|
||||
},
|
||||
{
|
||||
_id: IdMap.getId("size_id"),
|
||||
title: "Size",
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
if (query._id === IdMap.getId("emptyVariantProductId")) {
|
||||
return Promise.resolve({
|
||||
_id: IdMap.getId("emptyVariantProductId"),
|
||||
title: "testtitle",
|
||||
options: [],
|
||||
})
|
||||
}
|
||||
|
||||
if (query._id === IdMap.getId("deleteId")) {
|
||||
return Promise.resolve({
|
||||
_id: IdMap.getId("deleteId"),
|
||||
variants: ["1", "2"],
|
||||
})
|
||||
}
|
||||
|
||||
if (query._id === IdMap.getId("validId")) {
|
||||
return Promise.resolve({
|
||||
_id: IdMap.getId("validId"),
|
||||
title: "test",
|
||||
})
|
||||
}
|
||||
|
||||
if (query._id === IdMap.getId("failId")) {
|
||||
return Promise.reject(new Error("test error"))
|
||||
}
|
||||
return Promise.resolve(undefined)
|
||||
}),
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import IdMap from "../../../helpers/id-map"
|
||||
|
||||
const variant1 = {
|
||||
_id: "1",
|
||||
title: "variant1",
|
||||
options: [
|
||||
{
|
||||
option_id: IdMap.getId("color_id"),
|
||||
value: "blue",
|
||||
},
|
||||
{
|
||||
option_id: IdMap.getId("size_id"),
|
||||
value: "160",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const variant2 = {
|
||||
_id: "2",
|
||||
title: "variant2",
|
||||
options: [
|
||||
{
|
||||
option_id: IdMap.getId("color_id"),
|
||||
value: "black",
|
||||
},
|
||||
{
|
||||
option_id: IdMap.getId("size_id"),
|
||||
value: "160",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const variant3 = {
|
||||
_id: "3",
|
||||
title: "variant3",
|
||||
options: [
|
||||
{
|
||||
option_id: IdMap.getId("color_id"),
|
||||
value: "blue",
|
||||
},
|
||||
{
|
||||
option_id: IdMap.getId("size_id"),
|
||||
value: "150",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const variant4 = {
|
||||
_id: "4",
|
||||
title: "variant4",
|
||||
options: [
|
||||
{
|
||||
option_id: IdMap.getId("color_id"),
|
||||
value: "blue",
|
||||
},
|
||||
{
|
||||
option_id: IdMap.getId("size_id"),
|
||||
value: "50",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const invalidVariant = {
|
||||
_id: "invalid_option",
|
||||
title: "variant3",
|
||||
options: [
|
||||
{
|
||||
option_id: "invalid_id",
|
||||
value: "blue",
|
||||
},
|
||||
{
|
||||
option_id: IdMap.getId("size_id"),
|
||||
value: "150",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const emptyVariant = {
|
||||
_id: "empty_option",
|
||||
title: "variant3",
|
||||
options: [],
|
||||
}
|
||||
|
||||
export const variants = {
|
||||
one: variant1,
|
||||
two: variant2,
|
||||
three: variant3,
|
||||
four: variant4,
|
||||
invalid_variant: invalidVariant,
|
||||
empty_variant: emptyVariant,
|
||||
}
|
||||
|
||||
export const ProductVariantServiceMock = {
|
||||
retrieve: jest.fn().mockImplementation(variantId => {
|
||||
if (variantId === "1") {
|
||||
return Promise.resolve(variant1)
|
||||
}
|
||||
if (variantId === "2") {
|
||||
return Promise.resolve(variant2)
|
||||
}
|
||||
if (variantId === "3") {
|
||||
return Promise.resolve(variant3)
|
||||
}
|
||||
if (variantId === "4") {
|
||||
return Promise.resolve(variant4)
|
||||
}
|
||||
if (variantId === "invalid_option") {
|
||||
return Promise.resolve(invalidVariant)
|
||||
}
|
||||
if (variantId === "empty_option") {
|
||||
return Promise.resolve(emptyVariant)
|
||||
}
|
||||
return Promise.resolve(undefined)
|
||||
}),
|
||||
delete: jest.fn().mockReturnValue(Promise.resolve()),
|
||||
addOptionValue: jest.fn().mockImplementation((variantId, optionId, value) => {
|
||||
return Promise.resolve({})
|
||||
}),
|
||||
deleteOptionValue: jest.fn().mockImplementation((variantId, optionId) => {
|
||||
return Promise.resolve({})
|
||||
}),
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
import mongoose from "mongoose"
|
||||
import ProductService from "../product"
|
||||
import { ProductModelMock } from "./mocks/product-model"
|
||||
import {
|
||||
ProductVariantServiceMock,
|
||||
variants,
|
||||
} from "./mocks/product-variant-service"
|
||||
import IdMap from "../../helpers/id-map"
|
||||
|
||||
describe("ProductService", () => {
|
||||
describe("retrieve", () => {
|
||||
describe("successfully get product", () => {
|
||||
let res
|
||||
beforeAll(async () => {
|
||||
const productService = new ProductService({
|
||||
productModel: ProductModelMock,
|
||||
})
|
||||
|
||||
res = await productService.retrieve(IdMap.getId("validId"))
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("calls model layer findOne", () => {
|
||||
expect(ProductModelMock.findOne).toHaveBeenCalledTimes(1)
|
||||
expect(ProductModelMock.findOne).toHaveBeenCalledWith({
|
||||
_id: IdMap.getId("validId"),
|
||||
})
|
||||
})
|
||||
|
||||
it("returns correct product", () => {
|
||||
expect(res.title).toEqual("test")
|
||||
})
|
||||
})
|
||||
|
||||
describe("query fail", () => {
|
||||
let res
|
||||
beforeAll(async () => {
|
||||
const productService = new ProductService({
|
||||
productModel: ProductModelMock,
|
||||
})
|
||||
|
||||
await productService.retrieve(IdMap.getId("failId")).catch(err => {
|
||||
res = err
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("calls model layer findOne", () => {
|
||||
expect(ProductModelMock.findOne).toHaveBeenCalledTimes(1)
|
||||
expect(ProductModelMock.findOne).toHaveBeenCalledWith({
|
||||
_id: IdMap.getId("failId"),
|
||||
})
|
||||
})
|
||||
|
||||
it("model query throws error", () => {
|
||||
expect(res.name).toEqual("database_error")
|
||||
expect(res.message).toEqual("test error")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("createDraft", () => {
|
||||
beforeAll(() => {
|
||||
jest.clearAllMocks()
|
||||
const productService = new ProductService({
|
||||
productModel: ProductModelMock,
|
||||
})
|
||||
|
||||
productService.createDraft({
|
||||
title: "Test Prod",
|
||||
description: "Test Descript",
|
||||
tags: "Teststst",
|
||||
handle: "1234",
|
||||
images: [],
|
||||
options: [],
|
||||
variants: [],
|
||||
metadata: {},
|
||||
})
|
||||
})
|
||||
|
||||
it("calls model layer create", () => {
|
||||
expect(ProductModelMock.create).toHaveBeenCalledTimes(1)
|
||||
expect(ProductModelMock.create).toHaveBeenCalledWith({
|
||||
title: "Test Prod",
|
||||
description: "Test Descript",
|
||||
tags: "Teststst",
|
||||
handle: "1234",
|
||||
images: [],
|
||||
options: [],
|
||||
variants: [],
|
||||
metadata: {},
|
||||
published: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("publishProduct", () => {
|
||||
const productId = mongoose.Types.ObjectId()
|
||||
|
||||
beforeAll(() => {
|
||||
jest.clearAllMocks()
|
||||
const productService = new ProductService({
|
||||
productModel: ProductModelMock,
|
||||
})
|
||||
|
||||
productService.publish(IdMap.getId("productId"))
|
||||
})
|
||||
|
||||
it("calls model layer create", () => {
|
||||
expect(ProductModelMock.create).toHaveBeenCalledTimes(0)
|
||||
expect(ProductModelMock.updateOne).toHaveBeenCalledTimes(1)
|
||||
expect(ProductModelMock.updateOne).toHaveBeenCalledWith(
|
||||
{ _id: IdMap.getId("productId") },
|
||||
{ $set: { published: true } }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("decorate", () => {
|
||||
const productService = new ProductService({
|
||||
productModel: ProductModelMock,
|
||||
productVariantService: ProductVariantServiceMock,
|
||||
})
|
||||
|
||||
const fakeProduct = {
|
||||
_id: "1234",
|
||||
variants: ["1", "2", "3"],
|
||||
tags: "testtag1, testtag2",
|
||||
handle: "test-product",
|
||||
metadata: { testKey: "testValue" },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("returns decorated product", async () => {
|
||||
const decorated = await productService.decorate(
|
||||
fakeProduct,
|
||||
[],
|
||||
["variants"]
|
||||
)
|
||||
expect(decorated).toEqual({
|
||||
_id: "1234",
|
||||
metadata: { testKey: "testValue" },
|
||||
variants: [variants.one, variants.two, variants.three],
|
||||
})
|
||||
})
|
||||
|
||||
it("returns decorated product with handle", async () => {
|
||||
const decorated = await productService.decorate(
|
||||
fakeProduct,
|
||||
["handle"],
|
||||
["variants"]
|
||||
)
|
||||
expect(decorated).toEqual({
|
||||
_id: "1234",
|
||||
metadata: { testKey: "testValue" },
|
||||
handle: "test-product",
|
||||
variants: [variants.one, variants.two, variants.three],
|
||||
})
|
||||
})
|
||||
|
||||
it("returns decorated product with handle and tags", async () => {
|
||||
const decorated = await productService.decorate(fakeProduct, [
|
||||
"handle",
|
||||
"tags",
|
||||
])
|
||||
expect(decorated).toEqual({
|
||||
_id: "1234",
|
||||
metadata: { testKey: "testValue" },
|
||||
tags: "testtag1, testtag2",
|
||||
handle: "test-product",
|
||||
})
|
||||
})
|
||||
|
||||
it("returns decorated product with metadata", async () => {
|
||||
const decorated = await productService.decorate(fakeProduct, [])
|
||||
expect(decorated).toEqual({
|
||||
_id: "1234",
|
||||
metadata: { testKey: "testValue" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("add metadata to product model", () => {
|
||||
const productService = new ProductService({
|
||||
productModel: ProductModelMock,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("calls updateOne with correct params", async () => {
|
||||
const id = mongoose.Types.ObjectId()
|
||||
await productService.setMetadata(`${id}`, "metadata", "testMetadata")
|
||||
|
||||
expect(ProductModelMock.updateOne).toBeCalledTimes(1)
|
||||
expect(ProductModelMock.updateOne).toBeCalledWith(
|
||||
{ _id: `${id}` },
|
||||
{ $set: { "metadata.metadata": "testMetadata" } }
|
||||
)
|
||||
})
|
||||
|
||||
it("throw error on invalid key type", async () => {
|
||||
const id = mongoose.Types.ObjectId()
|
||||
|
||||
try {
|
||||
await productService.setMetadata(`${id}`, 1234, "nono")
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual(
|
||||
"Key type is invalid. Metadata keys must be strings"
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("throws error on invalid productId type", async () => {
|
||||
try {
|
||||
await productService.setMetadata("fakeProductId", 1234, "nono")
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual(
|
||||
"The productId could not be casted to an ObjectId"
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("update product", () => {
|
||||
const productService = new ProductService({
|
||||
productModel: ProductModelMock,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("calls updateOne with correct params", async () => {
|
||||
const id = mongoose.Types.ObjectId()
|
||||
|
||||
await productService.update(`${id}`, { title: "new title" })
|
||||
|
||||
expect(ProductModelMock.updateOne).toBeCalledTimes(1)
|
||||
expect(ProductModelMock.updateOne).toBeCalledWith(
|
||||
{ _id: `${id}` },
|
||||
{ $set: { title: "new title" } },
|
||||
{ runValidators: true }
|
||||
)
|
||||
})
|
||||
|
||||
it("throw error on invalid product id type", async () => {
|
||||
try {
|
||||
await productService.update(19314235, { title: "new title" })
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual(
|
||||
"The productId could not be casted to an ObjectId"
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("throws error when trying to update metadata", async () => {
|
||||
const id = mongoose.Types.ObjectId()
|
||||
try {
|
||||
await productService.update(`${id}`, { metadata: { key: "value" } })
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual("Use setMetadata to update metadata fields")
|
||||
}
|
||||
})
|
||||
|
||||
it("throws error when trying to update variants", async () => {
|
||||
const id = mongoose.Types.ObjectId()
|
||||
try {
|
||||
await productService.update(`${id}`, { variants: ["1", "2"] })
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual(
|
||||
"Use addVariant, reorderVariants, removeVariant to update Product Variants"
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("delete product", () => {
|
||||
const productService = new ProductService({
|
||||
productModel: ProductModelMock,
|
||||
productVariantService: ProductVariantServiceMock,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("deletes all variants and product successfully", async () => {
|
||||
await productService.delete(IdMap.getId("deleteId"))
|
||||
|
||||
expect(ProductVariantServiceMock.delete).toBeCalledTimes(2)
|
||||
expect(ProductVariantServiceMock.delete).toBeCalledWith("1")
|
||||
expect(ProductVariantServiceMock.delete).toBeCalledWith("2")
|
||||
|
||||
expect(ProductModelMock.deleteOne).toBeCalledTimes(1)
|
||||
expect(ProductModelMock.deleteOne).toBeCalledWith({
|
||||
_id: IdMap.getId("deleteId"),
|
||||
})
|
||||
})
|
||||
|
||||
it("throw error on invalid product id type", async () => {
|
||||
try {
|
||||
await productService.update(19314235, { title: "new title" })
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual(
|
||||
"The productId could not be casted to an ObjectId"
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("addVariant", () => {
|
||||
const productService = new ProductService({
|
||||
productModel: ProductModelMock,
|
||||
productVariantService: ProductVariantServiceMock,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("add variant to product successfilly", async () => {
|
||||
await productService.addVariant(IdMap.getId("variantProductId"), "1")
|
||||
|
||||
expect(ProductVariantServiceMock.retrieve).toBeCalledTimes(1)
|
||||
expect(ProductVariantServiceMock.retrieve).toBeCalledWith("1")
|
||||
expect(ProductModelMock.findOne).toBeCalledTimes(1)
|
||||
expect(ProductModelMock.findOne).toBeCalledWith({
|
||||
_id: IdMap.getId("variantProductId"),
|
||||
})
|
||||
expect(ProductModelMock.updateOne).toBeCalledTimes(1)
|
||||
expect(ProductModelMock.updateOne).toBeCalledWith(
|
||||
{ _id: IdMap.getId("variantProductId") },
|
||||
{ $push: { variants: "1" } }
|
||||
)
|
||||
})
|
||||
|
||||
it("throws error if option id is not present in product", async () => {
|
||||
try {
|
||||
await productService.addVariant(
|
||||
IdMap.getId("variantProductId"),
|
||||
"invalid_option"
|
||||
)
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual(
|
||||
"Variant options do not contain value for Color"
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("throws error if product variant options is empty", async () => {
|
||||
try {
|
||||
await productService.addVariant(
|
||||
IdMap.getId("variantProductId"),
|
||||
"empty_option"
|
||||
)
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual(
|
||||
"Product options length does not match variant options length. Product has 2 and variant has 0."
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("throws error if product options is empty and product variant contains options", async () => {
|
||||
try {
|
||||
await productService.addVariant(
|
||||
IdMap.getId("emptyVariantProductId"),
|
||||
"1"
|
||||
)
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual(
|
||||
"Product options length does not match variant options length. Product has 0 and variant has 2."
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("throws error if option values of added variant already exists", async () => {
|
||||
try {
|
||||
await productService.addVariant(IdMap.getId("productWithVariants"), "3")
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual(
|
||||
"Variant with provided options already exists"
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("addOption", () => {
|
||||
const productService = new ProductService({
|
||||
productModel: ProductModelMock,
|
||||
productVariantService: ProductVariantServiceMock,
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("creates variant option values and adds option", async () => {
|
||||
await productService.addOption(
|
||||
IdMap.getId("productWithVariants"),
|
||||
"Material"
|
||||
)
|
||||
|
||||
expect(ProductVariantServiceMock.addOptionValue).toHaveBeenCalledTimes(3)
|
||||
expect(ProductVariantServiceMock.addOptionValue).toHaveBeenCalledWith(
|
||||
"1",
|
||||
expect.anything(),
|
||||
"Default Value"
|
||||
)
|
||||
expect(ProductVariantServiceMock.addOptionValue).toHaveBeenCalledWith(
|
||||
"3",
|
||||
expect.anything(),
|
||||
"Default Value"
|
||||
)
|
||||
expect(ProductVariantServiceMock.addOptionValue).toHaveBeenCalledWith(
|
||||
"4",
|
||||
expect.anything(),
|
||||
"Default Value"
|
||||
)
|
||||
|
||||
expect(ProductModelMock.updateOne).toHaveBeenCalledWith(
|
||||
{ _id: IdMap.getId("productWithVariants") },
|
||||
{
|
||||
$push: {
|
||||
options: {
|
||||
_id: expect.anything(),
|
||||
title: "Material",
|
||||
product_id: IdMap.getId("productWithVariants"),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("cleans up after fail", async () => {
|
||||
try {
|
||||
await productService.addOption(
|
||||
IdMap.getId("productWithVariantsFail"),
|
||||
"Material"
|
||||
)
|
||||
} catch (err) {
|
||||
expect(err)
|
||||
}
|
||||
|
||||
expect(ProductVariantServiceMock.addOptionValue).toHaveBeenCalledTimes(3)
|
||||
expect(ProductVariantServiceMock.addOptionValue).toHaveBeenCalledWith(
|
||||
"1",
|
||||
expect.anything(),
|
||||
"Default Value"
|
||||
)
|
||||
expect(ProductVariantServiceMock.addOptionValue).toHaveBeenCalledWith(
|
||||
"3",
|
||||
expect.anything(),
|
||||
"Default Value"
|
||||
)
|
||||
expect(ProductVariantServiceMock.addOptionValue).toHaveBeenCalledWith(
|
||||
"4",
|
||||
expect.anything(),
|
||||
"Default Value"
|
||||
)
|
||||
|
||||
expect(ProductVariantServiceMock.deleteOptionValue).toHaveBeenCalledTimes(
|
||||
3
|
||||
)
|
||||
expect(ProductVariantServiceMock.deleteOptionValue).toHaveBeenCalledWith(
|
||||
"1",
|
||||
expect.anything()
|
||||
)
|
||||
expect(ProductVariantServiceMock.deleteOptionValue).toHaveBeenCalledWith(
|
||||
"3",
|
||||
expect.anything()
|
||||
)
|
||||
expect(ProductVariantServiceMock.deleteOptionValue).toHaveBeenCalledWith(
|
||||
"4",
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("deleteOption", () => {
|
||||
const productService = new ProductService({
|
||||
productModel: ProductModelMock,
|
||||
productVariantService: ProductVariantServiceMock,
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("deletes an option from a product", async () => {
|
||||
await productService.deleteOption(
|
||||
IdMap.getId("productWithVariants"),
|
||||
IdMap.getId("color_id")
|
||||
)
|
||||
|
||||
expect(ProductModelMock.updateOne).toBeCalledTimes(1)
|
||||
expect(ProductModelMock.updateOne).toBeCalledWith(
|
||||
{ _id: IdMap.getId("productWithVariants") },
|
||||
{ $pull: { options: { _id: IdMap.getId("color_id") } } }
|
||||
)
|
||||
expect(ProductVariantServiceMock.deleteOptionValue).toBeCalledTimes(3)
|
||||
expect(ProductVariantServiceMock.deleteOptionValue).toBeCalledWith(
|
||||
"1",
|
||||
IdMap.getId("color_id")
|
||||
)
|
||||
expect(ProductVariantServiceMock.deleteOptionValue).toBeCalledWith(
|
||||
"3",
|
||||
IdMap.getId("color_id")
|
||||
)
|
||||
expect(ProductVariantServiceMock.deleteOptionValue).toBeCalledWith(
|
||||
"4",
|
||||
IdMap.getId("color_id")
|
||||
)
|
||||
})
|
||||
|
||||
it("if option does not exist, do nothing", async () => {
|
||||
await productService.deleteOption(
|
||||
IdMap.getId("productWithVariants"),
|
||||
IdMap.getId("option_id")
|
||||
)
|
||||
|
||||
expect(ProductModelMock.updateOne).not.toBeCalled()
|
||||
})
|
||||
|
||||
it("throw if variant option values are not equal", async () => {
|
||||
try {
|
||||
await productService.deleteOption(
|
||||
IdMap.getId("productWithFourVariants"),
|
||||
IdMap.getId("size_id")
|
||||
)
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual(
|
||||
"To delete an option, first delete all variants, such that when option is deleted, no duplicate variants will exist. For more info check MEDUSA.com"
|
||||
)
|
||||
}
|
||||
|
||||
expect(ProductModelMock.updateOne).not.toBeCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("removeVariant", () => {
|
||||
const productService = new ProductService({
|
||||
productModel: ProductModelMock,
|
||||
productVariantService: ProductVariantServiceMock,
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("removes variant from product", async () => {
|
||||
await productService.removeVariant(
|
||||
IdMap.getId("productWithVariants"),
|
||||
"1"
|
||||
)
|
||||
|
||||
expect(ProductModelMock.updateOne).toBeCalledTimes(1)
|
||||
expect(ProductModelMock.updateOne).toBeCalledWith(
|
||||
{ _id: IdMap.getId("productWithVariants") },
|
||||
{ $pull: { variants: "1" } }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateOption", () => {
|
||||
const productService = new ProductService({
|
||||
productModel: ProductModelMock,
|
||||
productVariantService: ProductVariantServiceMock,
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("updates title", async () => {
|
||||
await productService.updateOption(
|
||||
IdMap.getId("productWithVariants"),
|
||||
IdMap.getId("color_id"),
|
||||
{
|
||||
title: "Shoe Color",
|
||||
}
|
||||
)
|
||||
|
||||
expect(ProductModelMock.updateOne).toBeCalledTimes(1)
|
||||
expect(ProductModelMock.updateOne).toBeCalledWith(
|
||||
{
|
||||
_id: IdMap.getId("productWithVariants"),
|
||||
"options._id": IdMap.getId("color_id"),
|
||||
},
|
||||
{ $set: { "options.$.title": "Shoe Color" } }
|
||||
)
|
||||
})
|
||||
|
||||
it("throws if option title exists", async () => {
|
||||
try {
|
||||
await productService.updateOption(
|
||||
IdMap.getId("productWithVariants"),
|
||||
IdMap.getId("color_id"),
|
||||
{
|
||||
title: "Size",
|
||||
}
|
||||
)
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual("An option with title Size already exists")
|
||||
}
|
||||
})
|
||||
|
||||
it("throws if option doesn't exist", async () => {
|
||||
try {
|
||||
await productService.updateOption(
|
||||
IdMap.getId("productWithVariants"),
|
||||
IdMap.getId("material"),
|
||||
{
|
||||
title: "Size",
|
||||
}
|
||||
)
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual(
|
||||
`Product has no option with id: ${IdMap.getId("material")}`
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("reorderOptions", () => {
|
||||
const productService = new ProductService({
|
||||
productModel: ProductModelMock,
|
||||
productVariantService: ProductVariantServiceMock,
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("reorders options", async () => {
|
||||
await productService.reorderOptions(IdMap.getId("productWithVariants"), [
|
||||
IdMap.getId("size_id"),
|
||||
IdMap.getId("color_id"),
|
||||
])
|
||||
|
||||
expect(ProductModelMock.updateOne).toBeCalledTimes(1)
|
||||
expect(ProductModelMock.updateOne).toBeCalledWith(
|
||||
{
|
||||
_id: IdMap.getId("productWithVariants"),
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
options: [
|
||||
{
|
||||
_id: IdMap.getId("size_id"),
|
||||
title: "Size",
|
||||
},
|
||||
{
|
||||
_id: IdMap.getId("color_id"),
|
||||
title: "Color",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("throws if one option id is not in the product options", async () => {
|
||||
try {
|
||||
await productService.reorderOptions(
|
||||
IdMap.getId("productWithVariants"),
|
||||
[IdMap.getId("size_id"), IdMap.getId("material")]
|
||||
)
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual(
|
||||
`Product has no option with id: ${IdMap.getId("material")}`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("throws if order length and product option lengths differ", async () => {
|
||||
try {
|
||||
await productService.reorderOptions(
|
||||
IdMap.getId("productWithVariants"),
|
||||
[
|
||||
IdMap.getId("size_id"),
|
||||
IdMap.getId("color_id"),
|
||||
IdMap.getId("material"),
|
||||
]
|
||||
)
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual(
|
||||
`Product options and new options order differ in length. To delete or add options use removeOption or addOption`
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("reorderVariants", () => {
|
||||
const productService = new ProductService({
|
||||
productModel: ProductModelMock,
|
||||
productVariantService: ProductVariantServiceMock,
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("reorders variants", async () => {
|
||||
await productService.reorderVariants(IdMap.getId("productWithVariants"), [
|
||||
"3",
|
||||
"4",
|
||||
"1",
|
||||
])
|
||||
|
||||
expect(ProductModelMock.updateOne).toBeCalledTimes(1)
|
||||
expect(ProductModelMock.updateOne).toBeCalledWith(
|
||||
{
|
||||
_id: IdMap.getId("productWithVariants"),
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
variants: ["3", "4", "1"],
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("throws if a variant id is not in the products variants", async () => {
|
||||
try {
|
||||
await productService.reorderVariants(
|
||||
IdMap.getId("productWithVariants"),
|
||||
["1", "2", "3"]
|
||||
)
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual(`Product has no variant with id: 2`)
|
||||
}
|
||||
})
|
||||
|
||||
it("throws if order length and product variant lengths differ", async () => {
|
||||
try {
|
||||
await productService.reorderVariants(
|
||||
IdMap.getId("productWithVariants"),
|
||||
["1", "2", "3", "4"]
|
||||
)
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual(
|
||||
`Product variants and new variant order differ in length. To delete or add variants use removeVariant or addVariant`
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import bcrypt from "bcrypt"
|
||||
import { BaseService } from "../interfaces"
|
||||
|
||||
/**
|
||||
* Can authenticate a user based on email password combination
|
||||
* @implements BaseService
|
||||
*/
|
||||
class AuthService extends BaseService {
|
||||
/** @param { userModel: (UserModel) } */
|
||||
constructor({ userModel }) {
|
||||
super()
|
||||
/** @private @const {UserModel} */
|
||||
this.userModel_ = userModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticates a given user based on an email, password combination. Uses
|
||||
* bcrypt to match password with hashed value.
|
||||
* @param {string} email - the email of the user
|
||||
* @param {string} password - the password of the user
|
||||
* @return {{ success: (bool), user: (object | undefined) }}
|
||||
* success: whether authentication succeeded
|
||||
* user: the user document if authentication succeded
|
||||
*/
|
||||
async authenticate(email, password) {
|
||||
const user = await this.userModel_.findOne({ email })
|
||||
const passwordsMatch = await bcrypt.compare(password, user.passwordHash)
|
||||
|
||||
if (passwordsMatch) {
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AuthService
|
||||
@@ -0,0 +1,81 @@
|
||||
import Bull from "bull"
|
||||
import config from "../config"
|
||||
/**
|
||||
* Can keep track of multiple subscribers to different events and run the
|
||||
* subscribers when events happen. Events will run asynchronously.
|
||||
* @interface
|
||||
*/
|
||||
class EventBusService {
|
||||
constructor({ logger }) {
|
||||
/** @private {logger} */
|
||||
this.logger_ = logger
|
||||
|
||||
/** @private {object} */
|
||||
this.observers_ = {}
|
||||
|
||||
/** @private {BullQueue} */
|
||||
this.queue_ = new Bull(`${this.constructor.name}:queue`, config.redisURI)
|
||||
|
||||
// Register our worker to handle emit calls
|
||||
this.queue_.process(this.worker_)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a function to a list of event subscribers.
|
||||
* @param {string} event - the event that the subscriber will listen for.
|
||||
* @param {func} subscriber - the function to be called when a certain event
|
||||
* happens. Subscribers must return a Promise.
|
||||
*/
|
||||
subscribe(event, subscriber) {
|
||||
if (typeof subscriber !== "function") {
|
||||
throw new Error("Subscriber must be a function")
|
||||
}
|
||||
|
||||
if (this.observers_[event]) {
|
||||
this.observers_[event].push(subscriber)
|
||||
} else {
|
||||
this.observers_[event] = [subscriber]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls all subscribers when an event occurs.
|
||||
* @param {string} eventName - the name of the event to be process.
|
||||
* @param {?any} data - the data to send to the subscriber.
|
||||
* @return {BullJob} - the job from our queue
|
||||
*/
|
||||
emit(eventName, data) {
|
||||
return this.queue_.add({
|
||||
eventName,
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming jobs.
|
||||
* @param job {{ eventName: (string), data: (any) }}
|
||||
* eventName - the name of the event to process
|
||||
* data - data to send to the subscriber
|
||||
*
|
||||
* @returns {Promise} resolves to the results of the subscriber calls.
|
||||
*/
|
||||
async worker_({ eventName, data }) {
|
||||
const observers = this.observers_[eventName] || []
|
||||
this.logger_.info(
|
||||
`Processing ${eventName} which has ${observers.length} subscribers`
|
||||
)
|
||||
|
||||
return Promise.all(
|
||||
observers.map(subscriber => {
|
||||
return subscriber(data).catch(err => {
|
||||
this.logger_.warn(
|
||||
`An error occured while processing ${eventName}: ${err}`
|
||||
)
|
||||
return err
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default EventBusService
|
||||
@@ -0,0 +1,50 @@
|
||||
import { BaseService } from "../interfaces"
|
||||
|
||||
/**
|
||||
* Provides layer to manipulate products.
|
||||
* @implements BaseService
|
||||
*/
|
||||
class ProductVariantService extends BaseService {
|
||||
/** @param { productModel: (ProductModel) } */
|
||||
constructor({ productVariantModel, eventBusService }) {
|
||||
super()
|
||||
|
||||
/** @private @const {ProductVariantModel} */
|
||||
this.productVariantModel_ = productVariantModel
|
||||
|
||||
/** @private @const {EventBus} */
|
||||
this.eventBus_ = eventBusService
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an unpublished product.
|
||||
* @param {object} product - the product to create
|
||||
* @return {Promise} resolves to the creation result.
|
||||
*/
|
||||
createDraft(productVariant) {
|
||||
return this.productVariantModel_.create({
|
||||
...productVariant,
|
||||
published: false,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an publishes product.
|
||||
* @param {string} productId - ID of the product to publish.
|
||||
* @return {Promise} resolves to the creation result.
|
||||
*/
|
||||
publish(variantId) {
|
||||
return this.productVariantModel_.updateOne(
|
||||
{ _id: variantId },
|
||||
{ $set: { published: true } }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
addOptionValue(variantId, optionId, optionValue) {
|
||||
}
|
||||
}
|
||||
|
||||
export default ProductVariantService
|
||||
@@ -0,0 +1,572 @@
|
||||
import mongoose from "mongoose"
|
||||
import _ from "lodash"
|
||||
import { BaseService } from "../interfaces"
|
||||
import MedusaError, { MedusaErrorTypes } from "../utils/errors"
|
||||
import validator from "../utils/validator"
|
||||
|
||||
/**
|
||||
* Provides layer to manipulate products.
|
||||
* @implements BaseService
|
||||
*/
|
||||
class ProductService extends BaseService {
|
||||
/** @param { productModel: (ProductModel) } */
|
||||
constructor({ productModel, eventBusService, productVariantService }) {
|
||||
super()
|
||||
|
||||
/** @private @const {ProductModel} */
|
||||
this.productModel_ = productModel
|
||||
|
||||
/** @private @const {EventBus} */
|
||||
this.eventBus_ = eventBusService
|
||||
|
||||
/** @private @const {ProductVariantService} */
|
||||
this.productVariantService_ = productVariantService
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to validate product ids. Throws an error if the cast fails
|
||||
* @param {string} rawId - the raw product 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(
|
||||
MedusaErrorTypes.INVALID_ARGUMENT,
|
||||
"The productId could not be casted to an ObjectId"
|
||||
)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
list(query) {
|
||||
const selector = {}
|
||||
return this.productModel_.find(selector)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a product by id.
|
||||
* @param {string} productId - the id of the product to get.
|
||||
* @return {Promise<Product>} the product document.
|
||||
*/
|
||||
retrieve(productId) {
|
||||
const validatedId = this.validateId_(productId)
|
||||
return this.productModel_.findOne({ _id: validatedId }).catch(err => {
|
||||
throw new MedusaError(MedusaErrorTypes.DB_ERROR, err.message)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an unpublished product.
|
||||
* @param {object} product - the product to create
|
||||
* @return {Promise} resolves to the creation result.
|
||||
*/
|
||||
createDraft(product) {
|
||||
return this.productModel_
|
||||
.create({
|
||||
...product,
|
||||
published: false,
|
||||
})
|
||||
.catch(err => {
|
||||
throw new MedusaError(MedusaErrorTypes.DB_ERROR, err.message)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an publishes product.
|
||||
* @param {string} productId - ID of the product to publish.
|
||||
* @return {Promise} resolves to the creation result.
|
||||
*/
|
||||
publish(productId) {
|
||||
return this.productModel_
|
||||
.updateOne({ _id: productId }, { $set: { published: true } })
|
||||
.catch(err => {
|
||||
throw new MedusaError(MedusaErrorTypes.DB_ERROR, err.message)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a product. Metadata updates and product variant updates should
|
||||
* use dedicated methods, e.g. `setMetadata`, `addVariant`, etc. The function
|
||||
* will throw errors if metadata or product variant updates are attempted.
|
||||
* @param {string} productId - the id of the product. Must be a string that
|
||||
* can be casted to an ObjectId
|
||||
* @param {object} update - an object with the update values.
|
||||
* @return {Promise} resolves to the update result.
|
||||
*/
|
||||
update(productId, update) {
|
||||
const validatedId = this.validateId_(productId)
|
||||
|
||||
if (update.metadata) {
|
||||
throw new MedusaError(
|
||||
MedusaErrorTypes.INVALID_DATA,
|
||||
"Use setMetadata to update metadata fields"
|
||||
)
|
||||
}
|
||||
|
||||
if (update.variants) {
|
||||
throw new MedusaError(
|
||||
MedusaErrorTypes.INVALID_DATA,
|
||||
"Use addVariant, reorderVariants, removeVariant to update Product Variants"
|
||||
)
|
||||
}
|
||||
|
||||
return this.productModel_
|
||||
.updateOne(
|
||||
{ _id: validatedId },
|
||||
{ $set: update },
|
||||
{ runValidators: true }
|
||||
)
|
||||
.catch(err => {
|
||||
throw new MedusaError(MedusaErrorTypes.DB_ERROR, err.message)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a product from a given product id. The product's associated
|
||||
* variants will also be deleted.
|
||||
* @param {string} productId - the id of the product to delete. Must be
|
||||
* castable as an ObjectId
|
||||
* @return {Promise} the result of the delete operation.
|
||||
*/
|
||||
async delete(productId) {
|
||||
const product = await this.retrieve(productId)
|
||||
// Delete is idempotent, but we return a promise to allow then-chaining
|
||||
if (!product) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
product.variants.map(id => this.productVariantService_.delete(id))
|
||||
).catch(err => {
|
||||
throw err
|
||||
})
|
||||
|
||||
return this.productModel_.deleteOne({ _id: product._id }).catch(err => {
|
||||
throw new MedusaError(MedusaErrorTypes.DB_ERROR, err.message)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a product variant to a product. Will check that the given product
|
||||
* variant has correct option values.
|
||||
* @param {string} productId - the product the variant will be added to
|
||||
* @param {string} variantId - the variant to add to the product
|
||||
* @return {Promise} the result of update
|
||||
*/
|
||||
async addVariant(productId, variantId) {
|
||||
const product = await this.retrieve(productId)
|
||||
if (!product) {
|
||||
throw new MedusaError(
|
||||
MedusaErrorTypes.NOT_FOUND,
|
||||
`Product with ${product._id} was not found`
|
||||
)
|
||||
}
|
||||
|
||||
const variant = await this.productVariantService_.retrieve(variantId)
|
||||
if (!variant) {
|
||||
throw new MedusaError(
|
||||
MedusaErrorTypes.NOT_FOUND,
|
||||
`Variant with ${variantId} was not found`
|
||||
)
|
||||
}
|
||||
|
||||
if (product.options.length !== variant.options.length) {
|
||||
throw new MedusaError(
|
||||
MedusaErrorTypes.INVALID_DATA,
|
||||
`Product options length does not match variant options length. Product has ${product.options.length} and variant has ${variant.options.length}.`
|
||||
)
|
||||
}
|
||||
|
||||
product.options.forEach(option => {
|
||||
if (!variant.options.find(vo => vo.option_id === option._id)) {
|
||||
throw new MedusaError(
|
||||
MedusaErrorTypes.INVALID_DATA,
|
||||
`Variant options do not contain value for ${option.title}`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
let combinationExists = false
|
||||
if (product.variants) {
|
||||
// Check if option value of the variant to add already exists. Go through
|
||||
// each existing variant. Check if this variants option values are
|
||||
// identical to the option values of the variant being added.
|
||||
combinationExists = product.variants.some(async vId => {
|
||||
const v = await this.productVariantService_.retrieve(vId)
|
||||
return v.options.reduce((acc, option, index) => {
|
||||
return acc && option.value === variant.options[index].value
|
||||
}, true)
|
||||
})
|
||||
}
|
||||
|
||||
if (combinationExists) {
|
||||
throw new MedusaError(
|
||||
MedusaErrorTypes.INVALID_DATA,
|
||||
`Variant with provided options already exists`
|
||||
)
|
||||
}
|
||||
|
||||
return this.productModel_.updateOne(
|
||||
{ _id: product._id },
|
||||
{ $push: { variants: variantId } }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an option to a product. Options can, for example, be "Size", "Color",
|
||||
* etc. Will update all the products variants with a dummy value for the newly
|
||||
* created option. The same option cannot be added more than once.
|
||||
* @param {string} productId - the product to apply the new option to
|
||||
* @param {string} optionTitle - the display title of the option, e.g. "Size"
|
||||
* @return {Promise} the result of the model update operation
|
||||
*/
|
||||
async addOption(productId, optionTitle) {
|
||||
const product = await this.retrieve(productId)
|
||||
if (!product) {
|
||||
throw new MedusaError(
|
||||
MedusaErrorTypes.NOT_FOUND,
|
||||
`Product with ${product._id} was not found`
|
||||
)
|
||||
}
|
||||
|
||||
// Make sure that option doesn't already exist
|
||||
if (product.options.find(o => o.title === optionTitle)) {
|
||||
throw new MedusaError(
|
||||
MedusaErrorTypes.INVALID_DATA,
|
||||
`An option with the title: ${optionTitle} already exists`
|
||||
)
|
||||
}
|
||||
|
||||
const optionId = mongoose.Types.ObjectId()
|
||||
|
||||
// All product variants must have at least a dummy value for the new option
|
||||
if (product.variants) {
|
||||
await Promise.all(
|
||||
product.variants.map(async variantId =>
|
||||
this.productVariantService_.addOptionValue(
|
||||
variantId,
|
||||
optionId,
|
||||
"Default Value"
|
||||
)
|
||||
)
|
||||
).catch(err => {
|
||||
// If any of the variants failed to add the new option value we clean up
|
||||
return Promise.all(
|
||||
product.variants.map(async variantId =>
|
||||
this.productVariantService_.deleteOptionValue(variantId, optionId)
|
||||
)
|
||||
).then(() => {
|
||||
throw err
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Everything went well add the product option
|
||||
return this.productModel_
|
||||
.updateOne(
|
||||
{ _id: productId },
|
||||
{
|
||||
$push: {
|
||||
options: {
|
||||
_id: optionId,
|
||||
title: optionTitle,
|
||||
product_id: productId,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
.catch(err => {
|
||||
// If we failed to update the product clean up its variants
|
||||
return Promise.all(
|
||||
product.variants.map(async variantId =>
|
||||
this.productVariantService_.deleteOptionValue(variantId, optionId)
|
||||
)
|
||||
).then(() => {
|
||||
throw err
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async reorderVariants(productId, variantOrder) {
|
||||
const product = await this.retrieve(productId)
|
||||
if (!product) {
|
||||
throw new MedusaError(
|
||||
MedusaErrorTypes.NOT_FOUND,
|
||||
`Product with ${product._id} was not found`
|
||||
)
|
||||
}
|
||||
|
||||
if (product.variants.length !== variantOrder.length) {
|
||||
throw new MedusaError(
|
||||
MedusaErrorTypes.INVALID_DATA,
|
||||
`Product variants and new variant order differ in length. To delete or add variants use removeVariant or addVariant`
|
||||
)
|
||||
}
|
||||
|
||||
const newOrder = variantOrder.map(vId => {
|
||||
const variant = product.variants.find(id => id === vId)
|
||||
if (!variant) {
|
||||
throw new MedusaError(
|
||||
MedusaErrorTypes.INVALID_DATA,
|
||||
`Product has no variant with id: ${vId}`
|
||||
)
|
||||
}
|
||||
|
||||
return variant
|
||||
})
|
||||
|
||||
return this.productModel_.updateOne(
|
||||
{
|
||||
_id: productId,
|
||||
},
|
||||
{
|
||||
$set: { variants: newOrder },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the order of a product's options. Will throw if the length of
|
||||
* optionOrder and the length of the product's options are different. Will
|
||||
* throw optionOrder contains an id not associated with the product.
|
||||
* @param {string} productId - the product whose options we are reordering
|
||||
* @param {[ObjectId]} optionId - the ids of the product's options in the
|
||||
* new order
|
||||
* @return {Promise} the result of the update operation
|
||||
*/
|
||||
async reorderOptions(productId, optionOrder) {
|
||||
const product = await this.retrieve(productId)
|
||||
if (!product) {
|
||||
throw new MedusaError(
|
||||
MedusaErrorTypes.NOT_FOUND,
|
||||
`Product with ${product._id} was not found`
|
||||
)
|
||||
}
|
||||
|
||||
if (product.options.length !== optionOrder.length) {
|
||||
throw new MedusaError(
|
||||
MedusaErrorTypes.INVALID_DATA,
|
||||
`Product options and new options order differ in length. To delete or add options use removeOption or addOption`
|
||||
)
|
||||
}
|
||||
|
||||
const newOrder = optionOrder.map(oId => {
|
||||
const option = product.options.find(o => o._id === oId)
|
||||
if (!option) {
|
||||
throw new MedusaError(
|
||||
MedusaErrorTypes.INVALID_DATA,
|
||||
`Product has no option with id: ${oId}`
|
||||
)
|
||||
}
|
||||
|
||||
return option
|
||||
})
|
||||
|
||||
return this.productModel_.updateOne(
|
||||
{
|
||||
_id: productId,
|
||||
},
|
||||
{
|
||||
$set: { options: newOrder },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a product's option. Throws if the call tries to update an option
|
||||
* not associated with the product. Throws if the updated title already exists.
|
||||
* @param {string} productId - the product whose option we are updating
|
||||
* @param {string} optionId - the id of the option we are updating
|
||||
* @param {object} data - the data to update the option with
|
||||
* @return {Promise} the result of the update operation
|
||||
*/
|
||||
async updateOption(productId, optionId, data) {
|
||||
const product = await this.retrieve(productId)
|
||||
if (!product) {
|
||||
throw new MedusaError(
|
||||
MedusaErrorTypes.NOT_FOUND,
|
||||
`Product with ${product._id} was not found`
|
||||
)
|
||||
}
|
||||
|
||||
const option = product.options.find(o => o._id === optionId)
|
||||
if (!option) {
|
||||
throw new MedusaError(
|
||||
MedusaErrorTypes.NOT_FOUND,
|
||||
`Product has no option with id: ${optionId}`
|
||||
)
|
||||
}
|
||||
|
||||
const { title } = data
|
||||
const titleExists = product.options.some(
|
||||
o => o.title.toUpperCase() === title.toUpperCase()
|
||||
)
|
||||
|
||||
if (titleExists) {
|
||||
throw new MedusaError(
|
||||
MedusaErrorTypes.NOT_FOUND,
|
||||
`An option with title ${title} already exists`
|
||||
)
|
||||
}
|
||||
|
||||
const update = {}
|
||||
update["options.$.title"] = title
|
||||
|
||||
return this.productModel_.updateOne(
|
||||
{
|
||||
_id: productId,
|
||||
"options._id": optionId,
|
||||
},
|
||||
{
|
||||
$set: update,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an option from a product.
|
||||
* @param {string} productId - the product to delete an option from
|
||||
* @param {string} optionId - the option to delete
|
||||
* @return {Promise} return the result of update
|
||||
*/
|
||||
async deleteOption(productId, optionId) {
|
||||
const product = await this.retrieve(productId)
|
||||
if (!product) {
|
||||
throw new MedusaError(
|
||||
MedusaErrorTypes.NOT_FOUND,
|
||||
`Product with ${product._id} was not found`
|
||||
)
|
||||
}
|
||||
|
||||
if (!product.options.find(o => o._id === optionId)) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
if (product.variants) {
|
||||
// For the option we want to delete, make sure that all variants have the
|
||||
// same option values. The reason for doing is, that we want to avoid
|
||||
// duplicate variants. For example, if we have a product with size and
|
||||
// color options, that has four variants: (black, 1), (black, 2),
|
||||
// (blue, 1), (blue, 2) and we delete the size option from the product,
|
||||
// we would end up with four variants: (black), (black), (blue), (blue).
|
||||
// We now have two duplicate variants. To ensure that this does not
|
||||
// happen, we will force the user to select which variants to keep.
|
||||
const firstVariant = await this.productVariantService_.retrieve(
|
||||
product.variants[0]
|
||||
)
|
||||
const valueToMatch = firstVariant.options.find(
|
||||
o => o.option_id === optionId
|
||||
).value
|
||||
|
||||
const equalsFirst = await Promise.all(
|
||||
product.variants.map(async vId => {
|
||||
const v = await this.productVariantService_.retrieve(vId)
|
||||
const option = v.options.find(o => o.option_id === optionId)
|
||||
return option.value === valueToMatch
|
||||
})
|
||||
)
|
||||
|
||||
if (!equalsFirst.every(v => v)) {
|
||||
throw new MedusaError(
|
||||
MedusaErrorTypes.INVALID_DATA,
|
||||
`To delete an option, first delete all variants, such that when option is deleted, no duplicate variants will exist. For more info check MEDUSA.com`
|
||||
)
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
product.variants.map(async variantId =>
|
||||
this.productVariantService_.deleteOptionValue(variantId, optionId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return this.productModel_.updateOne(
|
||||
{ _id: productId },
|
||||
{
|
||||
$pull: {
|
||||
options: {
|
||||
_id: optionId,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes variant from product
|
||||
* @param {string} productId - the product to remove the variant from
|
||||
* @param {string} variantId - the variant to remove from product
|
||||
* @return {Promise} the result of update
|
||||
*/
|
||||
async removeVariant(productId, variantId) {
|
||||
const product = await this.retrieve(productId)
|
||||
if (!product) {
|
||||
throw new MedusaError(
|
||||
MedusaErrorTypes.NOT_FOUND,
|
||||
`Product with ${product._id} was not found`
|
||||
)
|
||||
}
|
||||
|
||||
return this.productModel_.updateOne(
|
||||
{ _id: productId },
|
||||
{
|
||||
$pull: {
|
||||
variants: variantId,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorates a product with product variants.
|
||||
* @param {Product} product - the product to decorate.
|
||||
* @param {string[]} fields - the fields to include.
|
||||
* @param {string[]} expandFields - fields to expand.
|
||||
* @return {Product} return the decorated product.
|
||||
*/
|
||||
async decorate(product, fields, expandFields = []) {
|
||||
const requiredFields = ["_id", "metadata"]
|
||||
const decorated = _.pick(product, fields.concat(requiredFields))
|
||||
if (expandFields.includes("variants")) {
|
||||
decorated.variants = await Promise.all(
|
||||
product.variants.map(variantId =>
|
||||
this.productVariantService_.retrieve(variantId)
|
||||
)
|
||||
)
|
||||
}
|
||||
return decorated
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets metadata for a product
|
||||
* @param {string} productId - the product to decorate.
|
||||
* @param {string} key - key for metadata field
|
||||
* @param {string} value - value for metadata field.
|
||||
* @return {Promise} resolves to the updated result.
|
||||
*/
|
||||
setMetadata(productId, key, value) {
|
||||
const validatedId = this.validateId_(productId)
|
||||
|
||||
if (typeof key !== "string") {
|
||||
throw new MedusaError(
|
||||
MedusaErrorTypes.INVALID_ARGUMENT,
|
||||
"Key type is invalid. Metadata keys must be strings"
|
||||
)
|
||||
}
|
||||
|
||||
const keyPath = `metadata.${key}`
|
||||
return this.productModel_
|
||||
.updateOne({ _id: validatedId }, { $set: { [keyPath]: value } })
|
||||
.catch(err => {
|
||||
throw new MedusaError(MedusaErrorTypes.DB_ERROR, err.message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default ProductService
|
||||
@@ -0,0 +1,403 @@
|
||||
export const countries = [
|
||||
{ alpha2: "AF", name: "Afghanistan", alpha3: "AFG", numeric: "004" },
|
||||
{ alpha2: "AL", name: "Albania", alpha3: "ALB", numeric: "008" },
|
||||
{ alpha2: "DZ", name: "Algeria", alpha3: "DZA", numeric: "012" },
|
||||
{ alpha2: "AS", name: "American Samoa", alpha3: "ASM", numeric: "016" },
|
||||
{ alpha2: "AD", name: "Andorra", alpha3: "AND", numeric: "020" },
|
||||
{ alpha2: "AO", name: "Angola", alpha3: "AGO", numeric: "024" },
|
||||
{ alpha2: "AI", name: "Anguilla", alpha3: "AIA", numeric: "660" },
|
||||
{ alpha2: "AQ", name: "Antarctica", alpha3: "ATA", numeric: "010" },
|
||||
{ alpha2: "AG", name: "Antigua and Barbuda", alpha3: "ATG", numeric: "028" },
|
||||
{ alpha2: "AR", name: "Argentina", alpha3: "ARG", numeric: "032" },
|
||||
{ alpha2: "AM", name: "Armenia", alpha3: "ARM", numeric: "051" },
|
||||
{ alpha2: "AW", name: "Aruba", alpha3: "ABW", numeric: "533" },
|
||||
{ alpha2: "AU", name: "Australia", alpha3: "AUS", numeric: "036" },
|
||||
{ alpha2: "AT", name: "Austria", alpha3: "AUT", numeric: "040" },
|
||||
{ alpha2: "AZ", name: "Azerbaijan", alpha3: "AZE", numeric: "031" },
|
||||
{ alpha2: "BS", name: "Bahamas", alpha3: "BHS", numeric: "044" },
|
||||
{ alpha2: "BH", name: "Bahrain", alpha3: "BHR", numeric: "048" },
|
||||
{ alpha2: "BD", name: "Bangladesh", alpha3: "BGD", numeric: "050" },
|
||||
{ alpha2: "BB", name: "Barbados", alpha3: "BRB", numeric: "052" },
|
||||
{ alpha2: "BY", name: "Belarus", alpha3: "BLR", numeric: "112" },
|
||||
{ alpha2: "BE", name: "Belgium", alpha3: "BEL", numeric: "056" },
|
||||
{ alpha2: "BZ", name: "Belize", alpha3: "BLZ", numeric: "084" },
|
||||
{ alpha2: "BJ", name: "Benin", alpha3: "BEN", numeric: "204" },
|
||||
{ alpha2: "BM", name: "Bermuda", alpha3: "BMU", numeric: "060" },
|
||||
{ alpha2: "BT", name: "Bhutan", alpha3: "BTN", numeric: "064" },
|
||||
{ alpha2: "BO", name: "Bolivia", alpha3: "BOL", numeric: "068" },
|
||||
{
|
||||
alpha2: "BQ",
|
||||
name: "Bonaire, Sint Eustatius and Saba",
|
||||
alpha3: "BES",
|
||||
numeric: "535",
|
||||
},
|
||||
{
|
||||
alpha2: "BA",
|
||||
name: "Bosnia and Herzegovina",
|
||||
alpha3: "BIH",
|
||||
numeric: "070",
|
||||
},
|
||||
{ alpha2: "BW", name: "Botswana", alpha3: "BWA", numeric: "072" },
|
||||
{ alpha2: "BV", name: "Bouvet Island", alpha3: "BVD", numeric: "074" },
|
||||
{ alpha2: "BR", name: "Brazil", alpha3: "BRA", numeric: "076" },
|
||||
{
|
||||
alpha2: "IO",
|
||||
name: "British Indian Ocean Territory",
|
||||
alpha3: "IOT",
|
||||
numeric: "086",
|
||||
},
|
||||
{ alpha2: "BN", name: "Brunei Darussalam", alpha3: "BRN", numeric: "096" },
|
||||
{ alpha2: "BG", name: "Bulgaria", alpha3: "BGR", numeric: "100" },
|
||||
{ alpha2: "BF", name: "Burkina Faso", alpha3: "BFA", numeric: "854" },
|
||||
{ alpha2: "BI", name: "Burundi", alpha3: "BDI", numeric: "108" },
|
||||
{ alpha2: "KH", name: "Cambodia", alpha3: "KHM", numeric: "116" },
|
||||
{ alpha2: "CM", name: "Cameroon", alpha3: "CMR", numeric: "120" },
|
||||
{ alpha2: "CA", name: "Canada", alpha3: "CAN", numeric: "124" },
|
||||
{ alpha2: "CV", name: "Cape Verde", alpha3: "CPV", numeric: "132" },
|
||||
{ alpha2: "KY", name: "Cayman Islands", alpha3: "CYM", numeric: "136" },
|
||||
{
|
||||
alpha2: "CF",
|
||||
name: "Central African Republic",
|
||||
alpha3: "CAF",
|
||||
numeric: "140",
|
||||
},
|
||||
{ alpha2: "TD", name: "Chad", alpha3: "TCD", numeric: "148" },
|
||||
{ alpha2: "CL", name: "Chile", alpha3: "CHL", numeric: "152" },
|
||||
{ alpha2: "CN", name: "China", alpha3: "CHN", numeric: "156" },
|
||||
{ alpha2: "CX", name: "Christmas Island", alpha3: "CXR", numeric: "162" },
|
||||
{
|
||||
alpha2: "CC",
|
||||
name: "Cocos (Keeling) Islands",
|
||||
alpha3: "CCK",
|
||||
numeric: "166",
|
||||
},
|
||||
{ alpha2: "CO", name: "Colombia", alpha3: "COL", numeric: "170" },
|
||||
{ alpha2: "KM", name: "Comoros", alpha3: "COM", numeric: "174" },
|
||||
{ alpha2: "CG", name: "Congo", alpha3: "COG", numeric: "178" },
|
||||
{
|
||||
alpha2: "CD",
|
||||
name: "Congo, the Democratic Republic of the",
|
||||
alpha3: "COD",
|
||||
numeric: "180",
|
||||
},
|
||||
{ alpha2: "CK", name: "Cook Islands", alpha3: "COK", numeric: "184" },
|
||||
{ alpha2: "CR", name: "Costa Rica", alpha3: "CRI", numeric: "188" },
|
||||
{ alpha2: "CI", name: "Cote D'Ivoire", alpha3: "CIV", numeric: "384" },
|
||||
{ alpha2: "HR", name: "Croatia", alpha3: "HRV", numeric: "191" },
|
||||
{ alpha2: "CU", name: "Cuba", alpha3: "CUB", numeric: "192" },
|
||||
{ alpha2: "CW", name: "Curaçao", alpha3: "CUW", numeric: "531" },
|
||||
{ alpha2: "CY", name: "Cyprus", alpha3: "CYP", numeric: "196" },
|
||||
{ alpha2: "CZ", name: "Czech Republic", alpha3: "CZE", numeric: "203" },
|
||||
{ alpha2: "DK", name: "Denmark", alpha3: "DNK", numeric: "208" },
|
||||
{ alpha2: "DJ", name: "Djibouti", alpha3: "DJI", numeric: "262" },
|
||||
{ alpha2: "DM", name: "Dominica", alpha3: "DMA", numeric: "212" },
|
||||
{ alpha2: "DO", name: "Dominican Republic", alpha3: "DOM", numeric: "214" },
|
||||
{ alpha2: "EC", name: "Ecuador", alpha3: "ECU", numeric: "218" },
|
||||
{ alpha2: "EG", name: "Egypt", alpha3: "EGY", numeric: "818" },
|
||||
{ alpha2: "SV", name: "El Salvador", alpha3: "SLV", numeric: "222" },
|
||||
{ alpha2: "GQ", name: "Equatorial Guinea", alpha3: "GNQ", numeric: "226" },
|
||||
{ alpha2: "ER", name: "Eritrea", alpha3: "ERI", numeric: "232" },
|
||||
{ alpha2: "EE", name: "Estonia", alpha3: "EST", numeric: "233" },
|
||||
{ alpha2: "ET", name: "Ethiopia", alpha3: "ETH", numeric: "231" },
|
||||
{
|
||||
alpha2: "FK",
|
||||
name: "Falkland Islands (Malvinas)",
|
||||
alpha3: "FLK",
|
||||
numeric: "238",
|
||||
},
|
||||
{ alpha2: "FO", name: "Faroe Islands", alpha3: "FRO", numeric: "234" },
|
||||
{ alpha2: "FJ", name: "Fiji", alpha3: "FJI", numeric: "242" },
|
||||
{ alpha2: "FI", name: "Finland", alpha3: "FIN", numeric: "246" },
|
||||
{ alpha2: "FR", name: "France", alpha3: "FRA", numeric: "250" },
|
||||
{ alpha2: "GF", name: "French Guiana", alpha3: "GUF", numeric: "254" },
|
||||
{ alpha2: "PF", name: "French Polynesia", alpha3: "PYF", numeric: "258" },
|
||||
{
|
||||
alpha2: "TF",
|
||||
name: "French Southern Territories",
|
||||
alpha3: "ATF",
|
||||
numeric: "260",
|
||||
},
|
||||
{ alpha2: "GA", name: "Gabon", alpha3: "GAB", numeric: "266" },
|
||||
{ alpha2: "GM", name: "Gambia", alpha3: "GMB", numeric: "270" },
|
||||
{ alpha2: "GE", name: "Georgia", alpha3: "GEO", numeric: "268" },
|
||||
{ alpha2: "DE", name: "Germany", alpha3: "DEU", numeric: "276" },
|
||||
{ alpha2: "GH", name: "Ghana", alpha3: "GHA", numeric: "288" },
|
||||
{ alpha2: "GI", name: "Gibraltar", alpha3: "GIB", numeric: "292" },
|
||||
{ alpha2: "GR", name: "Greece", alpha3: "GRC", numeric: "300" },
|
||||
{ alpha2: "GL", name: "Greenland", alpha3: "GRL", numeric: "304" },
|
||||
{ alpha2: "GD", name: "Grenada", alpha3: "GRD", numeric: "308" },
|
||||
{ alpha2: "GP", name: "Guadeloupe", alpha3: "GLP", numeric: "312" },
|
||||
{ alpha2: "GU", name: "Guam", alpha3: "GUM", numeric: "316" },
|
||||
{ alpha2: "GT", name: "Guatemala", alpha3: "GTM", numeric: "320" },
|
||||
{ alpha2: "GG", name: "Guernsey", alpha3: "GGY", numeric: "831" },
|
||||
{ alpha2: "GN", name: "Guinea", alpha3: "GIN", numeric: "324" },
|
||||
{ alpha2: "GW", name: "Guinea-Bissau", alpha3: "GNB", numeric: "624" },
|
||||
{ alpha2: "GY", name: "Guyana", alpha3: "GUY", numeric: "328" },
|
||||
{ alpha2: "HT", name: "Haiti", alpha3: "HTI", numeric: "332" },
|
||||
{
|
||||
alpha2: "HM",
|
||||
name: "Heard Island And Mcdonald Islands",
|
||||
alpha3: "HMD",
|
||||
numeric: "334",
|
||||
},
|
||||
{
|
||||
alpha2: "VA",
|
||||
name: "Holy See (Vatican City State)",
|
||||
alpha3: "VAT",
|
||||
numeric: "336",
|
||||
},
|
||||
{ alpha2: "HN", name: "Honduras", alpha3: "HND", numeric: "340" },
|
||||
{ alpha2: "HK", name: "Hong Kong", alpha3: "HKG", numeric: "344" },
|
||||
{ alpha2: "HU", name: "Hungary", alpha3: "HUN", numeric: "348" },
|
||||
{ alpha2: "IS", name: "Iceland", alpha3: "ISL", numeric: "352" },
|
||||
{ alpha2: "IN", name: "India", alpha3: "IND", numeric: "356" },
|
||||
{ alpha2: "ID", name: "Indonesia", alpha3: "IDN", numeric: "360" },
|
||||
{
|
||||
alpha2: "IR",
|
||||
name: "Iran, Islamic Republic of",
|
||||
alpha3: "IRN",
|
||||
numeric: "364",
|
||||
},
|
||||
{ alpha2: "IQ", name: "Iraq", alpha3: "IRQ", numeric: "368" },
|
||||
{ alpha2: "IE", name: "Ireland", alpha3: "IRL", numeric: "372" },
|
||||
{ alpha2: "IM", name: "Isle Of Man", alpha3: "IMN", numeric: "833" },
|
||||
{ alpha2: "IL", name: "Israel", alpha3: "ISR", numeric: "376" },
|
||||
{ alpha2: "IT", name: "Italy", alpha3: "ITA", numeric: "380" },
|
||||
{ alpha2: "JM", name: "Jamaica", alpha3: "JAM", numeric: "388" },
|
||||
{ alpha2: "JP", name: "Japan", alpha3: "JPN", numeric: "392" },
|
||||
{ alpha2: "JE", name: "Jersey", alpha3: "JEY", numeric: "832" },
|
||||
{ alpha2: "JO", name: "Jordan", alpha3: "JOR", numeric: "400" },
|
||||
{ alpha2: "KZ", name: "Kazakhstan", alpha3: "KAZ", numeric: "398" },
|
||||
{ alpha2: "KE", name: "Kenya", alpha3: "KEN", numeric: "404" },
|
||||
{ alpha2: "KI", name: "Kiribati", alpha3: "KIR", numeric: "296" },
|
||||
{
|
||||
alpha2: "KP",
|
||||
name: "Korea, Democratic People's Republic of",
|
||||
alpha3: "PRK",
|
||||
numeric: "408",
|
||||
},
|
||||
{ alpha2: "KR", name: "Korea, Republic of", alpha3: "KOR", numeric: "410" },
|
||||
{ alpha2: "XK", name: "Kosovo", alpha3: "XKX", numeric: "900" },
|
||||
{ alpha2: "KW", name: "Kuwait", alpha3: "KWT", numeric: "414" },
|
||||
{ alpha2: "KG", name: "Kyrgyzstan", alpha3: "KGZ", numeric: "417" },
|
||||
{
|
||||
alpha2: "LA",
|
||||
name: "Lao People's Democratic Republic",
|
||||
alpha3: "LAO",
|
||||
numeric: "418",
|
||||
},
|
||||
{ alpha2: "LV", name: "Latvia", alpha3: "LVA", numeric: "428" },
|
||||
{ alpha2: "LB", name: "Lebanon", alpha3: "LBN", numeric: "422" },
|
||||
{ alpha2: "LS", name: "Lesotho", alpha3: "LSO", numeric: "426" },
|
||||
{ alpha2: "LR", name: "Liberia", alpha3: "LBR", numeric: "430" },
|
||||
{
|
||||
alpha2: "LY",
|
||||
name: "Libyan Arab Jamahiriya",
|
||||
alpha3: "LBY",
|
||||
numeric: "434",
|
||||
},
|
||||
{ alpha2: "LI", name: "Liechtenstein", alpha3: "LIE", numeric: "438" },
|
||||
{ alpha2: "LT", name: "Lithuania", alpha3: "LTU", numeric: "440" },
|
||||
{ alpha2: "LU", name: "Luxembourg", alpha3: "LUX", numeric: "442" },
|
||||
{ alpha2: "MO", name: "Macao", alpha3: "MAC", numeric: "446" },
|
||||
{
|
||||
alpha2: "MK",
|
||||
name: "Macedonia, the Former Yugoslav Republic of",
|
||||
alpha3: "MKD",
|
||||
numeric: "807",
|
||||
},
|
||||
{ alpha2: "MG", name: "Madagascar", alpha3: "MDG", numeric: "450" },
|
||||
{ alpha2: "MW", name: "Malawi", alpha3: "MWI", numeric: "454" },
|
||||
{ alpha2: "MY", name: "Malaysia", alpha3: "MYS", numeric: "458" },
|
||||
{ alpha2: "MV", name: "Maldives", alpha3: "MDV", numeric: "462" },
|
||||
{ alpha2: "ML", name: "Mali", alpha3: "MLI", numeric: "466" },
|
||||
{ alpha2: "MT", name: "Malta", alpha3: "MLT", numeric: "470" },
|
||||
{ alpha2: "MH", name: "Marshall Islands", alpha3: "MHL", numeric: "584" },
|
||||
{ alpha2: "MQ", name: "Martinique", alpha3: "MTQ", numeric: "474" },
|
||||
{ alpha2: "MR", name: "Mauritania", alpha3: "MRT", numeric: "478" },
|
||||
{ alpha2: "MU", name: "Mauritius", alpha3: "MUS", numeric: "480" },
|
||||
{ alpha2: "YT", name: "Mayotte", alpha3: "MYT", numeric: "175" },
|
||||
{ alpha2: "MX", name: "Mexico", alpha3: "MEX", numeric: "484" },
|
||||
{
|
||||
alpha2: "FM",
|
||||
name: "Micronesia, Federated States of",
|
||||
alpha3: "FSM",
|
||||
numeric: "583",
|
||||
},
|
||||
{ alpha2: "MD", name: "Moldova, Republic of", alpha3: "MDA", numeric: "498" },
|
||||
{ alpha2: "MC", name: "Monaco", alpha3: "MCO", numeric: "492" },
|
||||
{ alpha2: "MN", name: "Mongolia", alpha3: "MNG", numeric: "496" },
|
||||
{ alpha2: "ME", name: "Montenegro", alpha3: "MNE", numeric: "499" },
|
||||
{ alpha2: "MS", name: "Montserrat", alpha3: "MSR", numeric: "500" },
|
||||
{ alpha2: "MA", name: "Morocco", alpha3: "MAR", numeric: "504" },
|
||||
{ alpha2: "MZ", name: "Mozambique", alpha3: "MOZ", numeric: "508" },
|
||||
{ alpha2: "MM", name: "Myanmar", alpha3: "MMR", numeric: "104" },
|
||||
{ alpha2: "NA", name: "Namibia", alpha3: "NAM", numeric: "516" },
|
||||
{ alpha2: "NR", name: "Nauru", alpha3: "NRU", numeric: "520" },
|
||||
{ alpha2: "NP", name: "Nepal", alpha3: "NPL", numeric: "524" },
|
||||
{ alpha2: "NL", name: "Netherlands", alpha3: "NLD", numeric: "528" },
|
||||
{ alpha2: "NC", name: "New Caledonia", alpha3: "NCL", numeric: "540" },
|
||||
{ alpha2: "NZ", name: "New Zealand", alpha3: "NZL", numeric: "554" },
|
||||
{ alpha2: "NI", name: "Nicaragua", alpha3: "NIC", numeric: "558" },
|
||||
{ alpha2: "NE", name: "Niger", alpha3: "NER", numeric: "562" },
|
||||
{ alpha2: "NG", name: "Nigeria", alpha3: "NGA", numeric: "566" },
|
||||
{ alpha2: "NU", name: "Niue", alpha3: "NIU", numeric: "570" },
|
||||
{ alpha2: "NF", name: "Norfolk Island", alpha3: "NFK", numeric: "574" },
|
||||
{
|
||||
alpha2: "MP",
|
||||
name: "Northern Mariana Islands",
|
||||
alpha3: "MNP",
|
||||
numeric: "580",
|
||||
},
|
||||
{ alpha2: "NO", name: "Norway", alpha3: "NOR", numeric: "578" },
|
||||
{ alpha2: "OM", name: "Oman", alpha3: "OMN", numeric: "512" },
|
||||
{ alpha2: "PK", name: "Pakistan", alpha3: "PAK", numeric: "586" },
|
||||
{ alpha2: "PW", name: "Palau", alpha3: "PLW", numeric: "585" },
|
||||
{
|
||||
alpha2: "PS",
|
||||
name: "Palestinian Territory, Occupied",
|
||||
alpha3: "PSE",
|
||||
numeric: "275",
|
||||
},
|
||||
{ alpha2: "PA", name: "Panama", alpha3: "PAN", numeric: "591" },
|
||||
{ alpha2: "PG", name: "Papua New Guinea", alpha3: "PNG", numeric: "598" },
|
||||
{ alpha2: "PY", name: "Paraguay", alpha3: "PRY", numeric: "600" },
|
||||
{ alpha2: "PE", name: "Peru", alpha3: "PER", numeric: "604" },
|
||||
{ alpha2: "PH", name: "Philippines", alpha3: "PHL", numeric: "608" },
|
||||
{ alpha2: "PN", name: "Pitcairn", alpha3: "PCN", numeric: "612" },
|
||||
{ alpha2: "PL", name: "Poland", alpha3: "POL", numeric: "616" },
|
||||
{ alpha2: "PT", name: "Portugal", alpha3: "PRT", numeric: "620" },
|
||||
{ alpha2: "PR", name: "Puerto Rico", alpha3: "PRI", numeric: "630" },
|
||||
{ alpha2: "QA", name: "Qatar", alpha3: "QAT", numeric: "634" },
|
||||
{ alpha2: "RE", name: "Reunion", alpha3: "REU", numeric: "638" },
|
||||
{ alpha2: "RO", name: "Romania", alpha3: "ROU", numeric: "642" },
|
||||
{ alpha2: "RO", name: "Romania", alpha3: "ROM", numeric: "642" },
|
||||
{ alpha2: "RU", name: "Russian Federation", alpha3: "RUS", numeric: "643" },
|
||||
{ alpha2: "RW", name: "Rwanda", alpha3: "RWA", numeric: "646" },
|
||||
{ alpha2: "BL", name: "Saint Barthélemy", alpha3: "BLM", numeric: "652" },
|
||||
{ alpha2: "SH", name: "Saint Helena", alpha3: "SHN", numeric: "654" },
|
||||
{
|
||||
alpha2: "KN",
|
||||
name: "Saint Kitts and Nevis",
|
||||
alpha3: "KNA",
|
||||
numeric: "659",
|
||||
},
|
||||
{ alpha2: "LC", name: "Saint Lucia", alpha3: "LCA", numeric: "662" },
|
||||
{
|
||||
alpha2: "MF",
|
||||
name: "Saint Martin (French part)",
|
||||
alpha3: "MAF",
|
||||
numeric: "663",
|
||||
},
|
||||
{
|
||||
alpha2: "PM",
|
||||
name: "Saint Pierre and Miquelon",
|
||||
alpha3: "SPM",
|
||||
numeric: "666",
|
||||
},
|
||||
{
|
||||
alpha2: "VC",
|
||||
name: "Saint Vincent and the Grenadines",
|
||||
alpha3: "VCT",
|
||||
numeric: "670",
|
||||
},
|
||||
{ alpha2: "WS", name: "Samoa", alpha3: "WSM", numeric: "882" },
|
||||
{ alpha2: "SM", name: "San Marino", alpha3: "SMR", numeric: "674" },
|
||||
{
|
||||
alpha2: "ST",
|
||||
name: "Sao Tome and Principe",
|
||||
alpha3: "STP",
|
||||
numeric: "678",
|
||||
},
|
||||
{ alpha2: "SA", name: "Saudi Arabia", alpha3: "SAU", numeric: "682" },
|
||||
{ alpha2: "SN", name: "Senegal", alpha3: "SEN", numeric: "686" },
|
||||
{ alpha2: "RS", name: "Serbia", alpha3: "SRB", numeric: "688" },
|
||||
{ alpha2: "SC", name: "Seychelles", alpha3: "SYC", numeric: "690" },
|
||||
{ alpha2: "SL", name: "Sierra Leone", alpha3: "SLE", numeric: "694" },
|
||||
{ alpha2: "SG", name: "Singapore", alpha3: "SGP", numeric: "702" },
|
||||
{ alpha2: "SX", name: "Sint Maarten", alpha3: "SXM", numeric: "534" },
|
||||
{ alpha2: "SK", name: "Slovakia", alpha3: "SVK", numeric: "703" },
|
||||
{ alpha2: "SI", name: "Slovenia", alpha3: "SVN", numeric: "705" },
|
||||
{ alpha2: "SB", name: "Solomon Islands", alpha3: "SLB", numeric: "090" },
|
||||
{ alpha2: "SO", name: "Somalia", alpha3: "SOM", numeric: "706" },
|
||||
{ alpha2: "ZA", name: "South Africa", alpha3: "ZAF", numeric: "710" },
|
||||
{
|
||||
alpha2: "GS",
|
||||
name: "South Georgia and the South Sandwich Islands",
|
||||
alpha3: "SGS",
|
||||
numeric: "239",
|
||||
},
|
||||
{ alpha2: "SS", name: "South Sudan", alpha3: "SSD", numeric: "728" },
|
||||
{ alpha2: "ES", name: "Spain", alpha3: "ESP", numeric: "724" },
|
||||
{ alpha2: "LK", name: "Sri Lanka", alpha3: "LKA", numeric: "144" },
|
||||
{ alpha2: "SD", name: "Sudan", alpha3: "SDN", numeric: "729" },
|
||||
{ alpha2: "SR", name: "Suriname", alpha3: "SUR", numeric: "740" },
|
||||
{
|
||||
alpha2: "SJ",
|
||||
name: "Svalbard and Jan Mayen",
|
||||
alpha3: "SJM",
|
||||
numeric: "744",
|
||||
},
|
||||
{ alpha2: "SZ", name: "Swaziland", alpha3: "SWZ", numeric: "748" },
|
||||
{ alpha2: "SE", name: "Sweden", alpha3: "SWE", numeric: "752" },
|
||||
{ alpha2: "CH", name: "Switzerland", alpha3: "CHE", numeric: "756" },
|
||||
{ alpha2: "SY", name: "Syrian Arab Republic", alpha3: "SYR", numeric: "760" },
|
||||
{
|
||||
alpha2: "TW",
|
||||
name: "Taiwan, Province of China",
|
||||
alpha3: "TWN",
|
||||
numeric: "158",
|
||||
},
|
||||
{ alpha2: "TJ", name: "Tajikistan", alpha3: "TJK", numeric: "762" },
|
||||
{
|
||||
alpha2: "TZ",
|
||||
name: "Tanzania, United Republic of",
|
||||
alpha3: "TZA",
|
||||
numeric: "834",
|
||||
},
|
||||
{ alpha2: "TH", name: "Thailand", alpha3: "THA", numeric: "764" },
|
||||
{ alpha2: "TL", name: "Timor Leste", alpha3: "TLS", numeric: "626" },
|
||||
{ alpha2: "TG", name: "Togo", alpha3: "TGO", numeric: "768" },
|
||||
{ alpha2: "TK", name: "Tokelau", alpha3: "TKL", numeric: "772" },
|
||||
{ alpha2: "TO", name: "Tonga", alpha3: "TON", numeric: "776" },
|
||||
{ alpha2: "TT", name: "Trinidad and Tobago", alpha3: "TTO", numeric: "780" },
|
||||
{ alpha2: "TN", name: "Tunisia", alpha3: "TUN", numeric: "788" },
|
||||
{ alpha2: "TR", name: "Turkey", alpha3: "TUR", numeric: "792" },
|
||||
{ alpha2: "TM", name: "Turkmenistan", alpha3: "TKM", numeric: "795" },
|
||||
{
|
||||
alpha2: "TC",
|
||||
name: "Turks and Caicos Islands",
|
||||
alpha3: "TCA",
|
||||
numeric: "796",
|
||||
},
|
||||
{ alpha2: "TV", name: "Tuvalu", alpha3: "TUV", numeric: "798" },
|
||||
{ alpha2: "UG", name: "Uganda", alpha3: "UGA", numeric: "800" },
|
||||
{ alpha2: "UA", name: "Ukraine", alpha3: "UKR", numeric: "804" },
|
||||
{ alpha2: "AE", name: "United Arab Emirates", alpha3: "ARE", numeric: "784" },
|
||||
{ alpha2: "GB", name: "United Kingdom", alpha3: "GBR", numeric: "826" },
|
||||
{ alpha2: "US", name: "United States", alpha3: "USA", numeric: "840" },
|
||||
{
|
||||
alpha2: "UM",
|
||||
name: "United States Minor Outlying Islands",
|
||||
alpha3: "UMI",
|
||||
numeric: "581",
|
||||
},
|
||||
{ alpha2: "UY", name: "Uruguay", alpha3: "URY", numeric: "858" },
|
||||
{ alpha2: "UZ", name: "Uzbekistan", alpha3: "UZB", numeric: "860" },
|
||||
{ alpha2: "VU", name: "Vanuatu", alpha3: "VUT", numeric: "548" },
|
||||
{ alpha2: "VE", name: "Venezuela", alpha3: "VEN", numeric: "862" },
|
||||
{ alpha2: "VN", name: "Viet Nam", alpha3: "VNM", numeric: "704" },
|
||||
{
|
||||
alpha2: "VG",
|
||||
name: "Virgin Islands, British",
|
||||
alpha3: "VGB",
|
||||
numeric: "092",
|
||||
},
|
||||
{ alpha2: "VI", name: "Virgin Islands, U.S.", alpha3: "VIR", numeric: "850" },
|
||||
{ alpha2: "WF", name: "Wallis and Futuna", alpha3: "WLF", numeric: "876" },
|
||||
{ alpha2: "EH", name: "Western Sahara", alpha3: "ESH", numeric: "732" },
|
||||
{ alpha2: "YE", name: "Yemen", alpha3: "YEM", numeric: "887" },
|
||||
{ alpha2: "ZM", name: "Zambia", alpha3: "ZMB", numeric: "894" },
|
||||
{ alpha2: "ZW", name: "Zimbabwe", alpha3: "ZWE", numeric: "716" },
|
||||
{ alpha2: "AX", name: "Åland Islands", alpha3: "ALA", numeric: "248" },
|
||||
]
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @typedef MedusaErrorType
|
||||
*
|
||||
*/
|
||||
export const MedusaErrorTypes = {
|
||||
/** Errors stemming from the database */
|
||||
DB_ERROR: "database_error",
|
||||
INVALID_ARGUMENT: "invalid_argument",
|
||||
INVALID_DATA: "invalid_data",
|
||||
NOT_FOUND: "not_found"
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardized error to be used across Medusa project.
|
||||
* @extends Error
|
||||
*/
|
||||
class MedusaError extends Error {
|
||||
/**
|
||||
* Creates a standardized error to be used across Medusa project.
|
||||
* @param type {MedusaErrorType} - the type of error.
|
||||
* @param params {Array} - Error params.
|
||||
*/
|
||||
constructor(name, message, ...params) {
|
||||
super(...params)
|
||||
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, MedusaError)
|
||||
}
|
||||
|
||||
this.name = name
|
||||
this.message = message
|
||||
this.date = new Date()
|
||||
}
|
||||
}
|
||||
|
||||
export default MedusaError
|
||||
@@ -0,0 +1,4 @@
|
||||
import Joi from "@hapi/joi"
|
||||
Joi.objectId = require("joi-objectid")(Joi)
|
||||
|
||||
export default Joi
|
||||
Reference in New Issue
Block a user