This commit is contained in:
Sebastian Rindom
2020-01-18 15:08:22 +01:00
parent 2d8dd4b0a9
commit c7cf9b8061
58 changed files with 9873 additions and 2 deletions
+15
View File
@@ -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
}
+46
View File
@@ -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()
+41
View File
@@ -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",
},
}
+17
View File
@@ -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
+2
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
import routes from "../api"
export default async ({ app }) => {
app.use("/", routes())
return app
}
+31
View File
@@ -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
}
+65
View File
@@ -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)),
}
}
+32
View File
@@ -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
+28
View File
@@ -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
},
})
}
+11
View File
@@ -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
}
+45
View File
@@ -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)
}
)
)
}
+201
View File
@@ -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?`
)
}
}
+28
View File
@@ -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
},
})
}
+20
View File
@@ -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
+25
View File
@@ -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: [] },
})
+16
View File
@@ -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`
)
}
})
})
})
+42
View File
@@ -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
+81
View File
@@ -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
+572
View File
@@ -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
+403
View File
@@ -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" },
]
+36
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
import Joi from "@hapi/joi"
Joi.objectId = require("joi-objectid")(Joi)
export default Joi