Creates test request helper for API endpoints

Separates common utils into the medusa-core-utils package.
Sets up a testing environment where mocked models/services/etc. can be placed in
__mocks__ folder within its corresponding directory. The mocks will
automatically be registered in a awilix container only used for testing.
This commit is contained in:
Sebastian Rindom
2020-01-21 15:13:47 +01:00
parent 3d494bc652
commit ed472e9fba
33 changed files with 14053 additions and 76 deletions
@@ -1,5 +1,5 @@
import passport from "passport"
export default () => {
return passport.authenticate("jwt", { session: false })
return passport.authenticate(["jwt", "bearer"], { session: false })
}
@@ -0,0 +1,30 @@
import jwt from "jsonwebtoken"
import { Validator } from "medusa-core-utils"
import config from "../../../../config"
export default async (req, res) => {
const { body } = req
const schema = Validator.object().keys({
email: Validator.string().required(),
password: Validator.string().required(),
})
const { value, error } = schema.validate(body)
if (error) {
throw error
}
const authService = req.scope.resolve("authService")
const result = await authService.authenticate(value.email, value.password)
if (!result.success) {
res.sendStatus(401)
return
}
// Add JWT to cookie
req.session.jwt = jwt.sign({ userId: result.user._id }, config.jwtSecret, {
expiresIn: "24h",
})
res.json(result.user)
}
@@ -0,0 +1,12 @@
import { Router } from "express"
import middlewares from "../../../middlewares"
const route = Router()
export default app => {
app.use("/auth", route)
route.post("/", middlewares.wrap(require("./create-session").default))
return app
}
@@ -1,5 +1,8 @@
import { Router } from "express"
import middlewares from "../../middlewares"
import authRoutes from "./auth"
import productRoutes from "./products"
import productVariantRoutes from "./product-variants"
const route = Router()
@@ -7,12 +10,13 @@ export default app => {
app.use("/admin", route)
// Unauthenticated routes
// route.use("/auth", require("./auth").default)
authRoutes(route)
// Authenticated routes
route.use(middlewares.authenticate())
route.use("/products", require("./products").default)
route.use("/product-variants", require("./product-variants").default)
productRoutes(route)
// productVariantRoutes(route)
return app
}
@@ -0,0 +1,17 @@
import IdMap from "../../../../../helpers/id-map"
import { request } from "../../../../../helpers/test-request"
describe("POST /admin/products", () => {
describe("successful creation", () => {
it("calls mock function", async () => {
const res = await request("POST", "/admin/products", {
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
})
expect(res.status).toEqual(200)
})
})
})
@@ -0,0 +1,11 @@
import { Validator } from "medusa-core-utils"
export default async (req, res) => {
try {
const variantService = req.scope.resolve("productVariantService")
} catch (err) {
console.log(err)
}
res.sendStatus(200)
}
@@ -4,5 +4,11 @@ import middlewares from "../../../middlewares"
const route = Router()
export default app => {
app.use("/products", route)
route.post("/", middlewares.wrap(require("./create-product").default))
// route.get("/:productId", middlewares.wrap(require("./get-product").default))
return app
}
@@ -1,9 +1,9 @@
import validator from "../../../../utils/validator"
import { Validator } from "medusa-core-utils"
export default async (req, res) => {
const { productId } = req.params
const schema = validator.objectId()
const schema = Validator.objectId()
const { value, error } = schema.validate(productId)
if (error) {
@@ -5,6 +5,8 @@ const route = Router()
export default app => {
app.use("/products", route)
route.get("/:productId", middlewares.wrap(require("./get-product").default))
return app
}
+3 -3
View File
@@ -1,9 +1,9 @@
import "core-js/stable"
import "regenerator-runtime/runtime"
import express from "express"
import { MedusaError } from "medusa-core-utils"
import loaders from "./loaders"
import Logger from "./loaders/logger"
import { MedusaErrorTypes } from "./utils/errors"
const PORT = process.env.PORT || 80
@@ -21,10 +21,10 @@ const startServer = async () => {
case "ValidationError":
statusCode = 400
break
case MedusaErrorTypes.INVALID_DATA:
case MedusaError.Types.INVALID_DATA:
statusCode = 400
break
case MedusaErrorTypes.DB_ERROR:
case MedusaError.Types.DB_ERROR:
statusCode = 500
break
default:
+6 -3
View File
@@ -9,7 +9,7 @@ if (!envFound) {
throw new Error("⚠️ Couldn't find .env file ⚠️")
}
export default {
const config = {
/**
* Your favorite port
*/
@@ -21,9 +21,10 @@ export default {
/**
* Your secret sauce
*/
jwtSecret: process.env.JWT_SECRET,
jwtSecret: process.env.NODE_ENV === "test" ? "test" : process.env.JWT_SECRET,
cookieSecret: process.env.COOKIE_SECRET,
cookieSecret:
process.env.NODE_ENV === "test" ? "test" : process.env.COOKIE_SECRET,
/**
* Used by winston logger
@@ -39,3 +40,5 @@ export default {
prefix: "/api",
},
}
export default config
+116
View File
@@ -0,0 +1,116 @@
import { createContainer } from "awilix"
import express from "express"
import supertest from "supertest"
import jwt from "jsonwebtoken"
import sessions from "client-sessions"
import cookie from "cookie"
import servicesLoader from "../loaders/services"
import expressLoader from "../loaders/express"
import apiLoader from "../loaders/api"
import passportLoader from "../loaders/passport"
import config from "../config"
const testApp = express()
const container = createContainer()
servicesLoader({ container })
expressLoader({ app: testApp })
passportLoader({ app: testApp, container })
// Add the registered services to the request scope
testApp.use((req, res, next) => {
req.scope = container.createScope()
next()
})
apiLoader({ app: testApp })
const supertestRequest = supertest(testApp)
let adminSessionOpts = {
cookieName: "adminSession",
secret: "test",
}
export { adminSessionOpts }
let clientSessionOpts = {
cookieName: "clientSession",
secret: "test",
}
export { clientSessionOpts }
export async function request(method, url, opts = {}) {
let { payload, headers } = opts
headers = headers || {}
headers.Cookie = headers.Cookie || ""
if (opts.adminSession) {
if (opts.adminSession.jwt) {
opts.adminSession.jwt = jwt.sign(
opts.adminSession.jwt,
config.jwtSecret,
{
expiresIn: "30m",
}
)
}
headers.Cookie +=
adminSessionOpts.cookieName +
"=" +
sessions.util.encode(adminSessionOpts, opts.adminSession) +
"; "
// console.log(sessions.util.decode(adminSessionOpts, opts.headers.Cookie))
}
if (opts.clientSession) {
headers.Cookie +=
clientSessionOpts.cookieName +
"=" +
sessions.util.encode(clientSessionOpts, opts.clientSession) +
"; "
// console.log(sessions.util.decode(adminSessionOpts, opts.headers.Cookie))
}
let req = supertestRequest[method.toLowerCase()](url)
for (let name in headers) {
req.set(name, headers[name])
}
if (payload && !req.get("content-type")) {
req.set("Content-Type", "application/json")
}
if (!req.get("accept")) {
req.set("Accept", "application/json")
}
req.set("Host", "localhost")
let res
try {
res = await req.send(JSON.stringify(payload))
} catch (e) {
if (e.response) {
res = e.response
} else {
throw e
}
}
//let c =
// res.headers["set-cookie"] && cookie.parse(res.headers["set-cookie"][0])
//res.adminSession =
// c &&
// c[adminSessionOpts.cookieName] &&
// sessions.util.decode(adminSessionOpts, c[adminSessionOpts.cookieName])
// .content
//res.clientSession =
// c &&
// c[clientSessionOpts.cookieName] &&
// sessions.util.decode(clientSessionOpts, c[clientSessionOpts.cookieName])
// .content
return res
}
+13 -5
View File
@@ -1,6 +1,6 @@
import express from "express"
import bodyParser from "body-parser"
import session from "express-session"
import session from "client-sessions"
import cookieParser from "cookie-parser"
import cors from "cors"
import morgan from "morgan"
@@ -11,15 +11,23 @@ export default async ({ app }) => {
app.enable("trust proxy")
app.use(cors())
app.use(morgan("combined"))
app.use(
morgan("combined", {
skip: () => process.env.NODE_ENV === "test",
})
)
app.use(cookieParser())
app.use(bodyParser.json())
app.use(
session({
cookieName: "session",
secret: config.cookieSecret,
resave: false,
saveUninitialized: true,
cookie: { secure: true },
duration: 24 * 60 * 60 * 1000,
activeDuration: 1000 * 60 * 5,
cookie: {
httpOnly: true,
secure: false,
},
})
)
+21 -6
View File
@@ -1,11 +1,13 @@
import passport from "passport"
import { Strategy as LocalStrategy } from "passport-local"
import { Strategy as BearerStrategy } from "passport-http-bearer"
import { Strategy as JWTStrategy } from "passport-jwt"
import config from "../config"
export default async ({ app, container }) => {
const authService = container.cradle.authService
const authService = container.resolve("authService")
// For good old email password authentication
passport.use(
new LocalStrategy(
{
@@ -27,19 +29,32 @@ export default async ({ app, container }) => {
)
)
// After a user has authenticated a JWT will be placed on a cookie, all
// calls will be authenticated based on the JWT
passport.use(
new JWTStrategy(
{
jwtFromRequest: req => req.cookies.jwt,
jwtFromRequest: req => req.session.jwt,
secretOrKey: config.jwtSecret,
},
(jwtPayload, done) => {
if (Date.now() > jwtPayload.expires) {
return done("jwt expired")
}
return done(null, jwtPayload)
}
)
)
// Alternatively use bearer token to authenticate to the admin api
passport.use(
new BearerStrategy((token, done) => {
const auth = authService.authenticateAPIToken(token)
if (auth.success) {
done(null, auth.user)
} else {
done(auth.error)
}
})
)
app.use(passport.initialize())
app.use(passport.session())
}
+14 -2
View File
@@ -4,8 +4,14 @@ import { Lifetime } from "awilix"
* Registers all services in the services directory
*/
export default ({ container }) => {
let loadPath = "src/services/*.js"
if (process.env.NODE_ENV === "test") {
loadPath = "src/services/__mocks__/*.js"
}
// service/auth.js -> authService
container.loadModules(["src/services/*.js"], {
container.loadModules([loadPath], {
resolverOptions: {
lifetime: Lifetime.SINGLETON,
},
@@ -19,7 +25,13 @@ export default ({ container }) => {
const name = parts.join("")
const splat = descriptor.path.split("/")
const namespace = splat[splat.length - 2]
let offset = 2
if (process.env.NODE_ENV === "test") {
offset = 3
}
const namespace = splat[splat.length - offset]
const upperNamespace =
namespace.charAt(0).toUpperCase() + namespace.slice(1, -1)
return name + upperNamespace
@@ -0,0 +1,26 @@
import IdMap from "../../helpers/id-map"
const adminUser = {
_id: IdMap.getId("admin_user"),
password: "1235",
name: "hi",
}
const mock = jest.fn().mockImplementation(() => {
return {
authenticate: jest.fn().mockImplementation((email, password) => {
return Promise.resolve({
success: true,
user: adminUser,
})
}),
authenticateAPIToken: jest.fn().mockImplementation(token => {
return Promise.resolve({
success: true,
user: adminUser,
})
}),
}
})
export default mock
@@ -0,0 +1,128 @@
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,
}
const mock = jest.fn().mockImplementation(() => {
return {
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({})
}),
}
})
export default mock
+27
View File
@@ -13,6 +13,33 @@ class AuthService extends BaseService {
this.userModel_ = userModel
}
/**
* Authenticates a given user with an API token
* @param {string} token - the api_token of the user to authenticate
* @return {{
* success: (bool),
* user: (object | undefined),
* error: (string | undefined)
* }}
* success: whether authentication succeeded
* user: the user document if authentication succeded
* error: a string with the error message
*/
async authenticateAPIToken(token) {
const user = await this.userModel_.findOne({ api_token: token })
if (user) {
return {
success: true,
user,
}
} else {
return {
success: false,
error: "Invalid API Token",
}
}
}
/**
* Authenticates a given user based on an email, password combination. Uses
* bcrypt to match password with hashed value.
+31 -32
View File
@@ -1,8 +1,7 @@
import mongoose from "mongoose"
import _ from "lodash"
import { Validator, MedusaError } from "medusa-core-utils"
import { BaseService } from "../interfaces"
import MedusaError, { MedusaErrorTypes } from "../utils/errors"
import validator from "../utils/validator"
/**
* Provides layer to manipulate products.
@@ -29,11 +28,11 @@ class ProductService extends BaseService {
* @return {string} the validated id
*/
validateId_(rawId) {
const schema = validator.objectId()
const schema = Validator.objectId()
const { value, error } = schema.validate(rawId)
if (error) {
throw new MedusaError(
MedusaErrorTypes.INVALID_ARGUMENT,
MedusaError.Types.INVALID_ARGUMENT,
"The productId could not be casted to an ObjectId"
)
}
@@ -57,7 +56,7 @@ class ProductService extends BaseService {
retrieve(productId) {
const validatedId = this.validateId_(productId)
return this.productModel_.findOne({ _id: validatedId }).catch(err => {
throw new MedusaError(MedusaErrorTypes.DB_ERROR, err.message)
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
})
}
@@ -73,7 +72,7 @@ class ProductService extends BaseService {
published: false,
})
.catch(err => {
throw new MedusaError(MedusaErrorTypes.DB_ERROR, err.message)
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
})
}
@@ -86,7 +85,7 @@ class ProductService extends BaseService {
return this.productModel_
.updateOne({ _id: productId }, { $set: { published: true } })
.catch(err => {
throw new MedusaError(MedusaErrorTypes.DB_ERROR, err.message)
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
})
}
@@ -104,14 +103,14 @@ class ProductService extends BaseService {
if (update.metadata) {
throw new MedusaError(
MedusaErrorTypes.INVALID_DATA,
MedusaError.Types.INVALID_DATA,
"Use setMetadata to update metadata fields"
)
}
if (update.variants) {
throw new MedusaError(
MedusaErrorTypes.INVALID_DATA,
MedusaError.Types.INVALID_DATA,
"Use addVariant, reorderVariants, removeVariant to update Product Variants"
)
}
@@ -123,7 +122,7 @@ class ProductService extends BaseService {
{ runValidators: true }
)
.catch(err => {
throw new MedusaError(MedusaErrorTypes.DB_ERROR, err.message)
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
})
}
@@ -148,7 +147,7 @@ class ProductService extends BaseService {
})
return this.productModel_.deleteOne({ _id: product._id }).catch(err => {
throw new MedusaError(MedusaErrorTypes.DB_ERROR, err.message)
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
})
}
@@ -163,7 +162,7 @@ class ProductService extends BaseService {
const product = await this.retrieve(productId)
if (!product) {
throw new MedusaError(
MedusaErrorTypes.NOT_FOUND,
MedusaError.Types.NOT_FOUND,
`Product with ${product._id} was not found`
)
}
@@ -171,14 +170,14 @@ class ProductService extends BaseService {
const variant = await this.productVariantService_.retrieve(variantId)
if (!variant) {
throw new MedusaError(
MedusaErrorTypes.NOT_FOUND,
MedusaError.Types.NOT_FOUND,
`Variant with ${variantId} was not found`
)
}
if (product.options.length !== variant.options.length) {
throw new MedusaError(
MedusaErrorTypes.INVALID_DATA,
MedusaError.Types.INVALID_DATA,
`Product options length does not match variant options length. Product has ${product.options.length} and variant has ${variant.options.length}.`
)
}
@@ -186,7 +185,7 @@ class ProductService extends BaseService {
product.options.forEach(option => {
if (!variant.options.find(vo => vo.option_id === option._id)) {
throw new MedusaError(
MedusaErrorTypes.INVALID_DATA,
MedusaError.Types.INVALID_DATA,
`Variant options do not contain value for ${option.title}`
)
}
@@ -207,7 +206,7 @@ class ProductService extends BaseService {
if (combinationExists) {
throw new MedusaError(
MedusaErrorTypes.INVALID_DATA,
MedusaError.Types.INVALID_DATA,
`Variant with provided options already exists`
)
}
@@ -230,7 +229,7 @@ class ProductService extends BaseService {
const product = await this.retrieve(productId)
if (!product) {
throw new MedusaError(
MedusaErrorTypes.NOT_FOUND,
MedusaError.Types.NOT_FOUND,
`Product with ${product._id} was not found`
)
}
@@ -238,7 +237,7 @@ class ProductService extends BaseService {
// Make sure that option doesn't already exist
if (product.options.find(o => o.title === optionTitle)) {
throw new MedusaError(
MedusaErrorTypes.INVALID_DATA,
MedusaError.Types.INVALID_DATA,
`An option with the title: ${optionTitle} already exists`
)
}
@@ -297,14 +296,14 @@ class ProductService extends BaseService {
const product = await this.retrieve(productId)
if (!product) {
throw new MedusaError(
MedusaErrorTypes.NOT_FOUND,
MedusaError.Types.NOT_FOUND,
`Product with ${product._id} was not found`
)
}
if (product.variants.length !== variantOrder.length) {
throw new MedusaError(
MedusaErrorTypes.INVALID_DATA,
MedusaError.Types.INVALID_DATA,
`Product variants and new variant order differ in length. To delete or add variants use removeVariant or addVariant`
)
}
@@ -313,7 +312,7 @@ class ProductService extends BaseService {
const variant = product.variants.find(id => id === vId)
if (!variant) {
throw new MedusaError(
MedusaErrorTypes.INVALID_DATA,
MedusaError.Types.INVALID_DATA,
`Product has no variant with id: ${vId}`
)
}
@@ -344,14 +343,14 @@ class ProductService extends BaseService {
const product = await this.retrieve(productId)
if (!product) {
throw new MedusaError(
MedusaErrorTypes.NOT_FOUND,
MedusaError.Types.NOT_FOUND,
`Product with ${product._id} was not found`
)
}
if (product.options.length !== optionOrder.length) {
throw new MedusaError(
MedusaErrorTypes.INVALID_DATA,
MedusaError.Types.INVALID_DATA,
`Product options and new options order differ in length. To delete or add options use removeOption or addOption`
)
}
@@ -360,7 +359,7 @@ class ProductService extends BaseService {
const option = product.options.find(o => o._id === oId)
if (!option) {
throw new MedusaError(
MedusaErrorTypes.INVALID_DATA,
MedusaError.Types.INVALID_DATA,
`Product has no option with id: ${oId}`
)
}
@@ -390,7 +389,7 @@ class ProductService extends BaseService {
const product = await this.retrieve(productId)
if (!product) {
throw new MedusaError(
MedusaErrorTypes.NOT_FOUND,
MedusaError.Types.NOT_FOUND,
`Product with ${product._id} was not found`
)
}
@@ -398,7 +397,7 @@ class ProductService extends BaseService {
const option = product.options.find(o => o._id === optionId)
if (!option) {
throw new MedusaError(
MedusaErrorTypes.NOT_FOUND,
MedusaError.Types.NOT_FOUND,
`Product has no option with id: ${optionId}`
)
}
@@ -410,7 +409,7 @@ class ProductService extends BaseService {
if (titleExists) {
throw new MedusaError(
MedusaErrorTypes.NOT_FOUND,
MedusaError.Types.NOT_FOUND,
`An option with title ${title} already exists`
)
}
@@ -439,7 +438,7 @@ class ProductService extends BaseService {
const product = await this.retrieve(productId)
if (!product) {
throw new MedusaError(
MedusaErrorTypes.NOT_FOUND,
MedusaError.Types.NOT_FOUND,
`Product with ${product._id} was not found`
)
}
@@ -474,7 +473,7 @@ class ProductService extends BaseService {
if (!equalsFirst.every(v => v)) {
throw new MedusaError(
MedusaErrorTypes.INVALID_DATA,
MedusaError.Types.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`
)
}
@@ -508,7 +507,7 @@ class ProductService extends BaseService {
const product = await this.retrieve(productId)
if (!product) {
throw new MedusaError(
MedusaErrorTypes.NOT_FOUND,
MedusaError.Types.NOT_FOUND,
`Product with ${product._id} was not found`
)
}
@@ -555,7 +554,7 @@ class ProductService extends BaseService {
if (typeof key !== "string") {
throw new MedusaError(
MedusaErrorTypes.INVALID_ARGUMENT,
MedusaError.Types.INVALID_ARGUMENT,
"Key type is invalid. Metadata keys must be strings"
)
}
@@ -564,7 +563,7 @@ class ProductService extends BaseService {
return this.productModel_
.updateOne({ _id: validatedId }, { $set: { [keyPath]: value } })
.catch(err => {
throw new MedusaError(MedusaErrorTypes.DB_ERROR, err.message)
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
})
}
}
-36
View File
@@ -1,36 +0,0 @@
/**
* @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
@@ -1,4 +0,0 @@
import Joi from "@hapi/joi"
Joi.objectId = require("joi-objectid")(Joi)
export default Joi