Creates ShippingProfileService & ShippingOptionService (#30)
Adds ShippingProfiles: With Shipping Profiles store operators can group products together and select which shipping options can fulfill the products. The shipping profiles are region agnostic, but for products to be shippable to a given region the shipping profile must have a shipping option associated that ships to this region. Adds Shipping Options: Shipping Options represents a way that the customer can have their order shipped. The shipping option has a fulfillment provider associated to determine who fulfills orders with the given shipping method. If a fulfillment provider has multiple ways that they can ship a shipping option for each of the fulfillment provider's shipping options can be created.
This commit is contained in:
16
docs/services/ShippingOptionService.md
Normal file
16
docs/services/ShippingOptionService.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# ShippingOptionService
|
||||
|
||||
In Medusa, ShippingOptions represent ways that the customer can have their order shipped. Shipping options are defined by the store operator and are linked to a fulfillment provider. When the customer places their order the fulfillment provider plugin will be notified.
|
||||
|
||||
Shipping Options can have either flat rate prices or calculated prices. As the names suggest a flat rate price is a fixed amount, e.g. for Free Shipping, while calculated rates are prices that are calculated by the fulfillment provider.
|
||||
|
||||
## Creating ShippingOptions
|
||||
|
||||
Shipping options are created with POST calls to `/admin/shipping-options`. You can define requirements that the cart should meet in order to allow the shipping option to be applied to it. Furthermore, you should define what Region the shipping option is available in.
|
||||
|
||||
## Using Shipping Options
|
||||
|
||||
Your fulfillment provider may need additional data in order to validate the shipping option for use. For example, the store operator could make a shipping option called "ShopPickup" which is fulfilled by your warehouse and shipped with CarrierX.
|
||||
|
||||
CarrierX sends the order to the customer's local store where the customer can pick up their order. In this case fulfillment provider needs some additional data about which store CarrierX is shipping to. The additional data should be provided in the data field when calling POST `/store/carts/:id/shipping-method`.
|
||||
|
||||
9
docs/services/ShippingProfileService.md
Normal file
9
docs/services/ShippingProfileService.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# ShippingProfileService
|
||||
|
||||
In Medusa, a Shipping Profile represents a group of products and shipping options that can fulfill said products. For example, a store may have to product types "Shirts" and "Shorts" which are produced in Italy and UK respectively. In this case the store operator would create two shipping profiles, one for Shirts and one for Shorts, and add the products correspondingly. The store operator can now decide which shipping options can fulfill the products by adding shipping options to each of the profiles.
|
||||
|
||||
Products and Shipping Options can only belong to one shipping profile at a time.
|
||||
|
||||
|
||||
## Using Shipping Profiles
|
||||
The shipping profiles are used to fetch the correct shipping options for a cart. When GET `/store/shipping-options` is called the ShippingProfileService is asked to find all shipping options that can fulfill the cart's products.
|
||||
12
packages/medusa-fulfillment-manual/.babelrc
Normal file
12
packages/medusa-fulfillment-manual/.babelrc
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"plugins": [
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
"@babel/plugin-transform-instanceof"
|
||||
],
|
||||
"presets": ["@babel/preset-env"],
|
||||
"env": {
|
||||
"test": {
|
||||
"plugins": ["@babel/plugin-transform-runtime"]
|
||||
}
|
||||
}
|
||||
}
|
||||
13
packages/medusa-fulfillment-manual/.gitignore
vendored
Normal file
13
packages/medusa-fulfillment-manual/.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
/lib
|
||||
node_modules
|
||||
.DS_store
|
||||
.env*
|
||||
/*.js
|
||||
!index.js
|
||||
yarn.lock
|
||||
|
||||
/api
|
||||
/services
|
||||
/models
|
||||
/subscribers
|
||||
|
||||
9
packages/medusa-fulfillment-manual/.npmignore
Normal file
9
packages/medusa-fulfillment-manual/.npmignore
Normal file
@@ -0,0 +1,9 @@
|
||||
/lib
|
||||
node_modules
|
||||
.DS_store
|
||||
.env*
|
||||
/*.js
|
||||
!index.js
|
||||
yarn.lock
|
||||
|
||||
|
||||
1
packages/medusa-fulfillment-manual/index.js
Normal file
1
packages/medusa-fulfillment-manual/index.js
Normal file
@@ -0,0 +1 @@
|
||||
// noop
|
||||
33
packages/medusa-fulfillment-manual/package.json
Normal file
33
packages/medusa-fulfillment-manual/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "medusa-fulfillment-manual",
|
||||
"version": "0.1.27-alpha.0",
|
||||
"description": "A manual fulfillment provider for Medusa",
|
||||
"main": "index.js",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/medusajs/medusa",
|
||||
"directory": "packages/medusa-fulfillment-manual"
|
||||
},
|
||||
"author": "Sebastian Rindom",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.7.5",
|
||||
"@babel/core": "^7.7.5",
|
||||
"@babel/plugin-proposal-class-properties": "^7.7.4",
|
||||
"@babel/plugin-transform-runtime": "^7.7.6",
|
||||
"@babel/preset-env": "^7.7.5",
|
||||
"client-sessions": "^0.8.0",
|
||||
"cross-env": "^5.2.1",
|
||||
"eslint": "^6.8.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "babel src --out-dir . --ignore **/__tests__",
|
||||
"prepare": "cross-env NODE_ENV=production npm run build",
|
||||
"watch": "babel -w src --out-dir . --ignore **/__tests__"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.7.6",
|
||||
"express": "^4.17.1",
|
||||
"medusa-interfaces": "^0.1.27-alpha.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { FulfillmentService } from "medusa-interfaces"
|
||||
|
||||
class ManualFulfillmentService extends FulfillmentService {
|
||||
static identifier = "manual"
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
getFulfillmentOptions() {
|
||||
return [{
|
||||
id: "manual-fulfillment"
|
||||
}]
|
||||
}
|
||||
|
||||
validateFulfillmentData(data, cart) {
|
||||
return data
|
||||
}
|
||||
|
||||
validateOption(data) {
|
||||
if (data.id === "manual-fulfillment") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
canCalculate() {
|
||||
return false
|
||||
}
|
||||
|
||||
calculatePrice() {
|
||||
throw Error("Manual Fulfillment service cannot calculatePrice")
|
||||
}
|
||||
|
||||
createOrder() {
|
||||
// No data is being sent anywhere
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export default ManualFulfillmentService
|
||||
38
packages/medusa-interfaces/src/fulfillment-service.js
Normal file
38
packages/medusa-interfaces/src/fulfillment-service.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import BaseService from "./base-service"
|
||||
|
||||
/**
|
||||
* The interface that all fulfillment services must inherit from. The intercace
|
||||
* provides the necessary methods for creating, authorizing and managing
|
||||
* fulfillment orders.
|
||||
* @interface
|
||||
*/
|
||||
class BaseFulfillmentService extends BaseService {
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
getFulfillmentOptions() {
|
||||
}
|
||||
|
||||
validateFulfillmentData(data, cart) {
|
||||
throw Error("validateFulfillmentData must be overridden by the child class")
|
||||
}
|
||||
|
||||
validateOption(data) {
|
||||
throw Error("validateOption must be overridden by the child class")
|
||||
}
|
||||
|
||||
canCalculate(data) {
|
||||
throw Error("canCalculate must be overridden by the child class")
|
||||
}
|
||||
|
||||
calculatePrice(data) {
|
||||
throw Error("calculatePrice must be overridden by the child class")
|
||||
}
|
||||
|
||||
createOrder() {
|
||||
throw Error("createOrder must be overridden by the child class")
|
||||
}
|
||||
}
|
||||
|
||||
export default BaseFulfillmentService
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as BaseService } from "./base-service"
|
||||
export { default as BaseModel } from "./base-model"
|
||||
export { default as PaymentService } from "./payment-service"
|
||||
export { default as FulfillmentService } from "./fulfillment-service"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"plugins": ["@babel/plugin-proposal-class-properties"],
|
||||
"plugins": [
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
"@babel/plugin-transform-instanceof"
|
||||
],
|
||||
"presets": ["@babel/preset-env"],
|
||||
"env": {
|
||||
"test": {
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"watch": "babel -w src --out-dir . --ignore **/__tests__"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/plugin-transform-instanceof": "^7.8.3",
|
||||
"@babel/runtime": "^7.7.6",
|
||||
"express": "^4.17.1",
|
||||
"medusa-interfaces": "^0.3.0"
|
||||
|
||||
@@ -3,6 +3,8 @@ import middlewares from "../../middlewares"
|
||||
import authRoutes from "./auth"
|
||||
import productRoutes from "./products"
|
||||
import productVariantRoutes from "./product-variants"
|
||||
import shippingOptionRoutes from "./shipping-options"
|
||||
import shippingProfileRoutes from "./shipping-profiles"
|
||||
|
||||
const route = Router()
|
||||
|
||||
@@ -16,6 +18,8 @@ export default app => {
|
||||
route.use(middlewares.authenticate())
|
||||
|
||||
productRoutes(route)
|
||||
shippingOptionRoutes(route)
|
||||
shippingProfileRoutes(route)
|
||||
// productVariantRoutes(route)
|
||||
|
||||
return app
|
||||
|
||||
@@ -4,11 +4,6 @@ import middlewares from "../../../middlewares"
|
||||
const route = Router()
|
||||
|
||||
export default app => {
|
||||
// Inject <rootDir>/api/routes/admin/products/router.js
|
||||
// Inject <rootDir>/plugins/*/api/routes/admin/products/router.js
|
||||
// Inject <rootDir>/node_modules/*/api/routes/admin/products/router.js
|
||||
|
||||
|
||||
app.use("/products", route)
|
||||
|
||||
route.post("/", middlewares.wrap(require("./create-product").default))
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
import { request } from "../../../../../helpers/test-request"
|
||||
import { ShippingOptionServiceMock } from "../../../../../services/__mocks__/shipping-option"
|
||||
|
||||
describe("POST /admin/shipping-options", () => {
|
||||
describe("successful creation", () => {
|
||||
let subject
|
||||
|
||||
beforeAll(async () => {
|
||||
subject = await request("POST", "/admin/shipping-options", {
|
||||
payload: {
|
||||
name: "Test option",
|
||||
region_id: "testregion",
|
||||
provider_id: "test_provider",
|
||||
price: {
|
||||
type: "flat_rate",
|
||||
amount: 100,
|
||||
},
|
||||
requirements: [
|
||||
{
|
||||
type: "min_subtotal",
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
adminSession: {
|
||||
jwt: {
|
||||
userId: IdMap.getId("admin_user"),
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("returns 200", () => {
|
||||
expect(subject.status).toEqual(200)
|
||||
})
|
||||
|
||||
it("calls service create", () => {
|
||||
expect(ShippingOptionServiceMock.create).toHaveBeenCalledTimes(1)
|
||||
expect(ShippingOptionServiceMock.create).toHaveBeenCalledWith({
|
||||
name: "Test option",
|
||||
region_id: "testregion",
|
||||
provider_id: "test_provider",
|
||||
price: {
|
||||
type: "flat_rate",
|
||||
amount: 100,
|
||||
},
|
||||
requirements: [
|
||||
{
|
||||
type: "min_subtotal",
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("fails on invalid data", () => {
|
||||
let subject
|
||||
|
||||
beforeAll(async () => {
|
||||
subject = await request("POST", "/admin/shipping-options", {
|
||||
payload: {
|
||||
price: {
|
||||
type: "flat_rate",
|
||||
amount: 100,
|
||||
},
|
||||
requirements: [
|
||||
{
|
||||
type: "min_subtotal",
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
adminSession: {
|
||||
jwt: {
|
||||
userId: IdMap.getId("admin_user"),
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("returns 400", () => {
|
||||
expect(subject.status).toEqual(400)
|
||||
})
|
||||
|
||||
it("returns error", () => {
|
||||
expect(subject.body.message[0].message).toEqual(`"name" is required`)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
import { request } from "../../../../../helpers/test-request"
|
||||
import { ShippingOptionServiceMock } from "../../../../../services/__mocks__/shipping-option"
|
||||
|
||||
describe("POST /admin/shipping-options", () => {
|
||||
describe("successful creation", () => {
|
||||
let subject
|
||||
|
||||
beforeAll(async () => {
|
||||
subject = await request(
|
||||
"DELETE",
|
||||
`/admin/shipping-options/${IdMap.getId("validId")}`,
|
||||
{
|
||||
adminSession: {
|
||||
jwt: {
|
||||
userId: IdMap.getId("admin_user"),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("returns 200", () => {
|
||||
expect(subject.status).toEqual(200)
|
||||
})
|
||||
|
||||
it("calls service delete", () => {
|
||||
expect(ShippingOptionServiceMock.delete).toHaveBeenCalledTimes(1)
|
||||
expect(ShippingOptionServiceMock.delete).toHaveBeenCalledWith(
|
||||
IdMap.getId("validId")
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
import { request } from "../../../../../helpers/test-request"
|
||||
import { ShippingOptionServiceMock } from "../../../../../services/__mocks__/shipping-option"
|
||||
|
||||
describe("GET /admin/shipping-options/:optionId", () => {
|
||||
describe("successful retrieval", () => {
|
||||
let subject
|
||||
|
||||
beforeAll(async () => {
|
||||
subject = await request(
|
||||
"GET",
|
||||
`/admin/shipping-options/${IdMap.getId("validId")}`,
|
||||
{
|
||||
adminSession: {
|
||||
jwt: {
|
||||
userId: IdMap.getId("admin_user"),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("returns 200", () => {
|
||||
expect(subject.status).toEqual(200)
|
||||
})
|
||||
|
||||
it("calls service retrieve", () => {
|
||||
expect(ShippingOptionServiceMock.retrieve).toHaveBeenCalledTimes(1)
|
||||
expect(ShippingOptionServiceMock.retrieve).toHaveBeenCalledWith(
|
||||
IdMap.getId("validId")
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
import { request } from "../../../../../helpers/test-request"
|
||||
import { ShippingOptionServiceMock } from "../../../../../services/__mocks__/shipping-option"
|
||||
|
||||
describe("GET /admin/shipping-options", () => {
|
||||
describe("successful retrieval", () => {
|
||||
let subject
|
||||
|
||||
beforeAll(async () => {
|
||||
subject = await request("GET", `/admin/shipping-options`, {
|
||||
adminSession: {
|
||||
jwt: {
|
||||
userId: IdMap.getId("admin_user"),
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("returns 200", () => {
|
||||
expect(subject.status).toEqual(200)
|
||||
})
|
||||
|
||||
it("calls service retrieve", () => {
|
||||
expect(ShippingOptionServiceMock.list).toHaveBeenCalledTimes(1)
|
||||
expect(ShippingOptionServiceMock.list).toHaveBeenCalledWith()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
import { request } from "../../../../../helpers/test-request"
|
||||
import { ShippingOptionServiceMock } from "../../../../../services/__mocks__/shipping-option"
|
||||
|
||||
describe("POST /admin/shipping-options", () => {
|
||||
describe("successful creation", () => {
|
||||
let subject
|
||||
|
||||
beforeAll(async () => {
|
||||
subject = await request(
|
||||
"POST",
|
||||
`/admin/shipping-options/${IdMap.getId("validId")}`,
|
||||
{
|
||||
payload: {
|
||||
name: "Test option",
|
||||
price: {
|
||||
type: "flat_rate",
|
||||
amount: 100,
|
||||
},
|
||||
requirements: [
|
||||
{
|
||||
type: "min_subtotal",
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
adminSession: {
|
||||
jwt: {
|
||||
userId: IdMap.getId("admin_user"),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("returns 200", () => {
|
||||
expect(subject.status).toEqual(200)
|
||||
})
|
||||
|
||||
it("calls service method", () => {
|
||||
expect(ShippingOptionServiceMock.update).toHaveBeenCalledTimes(1)
|
||||
expect(ShippingOptionServiceMock.update).toHaveBeenCalledWith(
|
||||
IdMap.getId("validId"),
|
||||
{
|
||||
name: "Test option",
|
||||
price: {
|
||||
type: "flat_rate",
|
||||
amount: 100,
|
||||
},
|
||||
requirements: [
|
||||
{
|
||||
type: "min_subtotal",
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { MedusaError, Validator } from "medusa-core-utils"
|
||||
|
||||
export default async (req, res) => {
|
||||
const schema = Validator.object().keys({
|
||||
name: Validator.string().required(),
|
||||
region_id: Validator.string().required(),
|
||||
provider_id: Validator.string().required(),
|
||||
data: Validator.object(),
|
||||
price: Validator.object().keys({
|
||||
type: Validator.string().required(),
|
||||
amount: Validator.number().optional(),
|
||||
}),
|
||||
requirements: Validator.array()
|
||||
.items(
|
||||
Validator.object({
|
||||
type: Validator.string().required(),
|
||||
value: Validator.number().required(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
|
||||
const { value, error } = schema.validate(req.body)
|
||||
if (error) {
|
||||
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
|
||||
}
|
||||
|
||||
try {
|
||||
const optionService = req.scope.resolve("shippingOptionService")
|
||||
const data = await optionService.create(value)
|
||||
|
||||
res.status(200).json(data)
|
||||
} catch (err) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { MedusaError, Validator } from "medusa-core-utils"
|
||||
|
||||
export default async (req, res) => {
|
||||
const { option_id} = req.params
|
||||
try {
|
||||
const optionService = req.scope.resolve("shippingOptionService")
|
||||
|
||||
await optionService.delete(option_id)
|
||||
|
||||
res.sendStatus(200)
|
||||
} catch (err) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export default async (req, res) => {
|
||||
const { option_id } = req.params
|
||||
try {
|
||||
const optionService = req.scope.resolve("shippingOptionService")
|
||||
const data = await optionService.retrieve(option_id)
|
||||
|
||||
res.status(200).json(data)
|
||||
} catch (err) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Router } from "express"
|
||||
import middlewares from "../../../middlewares"
|
||||
|
||||
const route = Router()
|
||||
|
||||
export default app => {
|
||||
app.use("/shipping-options", route)
|
||||
|
||||
route.get("/", middlewares.wrap(require("./list-shipping-options").default))
|
||||
route.post("/", middlewares.wrap(require("./create-shipping-option").default))
|
||||
|
||||
route.get(
|
||||
"/:option_id",
|
||||
middlewares.wrap(require("./get-shipping-option").default)
|
||||
)
|
||||
route.post(
|
||||
"/:option_id",
|
||||
middlewares.wrap(require("./update-shipping-option").default)
|
||||
)
|
||||
route.delete(
|
||||
"/:option_id",
|
||||
middlewares.wrap(require("./delete-shipping-option").default)
|
||||
)
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export default async (req, res) => {
|
||||
const { optionId } = req.params
|
||||
try {
|
||||
const optionService = req.scope.resolve("shippingOptionService")
|
||||
const data = await optionService.list()
|
||||
|
||||
res.status(200).json(data)
|
||||
} catch (err) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MedusaError, Validator } from "medusa-core-utils"
|
||||
|
||||
export default async (req, res) => {
|
||||
const { option_id } = req.params
|
||||
const schema = Validator.object().keys({
|
||||
name: Validator.string().optional(),
|
||||
price: Validator.object()
|
||||
.keys({
|
||||
type: Validator.string().required(),
|
||||
amount: Validator.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
requirements: Validator.array()
|
||||
.items(
|
||||
Validator.object({
|
||||
type: Validator.string().required(),
|
||||
value: Validator.number().required(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
|
||||
const { value, error } = schema.validate(req.body)
|
||||
if (error) {
|
||||
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
|
||||
}
|
||||
|
||||
try {
|
||||
const optionService = req.scope.resolve("shippingOptionService")
|
||||
|
||||
await optionService.update(option_id, value)
|
||||
|
||||
const data = await optionService.retrieve(option_id)
|
||||
|
||||
res.status(200).json(data)
|
||||
} catch (err) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
import { request } from "../../../../../helpers/test-request"
|
||||
import { ShippingProfileServiceMock } from "../../../../../services/__mocks__/shipping-profile"
|
||||
|
||||
describe("POST /admin/shipping-profiles/:profile_id/products", () => {
|
||||
describe("successful addition", () => {
|
||||
let subject
|
||||
|
||||
beforeAll(async () => {
|
||||
const profileId = IdMap.getId("validId")
|
||||
subject = await request(
|
||||
"POST",
|
||||
`/admin/shipping-profiles/${profileId}/products`,
|
||||
{
|
||||
payload: {
|
||||
product_id: IdMap.getId("validId"),
|
||||
},
|
||||
adminSession: {
|
||||
jwt: {
|
||||
userId: IdMap.getId("admin_user"),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("returns 200", () => {
|
||||
expect(subject.status).toEqual(200)
|
||||
})
|
||||
|
||||
it("calls service retrieve", () => {
|
||||
expect(ShippingProfileServiceMock.retrieve).toHaveBeenCalledTimes(1)
|
||||
expect(ShippingProfileServiceMock.retrieve).toHaveBeenCalledWith(
|
||||
IdMap.getId("validId")
|
||||
)
|
||||
expect(ShippingProfileServiceMock.addProduct).toHaveBeenCalledTimes(1)
|
||||
expect(ShippingProfileServiceMock.addProduct).toHaveBeenCalledWith(
|
||||
IdMap.getId("validId"),
|
||||
IdMap.getId("validId")
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
import { request } from "../../../../../helpers/test-request"
|
||||
import { ShippingProfileServiceMock } from "../../../../../services/__mocks__/shipping-profile"
|
||||
|
||||
describe("POST /admin/shipping-profiles/:profile_id/products", () => {
|
||||
describe("successful addition", () => {
|
||||
let subject
|
||||
|
||||
beforeAll(async () => {
|
||||
const profileId = IdMap.getId("validId")
|
||||
subject = await request(
|
||||
"POST",
|
||||
`/admin/shipping-profiles/${profileId}/shipping-options`,
|
||||
{
|
||||
payload: {
|
||||
option_id: IdMap.getId("validId"),
|
||||
},
|
||||
adminSession: {
|
||||
jwt: {
|
||||
userId: IdMap.getId("admin_user"),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("returns 200", () => {
|
||||
expect(subject.status).toEqual(200)
|
||||
})
|
||||
|
||||
it("calls service retrieve", () => {
|
||||
expect(ShippingProfileServiceMock.retrieve).toHaveBeenCalledTimes(1)
|
||||
expect(ShippingProfileServiceMock.retrieve).toHaveBeenCalledWith(
|
||||
IdMap.getId("validId")
|
||||
)
|
||||
expect(
|
||||
ShippingProfileServiceMock.addShippingOption
|
||||
).toHaveBeenCalledTimes(1)
|
||||
expect(ShippingProfileServiceMock.addShippingOption).toHaveBeenCalledWith(
|
||||
IdMap.getId("validId"),
|
||||
IdMap.getId("validId")
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
import { request } from "../../../../../helpers/test-request"
|
||||
import { ShippingProfileServiceMock } from "../../../../../services/__mocks__/shipping-profile"
|
||||
|
||||
describe("POST /admin/shipping-profiles", () => {
|
||||
describe("successful creation", () => {
|
||||
let subject
|
||||
|
||||
beforeAll(async () => {
|
||||
subject = await request("POST", "/admin/shipping-profiles", {
|
||||
payload: {
|
||||
name: "Test Profile",
|
||||
},
|
||||
adminSession: {
|
||||
jwt: {
|
||||
userId: IdMap.getId("admin_user"),
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("returns 200", () => {
|
||||
expect(subject.status).toEqual(200)
|
||||
})
|
||||
|
||||
it("calls service create", () => {
|
||||
expect(ShippingProfileServiceMock.create).toHaveBeenCalledTimes(1)
|
||||
expect(ShippingProfileServiceMock.create).toHaveBeenCalledWith({
|
||||
name: "Test Profile",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
import { request } from "../../../../../helpers/test-request"
|
||||
import { ShippingOptionServiceMock } from "../../../../../services/__mocks__/shipping-option"
|
||||
|
||||
describe("POST /admin/shipping-options", () => {
|
||||
describe("successful creation", () => {
|
||||
let subject
|
||||
|
||||
beforeAll(async () => {
|
||||
subject = await request(
|
||||
"DELETE",
|
||||
`/admin/shipping-options/${IdMap.getId("validId")}`,
|
||||
{
|
||||
adminSession: {
|
||||
jwt: {
|
||||
userId: IdMap.getId("admin_user"),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("returns 200", () => {
|
||||
expect(subject.status).toEqual(200)
|
||||
})
|
||||
|
||||
it("calls service delete", () => {
|
||||
expect(ShippingOptionServiceMock.delete).toHaveBeenCalledTimes(1)
|
||||
expect(ShippingOptionServiceMock.delete).toHaveBeenCalledWith(
|
||||
IdMap.getId("validId")
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
import { request } from "../../../../../helpers/test-request"
|
||||
import { ShippingProfileServiceMock } from "../../../../../services/__mocks__/shipping-profile"
|
||||
|
||||
describe("GET /admin/shipping-profiles/:profile_id", () => {
|
||||
describe("successful retrieval", () => {
|
||||
let subject
|
||||
|
||||
beforeAll(async () => {
|
||||
subject = await request(
|
||||
"GET",
|
||||
`/admin/shipping-profiles/${IdMap.getId("validId")}`,
|
||||
{
|
||||
adminSession: {
|
||||
jwt: {
|
||||
userId: IdMap.getId("admin_user"),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("returns 200", () => {
|
||||
expect(subject.status).toEqual(200)
|
||||
})
|
||||
|
||||
it("calls service retrieve", () => {
|
||||
expect(ShippingProfileServiceMock.retrieve).toHaveBeenCalledTimes(1)
|
||||
expect(ShippingProfileServiceMock.retrieve).toHaveBeenCalledWith(
|
||||
IdMap.getId("validId")
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
import { request } from "../../../../../helpers/test-request"
|
||||
import { ShippingProfileServiceMock } from "../../../../../services/__mocks__/shipping-profile"
|
||||
|
||||
describe("GET /admin/shipping-profiles", () => {
|
||||
describe("successful retrieval", () => {
|
||||
let subject
|
||||
|
||||
beforeAll(async () => {
|
||||
subject = await request("GET", `/admin/shipping-profiles`, {
|
||||
adminSession: {
|
||||
jwt: {
|
||||
userId: IdMap.getId("admin_user"),
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("returns 200", () => {
|
||||
expect(subject.status).toEqual(200)
|
||||
})
|
||||
|
||||
it("calls service retrieve", () => {
|
||||
expect(ShippingProfileServiceMock.list).toHaveBeenCalledTimes(1)
|
||||
expect(ShippingProfileServiceMock.list).toHaveBeenCalledWith()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
import { request } from "../../../../../helpers/test-request"
|
||||
import { ShippingProfileServiceMock } from "../../../../../services/__mocks__/shipping-profile"
|
||||
|
||||
describe("DELETE /admin/shipping-profiles/:profile_id/products/:product_id", () => {
|
||||
describe("successful addition", () => {
|
||||
let subject
|
||||
|
||||
beforeAll(async () => {
|
||||
const profileId = IdMap.getId("validId")
|
||||
const productId = IdMap.getId("validId")
|
||||
subject = await request(
|
||||
"DELETE",
|
||||
`/admin/shipping-profiles/${profileId}/products/${productId}`,
|
||||
{
|
||||
adminSession: {
|
||||
jwt: {
|
||||
userId: IdMap.getId("admin_user"),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("returns 200", () => {
|
||||
expect(subject.status).toEqual(200)
|
||||
})
|
||||
|
||||
it("calls service retrieve", () => {
|
||||
expect(ShippingProfileServiceMock.removeProduct).toHaveBeenCalledTimes(1)
|
||||
expect(ShippingProfileServiceMock.removeProduct).toHaveBeenCalledWith(
|
||||
IdMap.getId("validId"),
|
||||
IdMap.getId("validId")
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
import { request } from "../../../../../helpers/test-request"
|
||||
import { ShippingProfileServiceMock } from "../../../../../services/__mocks__/shipping-profile"
|
||||
|
||||
describe("DELETE /admin/shipping-profiles/:profile_id/shipping-options/:option_id", () => {
|
||||
describe("successful addition", () => {
|
||||
let subject
|
||||
|
||||
beforeAll(async () => {
|
||||
const profileId = IdMap.getId("validId")
|
||||
const optionId = IdMap.getId("validId")
|
||||
subject = await request(
|
||||
"DELETE",
|
||||
`/admin/shipping-profiles/${profileId}/shipping-options/${optionId}`,
|
||||
{
|
||||
adminSession: {
|
||||
jwt: {
|
||||
userId: IdMap.getId("admin_user"),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("returns 200", () => {
|
||||
expect(subject.status).toEqual(200)
|
||||
})
|
||||
|
||||
it("calls service retrieve", () => {
|
||||
expect(
|
||||
ShippingProfileServiceMock.removeShippingOption
|
||||
).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
ShippingProfileServiceMock.removeShippingOption
|
||||
).toHaveBeenCalledWith(IdMap.getId("validId"), IdMap.getId("validId"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
import { request } from "../../../../../helpers/test-request"
|
||||
import { ShippingProfileServiceMock } from "../../../../../services/__mocks__/shipping-profile"
|
||||
|
||||
describe("POST /admin/shipping-profile", () => {
|
||||
describe("successful update", () => {
|
||||
let subject
|
||||
|
||||
beforeAll(async () => {
|
||||
subject = await request(
|
||||
"POST",
|
||||
`/admin/shipping-profiles/${IdMap.getId("validId")}`,
|
||||
{
|
||||
payload: {
|
||||
name: "Test option",
|
||||
products: [IdMap.getId("product1")],
|
||||
shipping_options: [IdMap.getId("shipping1")],
|
||||
},
|
||||
adminSession: {
|
||||
jwt: {
|
||||
userId: IdMap.getId("admin_user"),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("returns 200", () => {
|
||||
expect(subject.status).toEqual(200)
|
||||
})
|
||||
|
||||
it("calls service method", () => {
|
||||
expect(ShippingProfileServiceMock.update).toHaveBeenCalledTimes(1)
|
||||
expect(ShippingProfileServiceMock.update).toHaveBeenCalledWith(
|
||||
IdMap.getId("validId"),
|
||||
{
|
||||
name: "Test option",
|
||||
products: [IdMap.getId("product1")],
|
||||
shipping_options: [IdMap.getId("shipping1")],
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { MedusaError, Validator } from "medusa-core-utils"
|
||||
|
||||
export default async (req, res) => {
|
||||
const { profile_id } = req.params
|
||||
const schema = Validator.object().keys({
|
||||
product_id: Validator.objectId().required(),
|
||||
})
|
||||
|
||||
const { value, error } = schema.validate(req.body)
|
||||
if (error) {
|
||||
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
|
||||
}
|
||||
|
||||
try {
|
||||
const profileService = req.scope.resolve("shippingProfileService")
|
||||
|
||||
await profileService.addProduct(profile_id, value.product_id)
|
||||
|
||||
const data = profileService.retrieve(profile_id)
|
||||
res.status(200).json(data)
|
||||
} catch (err) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { MedusaError, Validator } from "medusa-core-utils"
|
||||
|
||||
export default async (req, res) => {
|
||||
const { profile_id } = req.params
|
||||
const schema = Validator.object().keys({
|
||||
option_id: Validator.objectId().required(),
|
||||
})
|
||||
|
||||
const { value, error } = schema.validate(req.body)
|
||||
if (error) {
|
||||
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
|
||||
}
|
||||
|
||||
try {
|
||||
const profileService = req.scope.resolve("shippingProfileService")
|
||||
|
||||
await profileService.addShippingOption(profile_id, value.option_id)
|
||||
|
||||
const data = profileService.retrieve(profile_id)
|
||||
res.status(200).json(data)
|
||||
} catch (err) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { MedusaError, Validator } from "medusa-core-utils"
|
||||
|
||||
export default async (req, res) => {
|
||||
const schema = Validator.object().keys({
|
||||
name: Validator.string().required(),
|
||||
})
|
||||
|
||||
const { value, error } = schema.validate(req.body)
|
||||
if (error) {
|
||||
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
|
||||
}
|
||||
|
||||
try {
|
||||
const profileService = req.scope.resolve("shippingProfileService")
|
||||
const data = await profileService.create(value)
|
||||
|
||||
res.status(200).json(data)
|
||||
} catch (err) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export default async (req, res) => {
|
||||
const { profile_id } = req.params
|
||||
try {
|
||||
const profileService = req.scope.resolve("shippingProfileService")
|
||||
|
||||
await profileService.delete(profile_id)
|
||||
|
||||
res.status(200).json({
|
||||
id: profile_id,
|
||||
object: "shipping_profile",
|
||||
deleted: true,
|
||||
})
|
||||
} catch (err) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export default async (req, res) => {
|
||||
const { profile_id } = req.params
|
||||
try {
|
||||
const profileService = req.scope.resolve("shippingProfileService")
|
||||
|
||||
const data = await profileService.retrieve(profile_id)
|
||||
|
||||
res.status(200).json(data)
|
||||
} catch (err) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Router } from "express"
|
||||
import middlewares from "../../../middlewares"
|
||||
|
||||
const route = Router()
|
||||
|
||||
export default app => {
|
||||
app.use("/shipping-profiles", route)
|
||||
|
||||
route.get("/", middlewares.wrap(require("./list-shipping-profiles").default))
|
||||
route.post(
|
||||
"/",
|
||||
middlewares.wrap(require("./create-shipping-profile").default)
|
||||
)
|
||||
|
||||
route.get(
|
||||
"/:profile_id",
|
||||
middlewares.wrap(require("./get-shipping-profile").default)
|
||||
)
|
||||
route.post(
|
||||
"/:profile_id",
|
||||
middlewares.wrap(require("./update-shipping-profile").default)
|
||||
)
|
||||
route.delete(
|
||||
"/:profile_id",
|
||||
middlewares.wrap(require("./delete-shipping-profile").default)
|
||||
)
|
||||
|
||||
// Product management
|
||||
route.post(
|
||||
"/:profile_id/products",
|
||||
middlewares.wrap(require("./add-product").default)
|
||||
)
|
||||
route.delete(
|
||||
"/:profile_id/products/:product_id",
|
||||
middlewares.wrap(require("./remove-product").default)
|
||||
)
|
||||
|
||||
// Shipping Option management
|
||||
route.post(
|
||||
"/:profile_id/shipping-options",
|
||||
middlewares.wrap(require("./add-shipping-option").default)
|
||||
)
|
||||
route.delete(
|
||||
"/:profile_id/shipping-options/:option_id",
|
||||
middlewares.wrap(require("./remove-shipping-option").default)
|
||||
)
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export default async (req, res) => {
|
||||
try {
|
||||
const profileService = req.scope.resolve("shippingProfileService")
|
||||
|
||||
const data = await profileService.list()
|
||||
|
||||
res.status(200).json(data)
|
||||
} catch (err) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export default async (req, res) => {
|
||||
const { profile_id, product_id } = req.params
|
||||
|
||||
try {
|
||||
const profileService = req.scope.resolve("shippingProfileService")
|
||||
|
||||
await profileService.removeProduct(profile_id, product_id)
|
||||
|
||||
const data = profileService.retrieve(profile_id)
|
||||
res.status(200).json(data)
|
||||
} catch (err) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export default async (req, res) => {
|
||||
const { profile_id, option_id } = req.params
|
||||
|
||||
try {
|
||||
const profileService = req.scope.resolve("shippingProfileService")
|
||||
|
||||
await profileService.removeShippingOption(profile_id, option_id)
|
||||
|
||||
const data = profileService.retrieve(profile_id)
|
||||
res.status(200).json(data)
|
||||
} catch (err) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MedusaError, Validator } from "medusa-core-utils"
|
||||
|
||||
export default async (req, res) => {
|
||||
const { profile_id } = req.params
|
||||
|
||||
const schema = Validator.object().keys({
|
||||
name: Validator.string(),
|
||||
products: Validator.array().items(Validator.objectId()),
|
||||
shipping_options: Validator.array().items(Validator.objectId()),
|
||||
})
|
||||
|
||||
const { value, error } = schema.validate(req.body)
|
||||
if (error) {
|
||||
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
|
||||
}
|
||||
|
||||
try {
|
||||
const profileService = req.scope.resolve("shippingProfileService")
|
||||
|
||||
await profileService.update(profile_id, value)
|
||||
|
||||
const data = await profileService.retrieve(profile_id)
|
||||
res.status(200).json(data)
|
||||
} catch (err) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
import { request } from "../../../../../helpers/test-request"
|
||||
import { CartServiceMock } from "../../../../../services/__mocks__/cart"
|
||||
import { LineItemServiceMock } from "../../../../../services/__mocks__/line-item"
|
||||
|
||||
describe("POST /store/carts/:id/shipping-methods", () => {
|
||||
describe("successfully adds a shipping method", () => {
|
||||
@@ -24,22 +23,12 @@ describe("POST /store/carts/:id/shipping-methods", () => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("calls CartService retrieveShippingOption", () => {
|
||||
expect(CartServiceMock.retrieveShippingOption).toHaveBeenCalledTimes(1)
|
||||
expect(CartServiceMock.retrieveShippingOption).toHaveBeenCalledWith(
|
||||
IdMap.getId("fr-cart"),
|
||||
IdMap.getId("freeShipping")
|
||||
)
|
||||
})
|
||||
|
||||
it("calls CartService addShipping", () => {
|
||||
expect(CartServiceMock.addShippingMethod).toHaveBeenCalledTimes(1)
|
||||
expect(CartServiceMock.addShippingMethod).toHaveBeenCalledWith(
|
||||
IdMap.getId("fr-cart"),
|
||||
{
|
||||
_id: IdMap.getId("freeShipping"),
|
||||
profile_id: "default_profile",
|
||||
}
|
||||
IdMap.getId("freeShipping"),
|
||||
{}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -76,24 +65,13 @@ describe("POST /store/carts/:id/shipping-methods", () => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("calls CartService retrieveShippingOption", () => {
|
||||
expect(CartServiceMock.retrieveShippingOption).toHaveBeenCalledTimes(1)
|
||||
expect(CartServiceMock.retrieveShippingOption).toHaveBeenCalledWith(
|
||||
IdMap.getId("fr-cart"),
|
||||
IdMap.getId("freeShipping")
|
||||
)
|
||||
})
|
||||
|
||||
it("calls CartService addShipping", () => {
|
||||
expect(CartServiceMock.addShippingMethod).toHaveBeenCalledTimes(1)
|
||||
expect(CartServiceMock.addShippingMethod).toHaveBeenCalledWith(
|
||||
IdMap.getId("fr-cart"),
|
||||
IdMap.getId("freeShipping"),
|
||||
{
|
||||
_id: IdMap.getId("freeShipping"),
|
||||
profile_id: "default_profile",
|
||||
data: {
|
||||
extra_id: "id",
|
||||
},
|
||||
extra_id: "id",
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -107,60 +85,4 @@ describe("POST /store/carts/:id/shipping-methods", () => {
|
||||
expect(subject.body.decorated).toEqual(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("additional data without overwriting", () => {
|
||||
let subject
|
||||
|
||||
beforeAll(async () => {
|
||||
const cartId = IdMap.getId("emptyCart")
|
||||
subject = await request(
|
||||
"POST",
|
||||
`/store/carts/${cartId}/shipping-methods`,
|
||||
{
|
||||
payload: {
|
||||
option_id: IdMap.getId("withData"),
|
||||
data: {
|
||||
extra_id: "id",
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("calls CartService retrieveShippingOption", () => {
|
||||
expect(CartServiceMock.retrieveShippingOption).toHaveBeenCalledTimes(1)
|
||||
expect(CartServiceMock.retrieveShippingOption).toHaveBeenCalledWith(
|
||||
IdMap.getId("emptyCart"),
|
||||
IdMap.getId("withData")
|
||||
)
|
||||
})
|
||||
|
||||
it("calls CartService addShipping", () => {
|
||||
expect(CartServiceMock.addShippingMethod).toHaveBeenCalledTimes(1)
|
||||
expect(CartServiceMock.addShippingMethod).toHaveBeenCalledWith(
|
||||
IdMap.getId("emptyCart"),
|
||||
{
|
||||
_id: IdMap.getId("withData"),
|
||||
profile_id: "default_profile",
|
||||
data: {
|
||||
extra_id: "id",
|
||||
some_data: "yes",
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("returns 200", () => {
|
||||
expect(subject.status).toEqual(200)
|
||||
})
|
||||
|
||||
it("returns the cart", () => {
|
||||
expect(subject.body._id).toEqual(IdMap.getId("emptyCart"))
|
||||
expect(subject.body.decorated).toEqual(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,9 @@ export default async (req, res) => {
|
||||
|
||||
const schema = Validator.object().keys({
|
||||
option_id: Validator.string().required(),
|
||||
data: Validator.object().optional(),
|
||||
data: Validator.object()
|
||||
.optional()
|
||||
.default({}),
|
||||
})
|
||||
|
||||
const { value, error } = schema.validate(req.body)
|
||||
@@ -17,17 +19,7 @@ export default async (req, res) => {
|
||||
try {
|
||||
const cartService = req.scope.resolve("cartService")
|
||||
|
||||
const method = await cartService.retrieveShippingOption(id, value.option_id)
|
||||
|
||||
// If the option accepts additional data this will be added
|
||||
if (!_.isEmpty(value.data)) {
|
||||
method.data = {
|
||||
...method.data,
|
||||
...value.data,
|
||||
}
|
||||
}
|
||||
|
||||
await cartService.addShippingMethod(id, method)
|
||||
await cartService.addShippingMethod(id, value.option_id, value.data)
|
||||
|
||||
let cart = await cartService.retrieve(id)
|
||||
cart = await cartService.decorate(cart)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from "express"
|
||||
import productRoutes from "./products"
|
||||
import cartRoutes from "./carts"
|
||||
import shippingOptionRoutes from "./shipping-options"
|
||||
import middlewares from "../../middlewares"
|
||||
|
||||
const route = Router()
|
||||
@@ -10,6 +11,7 @@ export default app => {
|
||||
|
||||
productRoutes(route)
|
||||
cartRoutes(route)
|
||||
shippingOptionRoutes(route)
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
import { request } from "../../../../../helpers/test-request"
|
||||
import { carts, CartServiceMock } from "../../../../../services/__mocks__/cart"
|
||||
import { ShippingProfileServiceMock } from "../../../../../services/__mocks__/shipping-profile"
|
||||
|
||||
describe("GET /store/shipping-options", () => {
|
||||
describe("retrieves shipping options", () => {
|
||||
let subject
|
||||
|
||||
beforeAll(async () => {
|
||||
subject = await request("GET", `/store/shipping-options`, {
|
||||
payload: {
|
||||
cart_id: IdMap.getId("emptyCart"),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("calls CartService retrieve", () => {
|
||||
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(1)
|
||||
expect(CartServiceMock.retrieve).toHaveBeenCalledWith(
|
||||
IdMap.getId("emptyCart")
|
||||
)
|
||||
})
|
||||
|
||||
it("calls ShippingProfileService fetchCartOptions", () => {
|
||||
expect(ShippingProfileServiceMock.fetchCartOptions).toHaveBeenCalledTimes(
|
||||
1
|
||||
)
|
||||
expect(ShippingProfileServiceMock.fetchCartOptions).toHaveBeenCalledWith(
|
||||
carts.emptyCart
|
||||
)
|
||||
})
|
||||
|
||||
it("returns 200", () => {
|
||||
expect(subject.status).toEqual(200)
|
||||
})
|
||||
|
||||
it("returns the cart", () => {
|
||||
expect(subject.body[0]._id).toEqual(IdMap.getId("cartShippingOption"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Validator, MedusaError } from "medusa-core-utils"
|
||||
|
||||
export default async (req, res) => {
|
||||
const schema = Validator.object().keys({
|
||||
cart_id: Validator.string(),
|
||||
})
|
||||
|
||||
const { value, error } = schema.validate(req.body)
|
||||
if (error) {
|
||||
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
|
||||
}
|
||||
|
||||
try {
|
||||
const cartService = req.scope.resolve("cartService")
|
||||
const shippingProfileService = req.scope.resolve("shippingProfileService")
|
||||
|
||||
const cart = await cartService.retrieve(value.cart_id)
|
||||
const options = await shippingProfileService.fetchCartOptions(cart)
|
||||
|
||||
res.status(200).json(options)
|
||||
} catch (err) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Router } from "express"
|
||||
import middlewares from "../../../middlewares"
|
||||
|
||||
const route = Router()
|
||||
|
||||
export default app => {
|
||||
app.use("/shipping-options", route)
|
||||
|
||||
route.get("/", middlewares.wrap(require("./get-shipping-options").default))
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import glob from "glob"
|
||||
import { BaseModel, BaseService, PaymentService } from "medusa-interfaces"
|
||||
import {
|
||||
BaseModel,
|
||||
BaseService,
|
||||
PaymentService,
|
||||
FulfillmentService,
|
||||
} from "medusa-interfaces"
|
||||
import _ from "lodash"
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
@@ -60,7 +65,7 @@ function registerServices(pluginDetails, container) {
|
||||
|
||||
if (!(loaded.prototype instanceof BaseService)) {
|
||||
const logger = container.resolve("logger")
|
||||
const message = `Models must inherit from BaseModel, please check ${fn}`
|
||||
const message = `Services must inherit from BaseService, please check ${fn}`
|
||||
logger.error(message)
|
||||
throw new Error(message)
|
||||
}
|
||||
@@ -79,6 +84,20 @@ function registerServices(pluginDetails, container) {
|
||||
cradle => new loaded(cradle, pluginDetails.options)
|
||||
),
|
||||
})
|
||||
} else if (loaded.prototype instanceof FulfillmentService) {
|
||||
// Register our payment providers to paymentProviders
|
||||
container.registerAdd(
|
||||
"fulfillmentProviders",
|
||||
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({
|
||||
[`fp_${loaded.identifier}`]: asFunction(
|
||||
cradle => new loaded(cradle, pluginDetails.options)
|
||||
),
|
||||
})
|
||||
} else {
|
||||
const name = formatRegistrationName(fn)
|
||||
container.register({
|
||||
|
||||
@@ -204,7 +204,7 @@ export const carts = {
|
||||
_id: IdMap.getId("eur-8-us-10"),
|
||||
},
|
||||
product: {
|
||||
_id: IdMap.getId("product"),
|
||||
_id: IdMap.getId("product1"),
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
@@ -214,7 +214,7 @@ export const carts = {
|
||||
_id: IdMap.getId("eur-10-us-12"),
|
||||
},
|
||||
product: {
|
||||
_id: IdMap.getId("product"),
|
||||
_id: IdMap.getId("product1"),
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
@@ -232,7 +232,7 @@ export const carts = {
|
||||
_id: IdMap.getId("eur-10-us-12"),
|
||||
},
|
||||
product: {
|
||||
_id: IdMap.getId("product"),
|
||||
_id: IdMap.getId("product2"),
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
|
||||
61
packages/medusa/src/models/__mocks__/shipping-option.js
Normal file
61
packages/medusa/src/models/__mocks__/shipping-option.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
|
||||
export const options = {
|
||||
validOption: {
|
||||
_id: IdMap.getId("validId"),
|
||||
name: "Default Option",
|
||||
region_id: IdMap.getId("fr-region"),
|
||||
provider_id: "default_provider",
|
||||
data: {
|
||||
id: "bonjour",
|
||||
},
|
||||
requirements: [
|
||||
{
|
||||
_id: "requirement_id",
|
||||
type: "min_subtotal",
|
||||
value: 100,
|
||||
},
|
||||
],
|
||||
price: {
|
||||
type: "flat_rate",
|
||||
amount: 10,
|
||||
},
|
||||
},
|
||||
noCalc: {
|
||||
_id: IdMap.getId("noCalc"),
|
||||
name: "No Calc",
|
||||
region_id: IdMap.getId("fr-region"),
|
||||
provider_id: "default_provider",
|
||||
data: {
|
||||
id: "bobo",
|
||||
},
|
||||
requirements: [
|
||||
{
|
||||
_id: "requirement_id",
|
||||
type: "min_subtotal",
|
||||
value: 100,
|
||||
},
|
||||
],
|
||||
price: {
|
||||
type: "flat_rate",
|
||||
amount: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const ShippingOptionModelMock = {
|
||||
create: jest.fn().mockReturnValue(Promise.resolve()),
|
||||
updateOne: jest.fn().mockImplementation((query, update) => {
|
||||
return Promise.resolve()
|
||||
}),
|
||||
deleteOne: jest.fn().mockReturnValue(Promise.resolve()),
|
||||
findOne: jest.fn().mockImplementation(query => {
|
||||
if (query._id === IdMap.getId("noCalc")) {
|
||||
return Promise.resolve(options.noCalc)
|
||||
}
|
||||
if (query._id === IdMap.getId("validId")) {
|
||||
return Promise.resolve(options.validOption)
|
||||
}
|
||||
return Promise.resolve(undefined)
|
||||
}),
|
||||
}
|
||||
42
packages/medusa/src/models/__mocks__/shipping-profile.js
Normal file
42
packages/medusa/src/models/__mocks__/shipping-profile.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
|
||||
export const profiles = {
|
||||
validProfile: {
|
||||
_id: IdMap.getId("validId"),
|
||||
name: "Default Profile",
|
||||
products: [IdMap.getId("validId")],
|
||||
shipping_options: [IdMap.getId("validId")],
|
||||
},
|
||||
profile1: {
|
||||
_id: IdMap.getId("profile1"),
|
||||
name: "Profile One",
|
||||
products: [IdMap.getId("product1")],
|
||||
shipping_options: [IdMap.getId("shipping1")],
|
||||
},
|
||||
}
|
||||
|
||||
export const ShippingProfileModelMock = {
|
||||
create: jest.fn().mockReturnValue(Promise.resolve()),
|
||||
updateOne: jest.fn().mockImplementation((query, update) => {
|
||||
return Promise.resolve()
|
||||
}),
|
||||
find: jest.fn().mockImplementation(query => {
|
||||
return Promise.resolve([])
|
||||
}),
|
||||
deleteOne: jest.fn().mockReturnValue(Promise.resolve()),
|
||||
findOne: jest.fn().mockImplementation(query => {
|
||||
if (query.shipping_options === IdMap.getId("validId")) {
|
||||
return Promise.resolve(profiles.validProfile)
|
||||
}
|
||||
if (query.products === IdMap.getId("validId")) {
|
||||
return Promise.resolve(profiles.validProfile)
|
||||
}
|
||||
if (query._id === IdMap.getId("validId")) {
|
||||
return Promise.resolve(profiles.validProfile)
|
||||
}
|
||||
if (query._id === IdMap.getId("profile1")) {
|
||||
return Promise.resolve(profiles.profile1)
|
||||
}
|
||||
return Promise.resolve(undefined)
|
||||
}),
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
/*******************************************************************************
|
||||
*
|
||||
******************************************************************************/
|
||||
import mongoose from "mongoose"
|
||||
|
||||
export default new mongoose.Schema({
|
||||
provider_id: { type: String, required: true },
|
||||
data: { type: mongoose.Schema.Types.Mixed, default: {} },
|
||||
items: { type: [mongoose.Schema.Types.Mixed], default: [] },
|
||||
})
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import mongoose from "mongoose"
|
||||
|
||||
export default new mongoose.Schema({
|
||||
type: { type: String, required: true },
|
||||
amount: { type: Number },
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import mongoose from "mongoose"
|
||||
|
||||
export default new mongoose.Schema({
|
||||
type: { type: String, required: true },
|
||||
value: { type: Number, required: true },
|
||||
})
|
||||
20
packages/medusa/src/models/shipping-option.js
Normal file
20
packages/medusa/src/models/shipping-option.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import mongoose from "mongoose"
|
||||
import { BaseModel } from "medusa-interfaces"
|
||||
|
||||
import ShippingOptionPrice from "./schemas/shipping-option-price"
|
||||
import ShippingOptionRequirement from "./schemas/shipping-option-requirement"
|
||||
|
||||
class ShippingOptionModel extends BaseModel {
|
||||
static modelName = "ShippingOption"
|
||||
static schema = {
|
||||
name: { type: String, required: true },
|
||||
region_id: { type: String, required: true },
|
||||
provider_id: { type: String, required: true },
|
||||
data: { type: mongoose.Schema.Types.Mixed, default: {} },
|
||||
price: { type: ShippingOptionPrice, required: true },
|
||||
requirements: { type: [ShippingOptionRequirement], default: [] },
|
||||
metadata: { type: mongoose.Schema.Types.Mixed, default: {} },
|
||||
}
|
||||
}
|
||||
|
||||
export default ShippingOptionModel
|
||||
13
packages/medusa/src/models/shipping-profile.js
Normal file
13
packages/medusa/src/models/shipping-profile.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import mongoose from "mongoose"
|
||||
import { BaseModel } from "medusa-interfaces"
|
||||
|
||||
class ShippingProfileModel extends BaseModel {
|
||||
static modelName = "ShippingProfile"
|
||||
static schema = {
|
||||
name: { type: String, required: true },
|
||||
products: { type: [String], default: [] },
|
||||
shipping_options: { type: [String], default: [] },
|
||||
}
|
||||
}
|
||||
|
||||
export default ShippingProfileModel
|
||||
@@ -0,0 +1,37 @@
|
||||
export const DefaultProviderMock = {
|
||||
validateOption: jest.fn().mockImplementation(data => {
|
||||
if (data.id === "new") {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
|
||||
return Promise.resolve(false)
|
||||
}),
|
||||
canCalculate: jest.fn().mockImplementation(data => {
|
||||
if (data.id === "bonjour") {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
|
||||
return Promise.resolve(false)
|
||||
}),
|
||||
calculatePrice: jest.fn().mockImplementation(data => {
|
||||
return Promise.resolve()
|
||||
}),
|
||||
createOrder: jest.fn().mockImplementation(data => {
|
||||
return Promise.resolve()
|
||||
}),
|
||||
}
|
||||
|
||||
export const FulfillmentProviderServiceMock = {
|
||||
retrieveProvider: jest.fn().mockImplementation(providerId => {
|
||||
if (providerId === "default_provider") {
|
||||
return DefaultProviderMock
|
||||
}
|
||||
throw new Error("Provider Not Found")
|
||||
}),
|
||||
}
|
||||
|
||||
const mock = jest.fn().mockImplementation(() => {
|
||||
return FulfillmentProviderServiceMock
|
||||
})
|
||||
|
||||
export default mock
|
||||
@@ -15,6 +15,18 @@ export const ProductServiceMock = {
|
||||
createDraft: jest.fn().mockImplementation(data => {
|
||||
return Promise.resolve(data)
|
||||
}),
|
||||
retrieve: jest.fn().mockImplementation(id => {
|
||||
if (id === IdMap.getId("validId")) {
|
||||
return Promise.resolve({ _id: IdMap.getId("validId") })
|
||||
}
|
||||
if (id === IdMap.getId("product1")) {
|
||||
return Promise.resolve(products.product1)
|
||||
}
|
||||
if (id === IdMap.getId("product2")) {
|
||||
return Promise.resolve(products.product2)
|
||||
}
|
||||
return Promise.resolve(undefined)
|
||||
}),
|
||||
list: jest.fn().mockImplementation(data => {
|
||||
// Used to retrieve a product based on a variant id see
|
||||
// ProductVariantService.addOptionValue
|
||||
|
||||
@@ -15,6 +15,7 @@ export const regions = {
|
||||
name: "France",
|
||||
countries: ["FR"],
|
||||
payment_providers: ["default_provider", "france-provider"],
|
||||
fulfillment_providers: ["default_provider"],
|
||||
currency_code: "eur",
|
||||
},
|
||||
regionUs: {
|
||||
|
||||
@@ -43,16 +43,35 @@ export const shippingOptions = {
|
||||
},
|
||||
provider_id: "test_shipper",
|
||||
},
|
||||
shipping1: {
|
||||
_id: IdMap.getId("shipping1"),
|
||||
},
|
||||
validId: {
|
||||
_id: IdMap.getId("validId"),
|
||||
},
|
||||
}
|
||||
|
||||
export const ShippingOptionServiceMock = {
|
||||
retrieve: jest.fn().mockImplementation(optionId => {
|
||||
if (optionId === IdMap.getId("shipping1")) {
|
||||
return Promise.resolve(shippingOptions.shipping1)
|
||||
}
|
||||
if (optionId === IdMap.getId("validId")) {
|
||||
return Promise.resolve(shippingOptions.validId)
|
||||
}
|
||||
if (optionId === IdMap.getId("franceShipping")) {
|
||||
return Promise.resolve(shippingOptions.franceShipping)
|
||||
}
|
||||
if (optionId === IdMap.getId("freeShipping")) {
|
||||
return Promise.resolve(shippingOptions.freeShipping)
|
||||
}
|
||||
return Promise.resolve(undefined)
|
||||
}),
|
||||
update: jest.fn().mockReturnValue(Promise.resolve()),
|
||||
list: jest.fn().mockImplementation(data => {
|
||||
if (!data) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
if (data.region_id === IdMap.getId("region-france")) {
|
||||
return Promise.resolve([shippingOptions.franceShipping])
|
||||
}
|
||||
@@ -63,33 +82,36 @@ export const ShippingOptionServiceMock = {
|
||||
])
|
||||
}
|
||||
}),
|
||||
validateCartOption: jest.fn().mockImplementation((method, cart) => {
|
||||
if (method._id === IdMap.getId("freeShipping")) {
|
||||
return Promise.resolve(true)
|
||||
create: jest.fn().mockImplementation(data => {
|
||||
return Promise.resolve(data)
|
||||
}),
|
||||
validateFulfillmentData: jest
|
||||
.fn()
|
||||
.mockImplementation((methodId, data, cart) => {
|
||||
return Promise.resolve(data)
|
||||
}),
|
||||
validateCartOption: jest.fn().mockImplementation((methodId, cart) => {
|
||||
if (methodId === IdMap.getId("freeShipping")) {
|
||||
return Promise.resolve({
|
||||
_id: IdMap.getId("freeShipping"),
|
||||
price: 0,
|
||||
provider_id: "default_provider",
|
||||
})
|
||||
}
|
||||
if (method._id === IdMap.getId("franceShipping")) {
|
||||
return Promise.resolve(true)
|
||||
if (methodId === IdMap.getId("additional")) {
|
||||
return Promise.resolve({
|
||||
_id: IdMap.getId("additional"),
|
||||
price: 0,
|
||||
provider_id: "default_provider",
|
||||
})
|
||||
}
|
||||
if (method._id === IdMap.getId("fail")) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
}),
|
||||
fetchCartOptions: jest.fn().mockImplementation(cart => {
|
||||
if (cart._id === IdMap.getId("cartWithLine")) {
|
||||
return Promise.resolve([
|
||||
{
|
||||
_id: IdMap.getId("freeShipping"),
|
||||
name: "Free Shipping",
|
||||
region_id: IdMap.getId("testRegion"),
|
||||
price: 10,
|
||||
data: {
|
||||
id: "fs",
|
||||
},
|
||||
provider_id: "test_shipper",
|
||||
},
|
||||
])
|
||||
if (methodId === IdMap.getId("fail")) {
|
||||
return Promise.resolve({
|
||||
_id: IdMap.getId("fail"),
|
||||
})
|
||||
}
|
||||
}),
|
||||
delete: jest.fn().mockReturnValue(Promise.resolve()),
|
||||
}
|
||||
|
||||
const mock = jest.fn().mockImplementation(() => {
|
||||
|
||||
124
packages/medusa/src/services/__mocks__/shipping-profile.js
Normal file
124
packages/medusa/src/services/__mocks__/shipping-profile.js
Normal file
@@ -0,0 +1,124 @@
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
|
||||
export const profiles = {
|
||||
default: {
|
||||
_id: IdMap.getId("default"),
|
||||
name: "default_profile",
|
||||
products: [],
|
||||
shipping_options: [],
|
||||
},
|
||||
}
|
||||
|
||||
export const ShippingProfileServiceMock = {
|
||||
update: jest.fn().mockImplementation(data => {
|
||||
return Promise.resolve()
|
||||
}),
|
||||
create: jest.fn().mockImplementation(data => {
|
||||
return Promise.resolve(data)
|
||||
}),
|
||||
retrieve: jest.fn().mockImplementation(profileId => {
|
||||
return Promise.resolve()
|
||||
}),
|
||||
list: jest.fn().mockImplementation(selector => {
|
||||
if (!selector) {
|
||||
return Promise.resolve([])
|
||||
}
|
||||
if (selector.shipping_options === IdMap.getId("fail")) {
|
||||
return Promise.resolve([])
|
||||
}
|
||||
if (selector.shipping_options === IdMap.getId("freeShipping")) {
|
||||
return Promise.resolve([{ _id: "default_profile" }])
|
||||
}
|
||||
if (selector.shipping_options === IdMap.getId("additional")) {
|
||||
return Promise.resolve([{ _id: "additional_profile" }])
|
||||
}
|
||||
if (
|
||||
selector.products &&
|
||||
selector.products.$in.includes(IdMap.getId("product"))
|
||||
) {
|
||||
return Promise.resolve([
|
||||
{
|
||||
name: "default",
|
||||
products: [IdMap.getId("product")],
|
||||
shipping_options: [
|
||||
{
|
||||
_id: IdMap.getId("freeShipping"),
|
||||
name: "Free Shipping",
|
||||
region_id: IdMap.getId("testRegion"),
|
||||
price: {
|
||||
type: "flat_rate",
|
||||
amount: 10,
|
||||
},
|
||||
requirements: [{ type: "max_subtotal", value: 1000 }],
|
||||
data: {
|
||||
id: "fs",
|
||||
},
|
||||
provider_id: "test_shipper",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
if (
|
||||
selector.products &&
|
||||
selector.products.$in.includes(IdMap.getId("product1"))
|
||||
) {
|
||||
return Promise.resolve([
|
||||
{
|
||||
name: "default1",
|
||||
products: [IdMap.getId("product1")],
|
||||
shipping_options: [
|
||||
{
|
||||
_id: IdMap.getId("freeShipping"),
|
||||
name: "Free Shipping",
|
||||
region_id: IdMap.getId("testRegion"),
|
||||
price: {
|
||||
type: "flat_rate",
|
||||
amount: 10,
|
||||
},
|
||||
requirements: [{ type: "max_subtotal", value: 1000 }],
|
||||
data: {
|
||||
id: "fs",
|
||||
},
|
||||
provider_id: "test_shipper",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "default2",
|
||||
products: [IdMap.getId("product2")],
|
||||
shipping_options: [
|
||||
{
|
||||
_id: IdMap.getId("freeShipping"),
|
||||
name: "Free French Shipping",
|
||||
region_id: IdMap.getId("region-france"),
|
||||
price: {
|
||||
type: "flat_rate",
|
||||
amount: 10,
|
||||
},
|
||||
requirements: [{ type: "max_subtotal", value: 1000 }],
|
||||
data: {
|
||||
id: "fs",
|
||||
},
|
||||
provider_id: "test_shipper",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}
|
||||
}),
|
||||
addShippingOption: jest.fn().mockImplementation(() => Promise.resolve()),
|
||||
removeShippingOption: jest.fn().mockImplementation(() => Promise.resolve()),
|
||||
addProduct: jest.fn().mockImplementation(() => Promise.resolve()),
|
||||
removeProduct: jest.fn().mockImplementation(() => Promise.resolve()),
|
||||
fetchCartOptions: jest.fn().mockImplementation(() => {
|
||||
return Promise.resolve([{ _id: IdMap.getId("cartShippingOption") }])
|
||||
}),
|
||||
}
|
||||
|
||||
const mock = jest.fn().mockImplementation(() => {
|
||||
return ShippingProfileServiceMock
|
||||
})
|
||||
|
||||
export default mock
|
||||
@@ -1,7 +1,10 @@
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
|
||||
export const TotalsServiceMock = {
|
||||
getSubTotal: jest.fn().mockImplementation(cart => {
|
||||
getSubtotal: jest.fn().mockImplementation(cart => {
|
||||
if (cart.subtotal) {
|
||||
return cart.subtotal
|
||||
}
|
||||
if (cart._id === IdMap.getId("discount-cart")) {
|
||||
return 280
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { ProductVariantServiceMock } from "../__mocks__/product-variant"
|
||||
import { RegionServiceMock } from "../__mocks__/region"
|
||||
import { ShippingOptionServiceMock } from "../__mocks__/shipping-option"
|
||||
import { ShippingProfileServiceMock } from "../__mocks__/shipping-profile"
|
||||
import { CartModelMock, carts } from "../../models/__mocks__/cart"
|
||||
import { LineItemServiceMock } from "../__mocks__/line-item"
|
||||
import { DiscountModelMock, discounts } from "../../models/__mocks__/discount"
|
||||
@@ -541,7 +542,7 @@ describe("CartService", () => {
|
||||
_id: IdMap.getId("eur-8-us-10"),
|
||||
},
|
||||
product: {
|
||||
_id: IdMap.getId("product"),
|
||||
_id: IdMap.getId("product1"),
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
@@ -551,7 +552,7 @@ describe("CartService", () => {
|
||||
_id: IdMap.getId("eur-10-us-12"),
|
||||
},
|
||||
product: {
|
||||
_id: IdMap.getId("product"),
|
||||
_id: IdMap.getId("product1"),
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
@@ -569,7 +570,7 @@ describe("CartService", () => {
|
||||
_id: IdMap.getId("eur-10-us-12"),
|
||||
},
|
||||
product: {
|
||||
_id: IdMap.getId("product"),
|
||||
_id: IdMap.getId("product2"),
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
@@ -934,55 +935,6 @@ describe("CartService", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("setShippingOptions", () => {
|
||||
const cartService = new CartService({
|
||||
cartModel: CartModelMock,
|
||||
regionService: RegionServiceMock,
|
||||
shippingOptionService: ShippingOptionServiceMock,
|
||||
})
|
||||
|
||||
describe("gets shipping options from the cart's regions", () => {
|
||||
beforeAll(async () => {
|
||||
jest.clearAllMocks()
|
||||
await cartService.setShippingOptions(IdMap.getId("cartWithLine"))
|
||||
})
|
||||
|
||||
it("gets shipping options from region", () => {
|
||||
expect(
|
||||
ShippingOptionServiceMock.fetchCartOptions
|
||||
).toHaveBeenCalledTimes(1)
|
||||
expect(ShippingOptionServiceMock.fetchCartOptions).toHaveBeenCalledWith(
|
||||
carts.cartWithLine
|
||||
)
|
||||
})
|
||||
|
||||
it("updates cart", () => {
|
||||
expect(CartModelMock.updateOne).toHaveBeenCalledTimes(1)
|
||||
expect(CartModelMock.updateOne).toHaveBeenCalledWith(
|
||||
{
|
||||
_id: IdMap.getId("cartWithLine"),
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
shipping_options: [
|
||||
{
|
||||
_id: IdMap.getId("freeShipping"),
|
||||
name: "Free Shipping",
|
||||
region_id: IdMap.getId("testRegion"),
|
||||
price: 10,
|
||||
data: {
|
||||
id: "fs",
|
||||
},
|
||||
provider_id: "test_shipper",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("retrievePaymentSession", () => {
|
||||
const cartService = new CartService({
|
||||
cartModel: CartModelMock,
|
||||
@@ -1045,35 +997,49 @@ describe("CartService", () => {
|
||||
describe("addShippingMethod", () => {
|
||||
const cartService = new CartService({
|
||||
cartModel: CartModelMock,
|
||||
shippingProfileService: ShippingProfileServiceMock,
|
||||
shippingOptionService: ShippingOptionServiceMock,
|
||||
})
|
||||
|
||||
describe("successfully adds the shipping method", () => {
|
||||
const method = {
|
||||
_id: IdMap.getId("freeShipping"),
|
||||
provider_id: "test_shipper",
|
||||
profile_id: "default_profile",
|
||||
price: 20,
|
||||
region_id: IdMap.getId("testRegion"),
|
||||
data: {
|
||||
id: "testshipperid",
|
||||
},
|
||||
products: [IdMap.getId("product")],
|
||||
const data = {
|
||||
id: "testshipperid",
|
||||
extra: "yes",
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.clearAllMocks()
|
||||
const cartId = IdMap.getId("cartWithPaySessions")
|
||||
await cartService.addShippingMethod(cartId, method)
|
||||
await cartService.addShippingMethod(
|
||||
cartId,
|
||||
IdMap.getId("freeShipping"),
|
||||
data
|
||||
)
|
||||
})
|
||||
|
||||
it("checks availability", () => {
|
||||
it("validates option", () => {
|
||||
expect(
|
||||
ShippingOptionServiceMock.validateCartOption
|
||||
).toHaveBeenCalledTimes(1)
|
||||
).toHaveBeenCalledWith(
|
||||
IdMap.getId("freeShipping"),
|
||||
carts.cartWithPaySessions
|
||||
)
|
||||
})
|
||||
|
||||
it("validates fulfillment data", () => {
|
||||
expect(
|
||||
ShippingOptionServiceMock.validateCartOption
|
||||
).toHaveBeenCalledWith(method, carts.cartWithPaySessions)
|
||||
ShippingOptionServiceMock.validateFulfillmentData
|
||||
).toHaveBeenCalledWith(
|
||||
IdMap.getId("freeShipping"),
|
||||
data,
|
||||
carts.cartWithPaySessions
|
||||
)
|
||||
})
|
||||
|
||||
it("gets shipping profile", () => {
|
||||
expect(ShippingProfileServiceMock.list).toHaveBeenCalledWith({
|
||||
shipping_options: IdMap.getId("freeShipping"),
|
||||
})
|
||||
})
|
||||
|
||||
it("updates cart", () => {
|
||||
@@ -1083,38 +1049,35 @@ describe("CartService", () => {
|
||||
_id: IdMap.getId("cartWithPaySessions"),
|
||||
},
|
||||
{
|
||||
$set: { shipping_methods: [method] },
|
||||
$set: {
|
||||
shipping_methods: [
|
||||
{
|
||||
_id: IdMap.getId("freeShipping"),
|
||||
price: 0,
|
||||
provider_id: "default_provider",
|
||||
profile_id: "default_profile",
|
||||
data,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("successfully overrides existing profile shipping method", () => {
|
||||
const method = {
|
||||
_id: IdMap.getId("freeShipping"),
|
||||
provider_id: "test_shipper",
|
||||
profile_id: "default_profile",
|
||||
price: 20,
|
||||
region_id: IdMap.getId("testRegion"),
|
||||
data: {
|
||||
id: "testshipperid",
|
||||
},
|
||||
products: [IdMap.getId("product")],
|
||||
const data = {
|
||||
id: "testshipperid",
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.clearAllMocks()
|
||||
const cartId = IdMap.getId("fr-cart")
|
||||
await cartService.addShippingMethod(cartId, method)
|
||||
})
|
||||
|
||||
it("checks availability", () => {
|
||||
expect(
|
||||
ShippingOptionServiceMock.validateCartOption
|
||||
).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
ShippingOptionServiceMock.validateCartOption
|
||||
).toHaveBeenCalledWith(method, carts.frCart)
|
||||
await cartService.addShippingMethod(
|
||||
cartId,
|
||||
IdMap.getId("freeShipping"),
|
||||
data
|
||||
)
|
||||
})
|
||||
|
||||
it("updates cart", () => {
|
||||
@@ -1124,38 +1087,37 @@ describe("CartService", () => {
|
||||
_id: IdMap.getId("fr-cart"),
|
||||
},
|
||||
{
|
||||
$set: { shipping_methods: [method] },
|
||||
$set: {
|
||||
shipping_methods: [
|
||||
{
|
||||
_id: IdMap.getId("freeShipping"),
|
||||
price: 0,
|
||||
provider_id: "default_provider",
|
||||
profile_id: "default_profile",
|
||||
data: {
|
||||
id: "testshipperid",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("successfully adds additional shipping method", () => {
|
||||
const method = {
|
||||
_id: IdMap.getId("freeShipping"),
|
||||
provider_id: "test_shipper",
|
||||
profile_id: "additional_profile",
|
||||
price: 20,
|
||||
region_id: IdMap.getId("testRegion"),
|
||||
data: {
|
||||
id: "testshipperid",
|
||||
},
|
||||
products: [IdMap.getId("product")],
|
||||
const data = {
|
||||
id: "additional_shipper_id",
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.clearAllMocks()
|
||||
const cartId = IdMap.getId("fr-cart")
|
||||
await cartService.addShippingMethod(cartId, method)
|
||||
})
|
||||
|
||||
it("checks availability", () => {
|
||||
expect(
|
||||
ShippingOptionServiceMock.validateCartOption
|
||||
).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
ShippingOptionServiceMock.validateCartOption
|
||||
).toHaveBeenCalledWith(method, carts.frCart)
|
||||
await cartService.addShippingMethod(
|
||||
cartId,
|
||||
IdMap.getId("additional"),
|
||||
data
|
||||
)
|
||||
})
|
||||
|
||||
it("updates cart", () => {
|
||||
@@ -1171,7 +1133,13 @@ describe("CartService", () => {
|
||||
_id: IdMap.getId("freeShipping"),
|
||||
profile_id: "default_profile",
|
||||
},
|
||||
method,
|
||||
{
|
||||
_id: IdMap.getId("additional"),
|
||||
price: 0,
|
||||
profile_id: "additional_profile",
|
||||
provider_id: "default_provider",
|
||||
data,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -1179,90 +1147,21 @@ describe("CartService", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("throws error on no availability", () => {
|
||||
const method = {
|
||||
_id: IdMap.getId("fail"),
|
||||
}
|
||||
|
||||
describe("throws if no profile", () => {
|
||||
let res
|
||||
beforeAll(async () => {
|
||||
jest.clearAllMocks()
|
||||
const cartId = IdMap.getId("fr-cart")
|
||||
try {
|
||||
await cartService.addShippingMethod(cartId, method)
|
||||
await cartService.addShippingMethod(cartId, IdMap.getId("fail"), {})
|
||||
} catch (err) {
|
||||
res = err
|
||||
}
|
||||
})
|
||||
|
||||
it("checks availability", () => {
|
||||
expect(
|
||||
ShippingOptionServiceMock.validateCartOption
|
||||
).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
ShippingOptionServiceMock.validateCartOption
|
||||
).toHaveBeenCalledWith(method, carts.frCart)
|
||||
})
|
||||
|
||||
it("throw error", () => {
|
||||
expect(res.message).toEqual(
|
||||
"The selected shipping method cannot be applied to the cart"
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("retrieveShippingOption", () => {
|
||||
const cartService = new CartService({
|
||||
cartModel: CartModelMock,
|
||||
})
|
||||
|
||||
let res
|
||||
|
||||
describe("it retrieves the correct payment session", () => {
|
||||
beforeAll(async () => {
|
||||
jest.clearAllMocks()
|
||||
res = await cartService.retrieveShippingOption(
|
||||
IdMap.getId("fr-cart"),
|
||||
IdMap.getId("freeShipping")
|
||||
)
|
||||
})
|
||||
|
||||
it("retrieves the cart", () => {
|
||||
expect(CartModelMock.findOne).toHaveBeenCalledTimes(1)
|
||||
expect(CartModelMock.findOne).toHaveBeenCalledWith({
|
||||
_id: IdMap.getId("fr-cart"),
|
||||
})
|
||||
})
|
||||
|
||||
it("finds the correct payment session", () => {
|
||||
expect(res._id).toEqual(IdMap.getId("freeShipping"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("it fails when provider doesn't match open session", () => {
|
||||
beforeAll(async () => {
|
||||
jest.clearAllMocks()
|
||||
try {
|
||||
await cartService.retrieveShippingOption(
|
||||
IdMap.getId("fr-cart"),
|
||||
"nono"
|
||||
)
|
||||
} catch (err) {
|
||||
res = err
|
||||
}
|
||||
})
|
||||
|
||||
it("retrieves the cart", () => {
|
||||
expect(CartModelMock.findOne).toHaveBeenCalledTimes(1)
|
||||
expect(CartModelMock.findOne).toHaveBeenCalledWith({
|
||||
_id: IdMap.getId("fr-cart"),
|
||||
})
|
||||
})
|
||||
|
||||
it("throws invalid data errro", () => {
|
||||
expect(res.message).toEqual(
|
||||
"The option id doesn't match any available shipping options"
|
||||
"Shipping Method must belong to a shipping profile"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
614
packages/medusa/src/services/__tests__/shipping-option.js
Normal file
614
packages/medusa/src/services/__tests__/shipping-option.js
Normal file
@@ -0,0 +1,614 @@
|
||||
import mongoose from "mongoose"
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
import ShippingOptionService from "../shipping-option"
|
||||
import { ShippingOptionModelMock } from "../../models/__mocks__/shipping-option"
|
||||
import { RegionServiceMock, regions } from "../__mocks__/region"
|
||||
import { TotalsServiceMock } from "../__mocks__/totals"
|
||||
import {
|
||||
FulfillmentProviderServiceMock,
|
||||
DefaultProviderMock,
|
||||
} from "../__mocks__/fulfillment-provider"
|
||||
|
||||
describe("ShippingOptionService", () => {
|
||||
describe("retrieve", () => {
|
||||
describe("successfully get profile", () => {
|
||||
let res
|
||||
beforeAll(async () => {
|
||||
const optionService = new ShippingOptionService({
|
||||
shippingOptionModel: ShippingOptionModelMock,
|
||||
})
|
||||
|
||||
res = await optionService.retrieve(IdMap.getId("validId"))
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("calls model layer findOne", () => {
|
||||
expect(ShippingOptionModelMock.findOne).toHaveBeenCalledTimes(1)
|
||||
expect(ShippingOptionModelMock.findOne).toHaveBeenCalledWith({
|
||||
_id: IdMap.getId("validId"),
|
||||
})
|
||||
})
|
||||
|
||||
it("returns correct product", () => {
|
||||
expect(res.name).toEqual("Default Option")
|
||||
})
|
||||
})
|
||||
|
||||
describe("query fail", () => {
|
||||
let res
|
||||
beforeAll(async () => {
|
||||
const optionService = new ShippingOptionService({
|
||||
shippingOptionModel: ShippingOptionModelMock,
|
||||
})
|
||||
|
||||
await optionService.retrieve(IdMap.getId("failId")).catch(err => {
|
||||
res = err
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("calls model layer findOne", () => {
|
||||
expect(ShippingOptionModelMock.findOne).toHaveBeenCalledTimes(1)
|
||||
expect(ShippingOptionModelMock.findOne).toHaveBeenCalledWith({
|
||||
_id: IdMap.getId("failId"),
|
||||
})
|
||||
})
|
||||
|
||||
it("model query throws error", () => {
|
||||
expect(res.name).toEqual("not_found")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("setMetadata", () => {
|
||||
const optionService = new ShippingOptionService({
|
||||
shippingOptionModel: ShippingOptionModelMock,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("calls updateOne with correct params", async () => {
|
||||
const id = mongoose.Types.ObjectId()
|
||||
await optionService.setMetadata(`${id}`, "metadata", "testMetadata")
|
||||
|
||||
expect(ShippingOptionModelMock.updateOne).toBeCalledTimes(1)
|
||||
expect(ShippingOptionModelMock.updateOne).toBeCalledWith(
|
||||
{ _id: `${id}` },
|
||||
{ $set: { "metadata.metadata": "testMetadata" } }
|
||||
)
|
||||
})
|
||||
|
||||
it("throw error on invalid key type", async () => {
|
||||
const id = mongoose.Types.ObjectId()
|
||||
|
||||
expect(() => optionService.setMetadata(`${id}`, 1234, "nono")).toThrow(
|
||||
"Key type is invalid. Metadata keys must be strings"
|
||||
)
|
||||
})
|
||||
|
||||
it("throws error on invalid optionId type", async () => {
|
||||
expect(() =>
|
||||
optionService.setMetadata("fakeProfileId", 1234, "nono")
|
||||
).toThrow("The shippingOptionId could not be casted to an ObjectId")
|
||||
})
|
||||
})
|
||||
|
||||
describe("update", () => {
|
||||
const optionService = new ShippingOptionService({
|
||||
shippingOptionModel: ShippingOptionModelMock,
|
||||
fulfillmentProviderService: FulfillmentProviderServiceMock,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("calls updateOne with correct params", async () => {
|
||||
const id = IdMap.getId("validId")
|
||||
|
||||
await optionService.update(`${id}`, { name: "new title" })
|
||||
|
||||
expect(ShippingOptionModelMock.updateOne).toBeCalledTimes(1)
|
||||
expect(ShippingOptionModelMock.updateOne).toBeCalledWith(
|
||||
{ _id: `${id}` },
|
||||
{ $set: { name: "new title" } },
|
||||
{ runValidators: true }
|
||||
)
|
||||
})
|
||||
|
||||
it("sets requirements", async () => {
|
||||
const requirements = [
|
||||
{
|
||||
type: "min_subtotal",
|
||||
value: 1,
|
||||
},
|
||||
]
|
||||
|
||||
await optionService.update(IdMap.getId("validId"), { requirements })
|
||||
|
||||
expect(ShippingOptionModelMock.updateOne).toHaveBeenCalledTimes(1)
|
||||
expect(ShippingOptionModelMock.updateOne).toHaveBeenCalledWith(
|
||||
{ _id: IdMap.getId("validId") },
|
||||
{ $set: { requirements } },
|
||||
{ runValidators: true }
|
||||
)
|
||||
})
|
||||
|
||||
it("fails on invalid req", async () => {
|
||||
const requirements = [
|
||||
{
|
||||
type: "_",
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
type: "min_subtotal",
|
||||
value: 1,
|
||||
},
|
||||
]
|
||||
|
||||
await expect(
|
||||
optionService.update(IdMap.getId("validId"), { requirements })
|
||||
).rejects.toThrow(
|
||||
"Requirement type must be one of min_subtotal, max_subtotal"
|
||||
)
|
||||
})
|
||||
|
||||
it("fails on duplicate reqs", async () => {
|
||||
const requirements = [
|
||||
{
|
||||
type: "min_subtotal",
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
type: "min_subtotal",
|
||||
value: 1,
|
||||
},
|
||||
]
|
||||
|
||||
await expect(
|
||||
optionService.update(IdMap.getId("validId"), { requirements })
|
||||
).rejects.toThrow("Only one requirement of each type is allowed")
|
||||
})
|
||||
|
||||
it("sets flat rate price", async () => {
|
||||
await optionService.update(IdMap.getId("validId"), {
|
||||
price: {
|
||||
type: "flat_rate",
|
||||
amount: 100,
|
||||
},
|
||||
})
|
||||
|
||||
expect(ShippingOptionModelMock.findOne).toHaveBeenCalledTimes(1)
|
||||
expect(ShippingOptionModelMock.findOne).toHaveBeenCalledWith({
|
||||
_id: IdMap.getId("validId"),
|
||||
})
|
||||
|
||||
expect(ShippingOptionModelMock.updateOne).toHaveBeenCalledTimes(1)
|
||||
expect(ShippingOptionModelMock.updateOne).toHaveBeenCalledWith(
|
||||
{ _id: IdMap.getId("validId") },
|
||||
{ $set: { price: { type: "flat_rate", amount: 100 } } },
|
||||
{ runValidators: true }
|
||||
)
|
||||
})
|
||||
|
||||
it("sets calculated price", async () => {
|
||||
await optionService.update(IdMap.getId("validId"), {
|
||||
price: {
|
||||
type: "calculated",
|
||||
},
|
||||
})
|
||||
|
||||
expect(ShippingOptionModelMock.findOne).toHaveBeenCalledTimes(1)
|
||||
expect(ShippingOptionModelMock.findOne).toHaveBeenCalledWith({
|
||||
_id: IdMap.getId("validId"),
|
||||
})
|
||||
|
||||
expect(DefaultProviderMock.canCalculate).toHaveBeenCalledTimes(1)
|
||||
expect(DefaultProviderMock.canCalculate).toHaveBeenCalledWith({
|
||||
id: "bonjour",
|
||||
})
|
||||
|
||||
expect(ShippingOptionModelMock.updateOne).toHaveBeenCalledTimes(1)
|
||||
expect(ShippingOptionModelMock.updateOne).toHaveBeenCalledWith(
|
||||
{ _id: IdMap.getId("validId") },
|
||||
{ $set: { price: { type: "calculated" } } },
|
||||
{ runValidators: true }
|
||||
)
|
||||
})
|
||||
|
||||
it("fails on invalid type", async () => {
|
||||
await expect(
|
||||
optionService.update(IdMap.getId("validId"), {
|
||||
price: {
|
||||
type: "non",
|
||||
},
|
||||
})
|
||||
).rejects.toThrow("The price must be of type flat_rate or calculated")
|
||||
})
|
||||
|
||||
it("fails if provider cannot calculate", async () => {
|
||||
await expect(
|
||||
optionService.update(IdMap.getId("noCalc"), {
|
||||
price: {
|
||||
type: "calculated",
|
||||
},
|
||||
})
|
||||
).rejects.toThrow(
|
||||
"The fulfillment provider cannot calculate prices for this option"
|
||||
)
|
||||
})
|
||||
|
||||
it("throw error on invalid shipping id type", async () => {
|
||||
await expect(
|
||||
optionService.update(19314235, { name: "new title" })
|
||||
).rejects.toThrow(
|
||||
"The shippingOptionId could not be casted to an ObjectId"
|
||||
)
|
||||
})
|
||||
|
||||
it("throws error when trying to update metadata", async () => {
|
||||
const id = IdMap.getId("validId")
|
||||
await expect(
|
||||
optionService.update(`${id}`, { metadata: { key: "value" } })
|
||||
).rejects.toThrow("Use setMetadata to update metadata fields")
|
||||
})
|
||||
|
||||
it("throws error when trying to update region_id", async () => {
|
||||
const id = IdMap.getId("validId")
|
||||
await expect(
|
||||
optionService.update(`${id}`, { region_id: "id" })
|
||||
).rejects.toThrow("Region and Provider cannot be updated after creation")
|
||||
})
|
||||
|
||||
it("throws error when trying to update region_id", async () => {
|
||||
const id = IdMap.getId("validId")
|
||||
await expect(
|
||||
optionService.update(`${id}`, { region_id: "id" })
|
||||
).rejects.toThrow("Region and Provider cannot be updated after creation")
|
||||
})
|
||||
|
||||
it("throws error when trying to update provider_id", async () => {
|
||||
const id = IdMap.getId("validId")
|
||||
await expect(
|
||||
optionService.update(`${id}`, { provider_id: "id" })
|
||||
).rejects.toThrow("Region and Provider cannot be updated after creation")
|
||||
})
|
||||
})
|
||||
|
||||
describe("delete", () => {
|
||||
const optionService = new ShippingOptionService({
|
||||
shippingOptionModel: ShippingOptionModelMock,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("deletes the option successfully", async () => {
|
||||
await optionService.delete(IdMap.getId("validId"))
|
||||
|
||||
expect(ShippingOptionModelMock.deleteOne).toBeCalledTimes(1)
|
||||
expect(ShippingOptionModelMock.deleteOne).toBeCalledWith({
|
||||
_id: IdMap.getId("validId"),
|
||||
})
|
||||
})
|
||||
|
||||
it("is idempotent", async () => {
|
||||
await optionService.delete(IdMap.getId("delete"))
|
||||
|
||||
expect(ShippingOptionModelMock.deleteOne).toBeCalledTimes(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("addRequirement", () => {
|
||||
const optionService = new ShippingOptionService({
|
||||
shippingOptionModel: ShippingOptionModelMock,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("add product to profile successfully", async () => {
|
||||
await optionService.addRequirement(IdMap.getId("validId"), {
|
||||
type: "max_subtotal",
|
||||
value: 10,
|
||||
})
|
||||
|
||||
expect(ShippingOptionModelMock.findOne).toBeCalledTimes(1)
|
||||
expect(ShippingOptionModelMock.findOne).toBeCalledWith({
|
||||
_id: IdMap.getId("validId"),
|
||||
})
|
||||
|
||||
expect(ShippingOptionModelMock.updateOne).toBeCalledTimes(1)
|
||||
expect(ShippingOptionModelMock.updateOne).toBeCalledWith(
|
||||
{ _id: IdMap.getId("validId") },
|
||||
{
|
||||
$push: {
|
||||
requirements: {
|
||||
type: "max_subtotal",
|
||||
value: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("fails if type exists", async () => {
|
||||
await expect(
|
||||
optionService.addRequirement(IdMap.getId("validId"), {
|
||||
type: "min_subtotal",
|
||||
value: 100,
|
||||
})
|
||||
).rejects.toThrow("A requirement with type: min_subtotal already exists")
|
||||
|
||||
expect(ShippingOptionModelMock.updateOne).toBeCalledTimes(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("removeRequirement", () => {
|
||||
const optionService = new ShippingOptionService({
|
||||
shippingOptionModel: ShippingOptionModelMock,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("remove requirement successfully", async () => {
|
||||
await optionService.removeRequirement(
|
||||
IdMap.getId("validId"),
|
||||
"requirement_id"
|
||||
)
|
||||
|
||||
expect(ShippingOptionModelMock.findOne).toBeCalledTimes(1)
|
||||
expect(ShippingOptionModelMock.findOne).toBeCalledWith({
|
||||
_id: IdMap.getId("validId"),
|
||||
})
|
||||
|
||||
expect(ShippingOptionModelMock.updateOne).toBeCalledTimes(1)
|
||||
expect(ShippingOptionModelMock.updateOne).toBeCalledWith(
|
||||
{ _id: IdMap.getId("validId") },
|
||||
{ $pull: { requirements: { _id: "requirement_id" } } }
|
||||
)
|
||||
})
|
||||
|
||||
it("is idempotent", async () => {
|
||||
await optionService.removeRequirement(IdMap.getId("validId"), "something")
|
||||
|
||||
expect(ShippingOptionModelMock.findOne).toBeCalledTimes(1)
|
||||
expect(ShippingOptionModelMock.findOne).toBeCalledWith({
|
||||
_id: IdMap.getId("validId"),
|
||||
})
|
||||
|
||||
expect(ShippingOptionModelMock.updateOne).toBeCalledTimes(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("create", () => {
|
||||
const optionService = new ShippingOptionService({
|
||||
shippingOptionModel: ShippingOptionModelMock,
|
||||
fulfillmentProviderService: FulfillmentProviderServiceMock,
|
||||
regionService: RegionServiceMock,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("creates a shipping option", async () => {
|
||||
const option = {
|
||||
name: "Test Option",
|
||||
provider_id: "default_provider",
|
||||
data: {
|
||||
id: "new",
|
||||
},
|
||||
region_id: IdMap.getId("region-france"),
|
||||
requirements: [
|
||||
{
|
||||
type: "min_subtotal",
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
price: {
|
||||
type: "flat_rate",
|
||||
amount: 13,
|
||||
},
|
||||
}
|
||||
|
||||
await optionService.create(option)
|
||||
|
||||
expect(RegionServiceMock.retrieve).toHaveBeenCalledTimes(1)
|
||||
expect(RegionServiceMock.retrieve).toHaveBeenCalledWith(
|
||||
IdMap.getId("region-france")
|
||||
)
|
||||
|
||||
expect(
|
||||
FulfillmentProviderServiceMock.retrieveProvider
|
||||
).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
FulfillmentProviderServiceMock.retrieveProvider
|
||||
).toHaveBeenCalledWith("default_provider")
|
||||
|
||||
expect(DefaultProviderMock.validateOption).toHaveBeenCalledTimes(1)
|
||||
expect(DefaultProviderMock.validateOption).toHaveBeenCalledWith({
|
||||
id: "new",
|
||||
})
|
||||
|
||||
expect(ShippingOptionModelMock.create).toHaveBeenCalledTimes(1)
|
||||
expect(ShippingOptionModelMock.create).toHaveBeenCalledWith(option)
|
||||
})
|
||||
|
||||
it("fails if region doesn't have fulfillment provider", async () => {
|
||||
const option = {
|
||||
name: "Test Option",
|
||||
provider_id: "testshipper",
|
||||
data: {
|
||||
id: "new",
|
||||
},
|
||||
region_id: IdMap.getId("region-france"),
|
||||
requirements: [
|
||||
{
|
||||
type: "min_subtotal",
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
price: {
|
||||
type: "flat_rate",
|
||||
amount: 13,
|
||||
},
|
||||
}
|
||||
|
||||
await expect(optionService.create(option)).rejects.toThrow(
|
||||
"The fulfillment provider is not available in the provided region"
|
||||
)
|
||||
})
|
||||
|
||||
it("fails if fulfillment provider cannot validate", async () => {
|
||||
const option = {
|
||||
name: "Test Option",
|
||||
provider_id: "default_provider",
|
||||
data: {
|
||||
id: "bno",
|
||||
},
|
||||
region_id: IdMap.getId("region-france"),
|
||||
price: {
|
||||
type: "flat_rate",
|
||||
amount: 13,
|
||||
},
|
||||
}
|
||||
|
||||
await expect(optionService.create(option)).rejects.toThrow(
|
||||
"The fulfillment provider cannot validate the shipping option"
|
||||
)
|
||||
})
|
||||
|
||||
it("fails if requirement is not validated", async () => {
|
||||
const option = {
|
||||
name: "Test Option",
|
||||
provider_id: "default_provider",
|
||||
data: {
|
||||
id: "new",
|
||||
},
|
||||
requirements: [
|
||||
{
|
||||
type: "_subtotal",
|
||||
value: 100,
|
||||
},
|
||||
],
|
||||
region_id: IdMap.getId("region-france"),
|
||||
price: {
|
||||
type: "flat_rate",
|
||||
amount: 13,
|
||||
},
|
||||
}
|
||||
|
||||
await expect(optionService.create(option)).rejects.toThrow(
|
||||
"Requirement type must be one of min_subtotal, max_subtotal"
|
||||
)
|
||||
})
|
||||
|
||||
it("fails if price is not validated", async () => {
|
||||
const option = {
|
||||
name: "Test Option",
|
||||
provider_id: "default_provider",
|
||||
data: {
|
||||
id: "new",
|
||||
},
|
||||
region_id: IdMap.getId("region-france"),
|
||||
price: {
|
||||
type: "nonon",
|
||||
amount: 13,
|
||||
},
|
||||
}
|
||||
|
||||
await expect(optionService.create(option)).rejects.toThrow(
|
||||
"The price must be of type flat_rate or calculated"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("setRequirements", () => {
|
||||
const optionService = new ShippingOptionService({
|
||||
shippingOptionModel: ShippingOptionModelMock,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateCartOption", () => {
|
||||
const optionService = new ShippingOptionService({
|
||||
shippingOptionModel: ShippingOptionModelMock,
|
||||
totalsService: TotalsServiceMock,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("validates", async () => {
|
||||
const cart = {
|
||||
region_id: IdMap.getId("fr-region"),
|
||||
subtotal: 400,
|
||||
}
|
||||
|
||||
const res = await optionService.validateCartOption(
|
||||
IdMap.getId("validId"),
|
||||
cart
|
||||
)
|
||||
|
||||
expect(res).toEqual({
|
||||
_id: IdMap.getId("validId"),
|
||||
name: "Default Option",
|
||||
region_id: IdMap.getId("fr-region"),
|
||||
provider_id: "default_provider",
|
||||
data: {
|
||||
id: "bonjour",
|
||||
},
|
||||
requirements: [
|
||||
{
|
||||
_id: "requirement_id",
|
||||
type: "min_subtotal",
|
||||
value: 100,
|
||||
},
|
||||
],
|
||||
price: 10,
|
||||
})
|
||||
})
|
||||
|
||||
it("fails on invalid req", async () => {
|
||||
const cart = {
|
||||
region_id: IdMap.getId("nomatch"),
|
||||
}
|
||||
|
||||
await expect(
|
||||
optionService.validateCartOption(IdMap.getId("validId"), cart)
|
||||
).rejects.toThrow(
|
||||
"The shipping option is not available in the cart's region"
|
||||
)
|
||||
})
|
||||
|
||||
it("fails if reqs are not satisfied", async () => {
|
||||
const cart = {
|
||||
region_id: IdMap.getId("fr-region"),
|
||||
subtotal: 2,
|
||||
}
|
||||
|
||||
await expect(
|
||||
optionService.validateCartOption(IdMap.getId("validId"), cart)
|
||||
).rejects.toThrow(
|
||||
"The Cart does not satisfy the shipping option's requirements"
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
493
packages/medusa/src/services/__tests__/shipping-profile.js
Normal file
493
packages/medusa/src/services/__tests__/shipping-profile.js
Normal file
@@ -0,0 +1,493 @@
|
||||
import mongoose from "mongoose"
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
import ShippingProfileService from "../shipping-profile"
|
||||
import { ShippingProfileModelMock } from "../../models/__mocks__/shipping-profile"
|
||||
import { ProductServiceMock, products } from "../__mocks__/product"
|
||||
import {
|
||||
ShippingOptionServiceMock,
|
||||
shippingOptions,
|
||||
} from "../__mocks__/shipping-option"
|
||||
|
||||
describe("ShippingProfileService", () => {
|
||||
describe("retrieve", () => {
|
||||
describe("successfully get profile", () => {
|
||||
let res
|
||||
beforeAll(async () => {
|
||||
const profileService = new ShippingProfileService({
|
||||
shippingProfileModel: ShippingProfileModelMock,
|
||||
})
|
||||
|
||||
res = await profileService.retrieve(IdMap.getId("validId"))
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("calls model layer findOne", () => {
|
||||
expect(ShippingProfileModelMock.findOne).toHaveBeenCalledTimes(1)
|
||||
expect(ShippingProfileModelMock.findOne).toHaveBeenCalledWith({
|
||||
_id: IdMap.getId("validId"),
|
||||
})
|
||||
})
|
||||
|
||||
it("returns correct product", () => {
|
||||
expect(res.name).toEqual("Default Profile")
|
||||
})
|
||||
})
|
||||
|
||||
describe("query fail", () => {
|
||||
let res
|
||||
beforeAll(async () => {
|
||||
const profileService = new ShippingProfileService({
|
||||
shippingProfileModel: ShippingProfileModelMock,
|
||||
})
|
||||
|
||||
await profileService.retrieve(IdMap.getId("failId")).catch(err => {
|
||||
res = err
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("calls model layer findOne", () => {
|
||||
expect(ShippingProfileModelMock.findOne).toHaveBeenCalledTimes(1)
|
||||
expect(ShippingProfileModelMock.findOne).toHaveBeenCalledWith({
|
||||
_id: IdMap.getId("failId"),
|
||||
})
|
||||
})
|
||||
|
||||
it("model query throws error", () => {
|
||||
expect(res.name).toEqual("not_found")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("decorate", () => {
|
||||
const profileService = new ShippingProfileService({
|
||||
shippingProfileModel: ShippingProfileModelMock,
|
||||
productService: ProductServiceMock,
|
||||
shippingOptionService: ShippingOptionServiceMock,
|
||||
})
|
||||
|
||||
const fakeProfile = {
|
||||
_id: "1234",
|
||||
name: "Fake",
|
||||
products: [IdMap.getId("product1")],
|
||||
shipping_options: [IdMap.getId("franceShipping")],
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("returns decorated profile", async () => {
|
||||
const decorated = await profileService.decorate(
|
||||
fakeProfile,
|
||||
[],
|
||||
["shipping_options"]
|
||||
)
|
||||
expect(decorated).toEqual({
|
||||
_id: "1234",
|
||||
metadata: {},
|
||||
shipping_options: [shippingOptions.franceShipping],
|
||||
})
|
||||
})
|
||||
|
||||
it("returns decorated profile with name", async () => {
|
||||
const decorated = await profileService.decorate(
|
||||
fakeProfile,
|
||||
["name"],
|
||||
["products"]
|
||||
)
|
||||
expect(decorated).toEqual({
|
||||
_id: "1234",
|
||||
metadata: {},
|
||||
name: "Fake",
|
||||
products: [products.product1],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("setMetadata", () => {
|
||||
const profileService = new ShippingProfileService({
|
||||
shippingProfileModel: ShippingProfileModelMock,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("calls updateOne with correct params", async () => {
|
||||
const id = mongoose.Types.ObjectId()
|
||||
await profileService.setMetadata(`${id}`, "metadata", "testMetadata")
|
||||
|
||||
expect(ShippingProfileModelMock.updateOne).toBeCalledTimes(1)
|
||||
expect(ShippingProfileModelMock.updateOne).toBeCalledWith(
|
||||
{ _id: `${id}` },
|
||||
{ $set: { "metadata.metadata": "testMetadata" } }
|
||||
)
|
||||
})
|
||||
|
||||
it("throw error on invalid key type", async () => {
|
||||
const id = mongoose.Types.ObjectId()
|
||||
|
||||
try {
|
||||
await profileService.setMetadata(`${id}`, 1234, "nono")
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual(
|
||||
"Key type is invalid. Metadata keys must be strings"
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("throws error on invalid profileId type", async () => {
|
||||
try {
|
||||
await profileService.setMetadata("fakeProfileId", 1234, "nono")
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual(
|
||||
"The profileId could not be casted to an ObjectId"
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("update", () => {
|
||||
const profileService = new ShippingProfileService({
|
||||
shippingProfileModel: ShippingProfileModelMock,
|
||||
productService: ProductServiceMock,
|
||||
shippingOptionService: ShippingOptionServiceMock,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("calls updateOne with correct params", async () => {
|
||||
const id = IdMap.getId("validId")
|
||||
|
||||
await profileService.update(`${id}`, { name: "new title" })
|
||||
|
||||
expect(ShippingProfileModelMock.updateOne).toBeCalledTimes(1)
|
||||
expect(ShippingProfileModelMock.updateOne).toBeCalledWith(
|
||||
{ _id: `${id}` },
|
||||
{ $set: { name: "new title" } },
|
||||
{ runValidators: true }
|
||||
)
|
||||
})
|
||||
|
||||
it("calls updateOne products", async () => {
|
||||
const id = IdMap.getId("validId")
|
||||
|
||||
await profileService.update(`${id}`, {
|
||||
products: [IdMap.getId("product1"), IdMap.getId("product1")],
|
||||
})
|
||||
|
||||
expect(ProductServiceMock.retrieve).toBeCalledTimes(1)
|
||||
expect(ProductServiceMock.retrieve).toBeCalledWith(
|
||||
IdMap.getId("product1")
|
||||
)
|
||||
|
||||
expect(ShippingProfileModelMock.updateOne).toBeCalledTimes(1)
|
||||
expect(ShippingProfileModelMock.updateOne).toBeCalledWith(
|
||||
{ _id: `${id}` },
|
||||
{ $set: { products: [IdMap.getId("product1")] } },
|
||||
{ runValidators: true }
|
||||
)
|
||||
})
|
||||
|
||||
it("calls updateOne products", async () => {
|
||||
const id = IdMap.getId("profile1")
|
||||
|
||||
await profileService.update(`${id}`, {
|
||||
products: [IdMap.getId("validId")],
|
||||
})
|
||||
|
||||
expect(ProductServiceMock.retrieve).toBeCalledTimes(1)
|
||||
expect(ProductServiceMock.retrieve).toBeCalledWith(IdMap.getId("validId"))
|
||||
|
||||
expect(ShippingProfileModelMock.updateOne).toBeCalledTimes(2)
|
||||
expect(ShippingProfileModelMock.updateOne).toBeCalledWith(
|
||||
{ _id: IdMap.getId("validId") },
|
||||
{ $pull: { products: IdMap.getId("validId") } }
|
||||
)
|
||||
expect(ShippingProfileModelMock.updateOne).toBeCalledWith(
|
||||
{ _id: `${id}` },
|
||||
{ $set: { products: [IdMap.getId("validId")] } },
|
||||
{ runValidators: true }
|
||||
)
|
||||
})
|
||||
|
||||
it("calls updateOne with shipping options", async () => {
|
||||
const id = IdMap.getId("profile1")
|
||||
|
||||
await profileService.update(`${id}`, {
|
||||
shipping_options: [IdMap.getId("validId")],
|
||||
})
|
||||
|
||||
expect(ShippingOptionServiceMock.retrieve).toBeCalledTimes(1)
|
||||
expect(ShippingOptionServiceMock.retrieve).toBeCalledWith(
|
||||
IdMap.getId("validId")
|
||||
)
|
||||
|
||||
expect(ShippingProfileModelMock.updateOne).toBeCalledTimes(2)
|
||||
expect(ShippingProfileModelMock.updateOne).toBeCalledWith(
|
||||
{ _id: IdMap.getId("validId") },
|
||||
{ $pull: { shipping_options: IdMap.getId("validId") } }
|
||||
)
|
||||
expect(ShippingProfileModelMock.updateOne).toBeCalledWith(
|
||||
{ _id: `${id}` },
|
||||
{ $set: { shipping_options: [IdMap.getId("validId")] } },
|
||||
{ runValidators: true }
|
||||
)
|
||||
})
|
||||
|
||||
it("calls updateOne with shipping options", async () => {
|
||||
const id = IdMap.getId("validId")
|
||||
|
||||
await profileService.update(`${id}`, {
|
||||
shipping_options: [IdMap.getId("validId")],
|
||||
})
|
||||
|
||||
expect(ShippingOptionServiceMock.retrieve).toBeCalledTimes(1)
|
||||
expect(ShippingOptionServiceMock.retrieve).toBeCalledWith(
|
||||
IdMap.getId("validId")
|
||||
)
|
||||
|
||||
expect(ShippingProfileModelMock.updateOne).toBeCalledTimes(1)
|
||||
expect(ShippingProfileModelMock.updateOne).toBeCalledWith(
|
||||
{ _id: `${id}` },
|
||||
{ $set: { shipping_options: [IdMap.getId("validId")] } },
|
||||
{ runValidators: true }
|
||||
)
|
||||
})
|
||||
|
||||
it("throw error on invalid product id type", async () => {
|
||||
await expect(
|
||||
profileService.update(19314235, { name: "new title" })
|
||||
).rejects.toThrow("The profileId could not be casted to an ObjectId")
|
||||
})
|
||||
|
||||
it("throws error when trying to update metadata", async () => {
|
||||
const id = IdMap.getId("validId")
|
||||
await expect(
|
||||
profileService.update(`${id}`, { metadata: { key: "value" } })
|
||||
).rejects.toThrow("Use setMetadata to update metadata fields")
|
||||
})
|
||||
})
|
||||
|
||||
describe("delete", () => {
|
||||
const profileService = new ShippingProfileService({
|
||||
shippingProfileModel: ShippingProfileModelMock,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("deletes the profile successfully", async () => {
|
||||
await profileService.delete(IdMap.getId("validId"))
|
||||
|
||||
expect(ShippingProfileModelMock.deleteOne).toBeCalledTimes(1)
|
||||
expect(ShippingProfileModelMock.deleteOne).toBeCalledWith({
|
||||
_id: IdMap.getId("validId"),
|
||||
})
|
||||
})
|
||||
|
||||
it("is idempotent", async () => {
|
||||
await profileService.delete(IdMap.getId("delete"))
|
||||
|
||||
expect(ShippingProfileModelMock.deleteOne).toBeCalledTimes(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("addProduct", () => {
|
||||
const profileService = new ShippingProfileService({
|
||||
shippingProfileModel: ShippingProfileModelMock,
|
||||
productService: ProductServiceMock,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("add product to profile successfully", async () => {
|
||||
await profileService.addProduct(
|
||||
IdMap.getId("validId"),
|
||||
IdMap.getId("product2")
|
||||
)
|
||||
|
||||
expect(ProductServiceMock.retrieve).toBeCalledTimes(1)
|
||||
expect(ProductServiceMock.retrieve).toBeCalledWith(
|
||||
IdMap.getId("product2")
|
||||
)
|
||||
expect(ShippingProfileModelMock.findOne).toBeCalledTimes(1)
|
||||
expect(ShippingProfileModelMock.findOne).toBeCalledWith({
|
||||
_id: IdMap.getId("validId"),
|
||||
})
|
||||
|
||||
expect(ShippingProfileModelMock.updateOne).toBeCalledTimes(1)
|
||||
expect(ShippingProfileModelMock.updateOne).toBeCalledWith(
|
||||
{ _id: IdMap.getId("validId") },
|
||||
{ $push: { products: IdMap.getId("product2") } }
|
||||
)
|
||||
})
|
||||
|
||||
it("is idempotent", async () => {
|
||||
await profileService.addProduct(
|
||||
IdMap.getId("validId"),
|
||||
IdMap.getId("validId")
|
||||
)
|
||||
|
||||
expect(ProductServiceMock.retrieve).toBeCalledTimes(1)
|
||||
expect(ProductServiceMock.retrieve).toBeCalledWith(IdMap.getId("validId"))
|
||||
expect(ShippingProfileModelMock.findOne).toBeCalledTimes(1)
|
||||
expect(ShippingProfileModelMock.findOne).toBeCalledWith({
|
||||
_id: IdMap.getId("validId"),
|
||||
})
|
||||
|
||||
expect(ShippingProfileModelMock.updateOne).toBeCalledTimes(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("addShippingOption", () => {
|
||||
const profileService = new ShippingProfileService({
|
||||
shippingProfileModel: ShippingProfileModelMock,
|
||||
shippingOptionService: ShippingOptionServiceMock,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("add shipping option to profile successfully", async () => {
|
||||
await profileService.addShippingOption(
|
||||
IdMap.getId("validId"),
|
||||
IdMap.getId("freeShipping")
|
||||
)
|
||||
|
||||
expect(ShippingOptionServiceMock.retrieve).toBeCalledTimes(1)
|
||||
expect(ShippingOptionServiceMock.retrieve).toBeCalledWith(
|
||||
IdMap.getId("freeShipping")
|
||||
)
|
||||
expect(ShippingProfileModelMock.findOne).toBeCalledTimes(1)
|
||||
expect(ShippingProfileModelMock.findOne).toBeCalledWith({
|
||||
_id: IdMap.getId("validId"),
|
||||
})
|
||||
|
||||
expect(ShippingProfileModelMock.updateOne).toBeCalledTimes(1)
|
||||
expect(ShippingProfileModelMock.updateOne).toBeCalledWith(
|
||||
{ _id: IdMap.getId("validId") },
|
||||
{ $push: { shipping_options: IdMap.getId("freeShipping") } }
|
||||
)
|
||||
})
|
||||
|
||||
it("add product is idempotent", async () => {
|
||||
await profileService.addShippingOption(
|
||||
IdMap.getId("validId"),
|
||||
IdMap.getId("validId")
|
||||
)
|
||||
|
||||
expect(ShippingOptionServiceMock.retrieve).toBeCalledTimes(1)
|
||||
expect(ShippingOptionServiceMock.retrieve).toBeCalledWith(
|
||||
IdMap.getId("validId")
|
||||
)
|
||||
expect(ShippingProfileModelMock.findOne).toBeCalledTimes(1)
|
||||
expect(ShippingProfileModelMock.findOne).toBeCalledWith({
|
||||
_id: IdMap.getId("validId"),
|
||||
})
|
||||
|
||||
expect(ShippingProfileModelMock.updateOne).toBeCalledTimes(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("removeShippingOption", () => {
|
||||
const profileService = new ShippingProfileService({
|
||||
shippingProfileModel: ShippingProfileModelMock,
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("deletes a shipping option from a profile", async () => {
|
||||
await profileService.removeShippingOption(
|
||||
IdMap.getId("validId"),
|
||||
IdMap.getId("validId")
|
||||
)
|
||||
|
||||
expect(ShippingProfileModelMock.updateOne).toBeCalledTimes(1)
|
||||
expect(ShippingProfileModelMock.updateOne).toBeCalledWith(
|
||||
{ _id: IdMap.getId("validId") },
|
||||
{ $pull: { shipping_options: IdMap.getId("validId") } }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("removeProduct", () => {
|
||||
const profileService = new ShippingProfileService({
|
||||
shippingProfileModel: ShippingProfileModelMock,
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("deletes a product from a profile", async () => {
|
||||
await profileService.removeProduct(
|
||||
IdMap.getId("validId"),
|
||||
IdMap.getId("validId")
|
||||
)
|
||||
|
||||
expect(ShippingProfileModelMock.updateOne).toBeCalledTimes(1)
|
||||
expect(ShippingProfileModelMock.updateOne).toBeCalledWith(
|
||||
{ _id: IdMap.getId("validId") },
|
||||
{ $pull: { products: IdMap.getId("validId") } }
|
||||
)
|
||||
})
|
||||
|
||||
it("if product does not exist, do nothing", async () => {
|
||||
await profileService.removeProduct(
|
||||
IdMap.getId("validId"),
|
||||
IdMap.getId("produt")
|
||||
)
|
||||
|
||||
expect(ShippingProfileModelMock.updateOne).not.toBeCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("create", () => {
|
||||
const profileService = new ShippingProfileService({
|
||||
shippingProfileModel: ShippingProfileModelMock,
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("successfully creates a new shipping profile", async () => {
|
||||
await profileService.create({
|
||||
name: "New Profile",
|
||||
})
|
||||
|
||||
expect(ShippingProfileModelMock.create).toHaveBeenCalledTimes(1)
|
||||
expect(ShippingProfileModelMock.create).toHaveBeenCalledWith({
|
||||
name: "New Profile",
|
||||
})
|
||||
})
|
||||
|
||||
it("throws if trying to create with products", async () => {
|
||||
await expect(
|
||||
profileService.create({
|
||||
name: "New Profile",
|
||||
products: ["144"],
|
||||
})
|
||||
).rejects.toThrow(
|
||||
"Please add products and shipping_options after creating Shipping Profiles"
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,7 @@ class CartService extends BaseService {
|
||||
regionService,
|
||||
lineItemService,
|
||||
shippingOptionService,
|
||||
shippingProfileService,
|
||||
discountService,
|
||||
}) {
|
||||
super()
|
||||
@@ -41,7 +42,10 @@ class CartService extends BaseService {
|
||||
/** @private @const {PaymentProviderService} */
|
||||
this.paymentProviderService_ = paymentProviderService
|
||||
|
||||
/** @private @const {ShippingOptionsService} */
|
||||
/** @private @const {ShippingProfileService} */
|
||||
this.shippingProfileService_ = shippingProfileService
|
||||
|
||||
/** @private @const {ShippingOptionService} */
|
||||
this.shippingOptionService_ = shippingOptionService
|
||||
|
||||
/** @private @const {DiscountService} */
|
||||
@@ -633,57 +637,50 @@ class CartService extends BaseService {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves one of the open shipping options for the cart.
|
||||
* @param {string} cartId - the id of the cart to retrieve the option from
|
||||
* @param {string} optionId - the id of the option to retrieve
|
||||
* @return {ShippingOption} the option that was found
|
||||
*/
|
||||
async retrieveShippingOption(cartId, optionId) {
|
||||
const cart = await this.retrieve(cartId)
|
||||
|
||||
const option = cart.shipping_options.find(({ _id }) => _id === optionId)
|
||||
|
||||
if (!option) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`The option id doesn't match any available shipping options`
|
||||
)
|
||||
}
|
||||
|
||||
return option
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the shipping method to the list of shipping methods associated with
|
||||
* the cart.
|
||||
* the cart. Shipping Methods are the ways that an order is shipped, whereas a
|
||||
* Shipping Option is a possible way to ship an order. Shipping Methods may
|
||||
* also have additional details in the data field such as an id for a package
|
||||
* shop.
|
||||
* @param {string} cartId - the id of the cart to add shipping method to
|
||||
* @param {ShippingOption} method - the shipping method to add to the cart
|
||||
* @param {ShippingMethod} method - the shipping method to add to the cart
|
||||
* @return {Promise} the result of the update operation
|
||||
*/
|
||||
async addShippingMethod(cartId, method) {
|
||||
async addShippingMethod(cartId, optionId, data) {
|
||||
const cart = await this.retrieve(cartId)
|
||||
const { shipping_methods } = cart
|
||||
|
||||
const isValid = await this.shippingOptionService_.validateCartOption(
|
||||
method,
|
||||
const option = await this.shippingOptionService_.validateCartOption(
|
||||
optionId,
|
||||
cart
|
||||
)
|
||||
|
||||
if (!isValid) {
|
||||
option.data = await this.shippingOptionService_.validateFulfillmentData(
|
||||
optionId,
|
||||
data,
|
||||
cart
|
||||
)
|
||||
|
||||
const profile = await this.shippingProfileService_.list({
|
||||
shipping_options: option._id,
|
||||
})
|
||||
if (profile.length !== 1) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
"The selected shipping method cannot be applied to the cart"
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"Shipping Method must belong to a shipping profile"
|
||||
)
|
||||
}
|
||||
|
||||
option.profile_id = profile[0]._id
|
||||
|
||||
// Go through all existing selected shipping methods and update the one
|
||||
// that has the same profile as the selected shipping method.
|
||||
let exists = false
|
||||
const newMethods = shipping_methods.map(sm => {
|
||||
if (sm.profile_id === method.profile_id) {
|
||||
if (sm.profile_id === option.profile_id) {
|
||||
exists = true
|
||||
return method
|
||||
return option
|
||||
}
|
||||
|
||||
return sm
|
||||
@@ -693,7 +690,7 @@ class CartService extends BaseService {
|
||||
// shipping method the exists flag will be false. Therefore we push the new
|
||||
// method.
|
||||
if (!exists) {
|
||||
newMethods.push(method)
|
||||
newMethods.push(option)
|
||||
}
|
||||
|
||||
return this.cartModel_.updateOne(
|
||||
@@ -706,29 +703,6 @@ class CartService extends BaseService {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all shipping options that are available to the cart and stores them
|
||||
* in shipping_options. The shipping options are retrieved from the shipping
|
||||
* option service.
|
||||
* @param {string} cartId - the id of the cart
|
||||
* @return {Promse} the result of the update operation
|
||||
*/
|
||||
async setShippingOptions(cartId) {
|
||||
const cart = await this.retrieve(cartId)
|
||||
|
||||
// Get the shipping options available in the region
|
||||
const cartOptions = await this.shippingOptionService_.fetchCartOptions(cart)
|
||||
|
||||
return this.cartModel_.updateOne(
|
||||
{
|
||||
_id: cart._id,
|
||||
},
|
||||
{
|
||||
$set: { shipping_options: cartOptions },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set's the region of a cart.
|
||||
* @param {string} cartId - the id of the cart to set region on
|
||||
|
||||
27
packages/medusa/src/services/fulfillment-provider.js
Normal file
27
packages/medusa/src/services/fulfillment-provider.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { MedusaError } from "medusa-core-utils"
|
||||
|
||||
/**
|
||||
* Helps retrive fulfillment providers
|
||||
*/
|
||||
class FulfillmentProviderService {
|
||||
constructor(container) {
|
||||
/** @private {logger} */
|
||||
this.container_ = container
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {FulfillmentService} the payment fulfillment provider
|
||||
*/
|
||||
retrieveProvider(provider_id) {
|
||||
try {
|
||||
return this.container_.resolve(`fp_${provider_id}`)
|
||||
} catch (err) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`Could not find a fulfillment provider with id: ${provider_id}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default FulfillmentProviderService
|
||||
428
packages/medusa/src/services/shipping-option.js
Normal file
428
packages/medusa/src/services/shipping-option.js
Normal file
@@ -0,0 +1,428 @@
|
||||
import mongoose from "mongoose"
|
||||
import _ from "lodash"
|
||||
import { Validator, MedusaError } from "medusa-core-utils"
|
||||
import { BaseService } from "medusa-interfaces"
|
||||
|
||||
/**
|
||||
* Provides layer to manipulate profiles.
|
||||
* @implements BaseService
|
||||
*/
|
||||
class ShippingOptionService extends BaseService {
|
||||
/** @param { shippingOptionModel: (ShippingOptionModel) } */
|
||||
constructor({
|
||||
shippingOptionModel,
|
||||
fulfillmentProviderService,
|
||||
regionService,
|
||||
totalsService,
|
||||
}) {
|
||||
super()
|
||||
|
||||
/** @private @const {ShippingProfileModel} */
|
||||
this.optionModel_ = shippingOptionModel
|
||||
|
||||
/** @private @const {ProductService} */
|
||||
this.providerService_ = fulfillmentProviderService
|
||||
|
||||
/** @private @const {RegionService} */
|
||||
this.regionService_ = regionService
|
||||
|
||||
/** @private @const {TotalsService} */
|
||||
this.totalsService_ = totalsService
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
MedusaError.Types.INVALID_ARGUMENT,
|
||||
"The shippingOptionId could not be casted to an ObjectId"
|
||||
)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a requirement
|
||||
* @param {ShippingRequirement} requirement - the requirement to validate
|
||||
* @return {ShippingRequirement} a validated shipping requirement
|
||||
*/
|
||||
validateRequirement_(requirement) {
|
||||
if (!requirement.type) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"A Shipping Requirement must have a type field"
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
requirement.type !== "min_subtotal" &&
|
||||
requirement.type !== "max_subtotal"
|
||||
) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"Requirement type must be one of min_subtotal, max_subtotal"
|
||||
)
|
||||
}
|
||||
|
||||
return requirement
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} selector - the query object for find
|
||||
* @return {Promise} the result of the find operation
|
||||
*/
|
||||
list(selector) {
|
||||
return this.optionModel_.find(selector)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a profile by id.
|
||||
* Throws in case of DB Error and if profile was not found.
|
||||
* @param {string} optionId - the id of the profile to get.
|
||||
* @return {Promise<Product>} the profile document.
|
||||
*/
|
||||
async retrieve(optionId) {
|
||||
const validatedId = this.validateId_(optionId)
|
||||
const option = await this.optionModel_
|
||||
.findOne({ _id: validatedId })
|
||||
.catch(err => {
|
||||
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
|
||||
})
|
||||
|
||||
if (!option) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`Shipping Option with ${optionId} was not found`
|
||||
)
|
||||
}
|
||||
|
||||
return option
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the provided data for a fulfillment is valid.
|
||||
* @param {string} optionId - the id of the option.
|
||||
* @param {FulfillmentData} data - the data to validate
|
||||
* @param {Cart} cart - the cart to validate against
|
||||
* @return {FulfillmentData} the validated fulfillment data
|
||||
*/
|
||||
async validateFulfillmentData(optionId, data, cart) {
|
||||
const option = await this.retrieve(optionId)
|
||||
const provider = await this.providerService_.retrieveProvider(
|
||||
option.provider_id
|
||||
)
|
||||
return provider.validateFulfillmentData(data, cart)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given option id is a valid option for a cart. If it is the
|
||||
* option is returned with the correct price. Throws when region_ids do not
|
||||
* match, or when the shipping option requirements are not satisfied.
|
||||
* @param {string} optionId - the id of the option to check
|
||||
* @param {Cart} cart - the cart object to check against
|
||||
* @return {ShippingOption} the validated shipping option
|
||||
*/
|
||||
async validateCartOption(optionId, cart) {
|
||||
let option = await this.retrieve(optionId)
|
||||
|
||||
if (cart.region_id !== option.region_id) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"The shipping option is not available in the cart's region"
|
||||
)
|
||||
}
|
||||
|
||||
const subtotal = this.totalsService_.getSubtotal(cart)
|
||||
const requirementResults = option.requirements.map(requirement => {
|
||||
if (requirement.type === "max_subtotal") {
|
||||
return requirement.value > subtotal
|
||||
} else if (requirement.type === "min_subtotal") {
|
||||
return requirement.value < subtotal
|
||||
}
|
||||
|
||||
return true // default to true
|
||||
})
|
||||
|
||||
if (!requirementResults.every(r => r)) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
"The Cart does not satisfy the shipping option's requirements"
|
||||
)
|
||||
}
|
||||
|
||||
if (option.price && option.price.type === "calculated") {
|
||||
const provider = this.providerService_.retrieveProvider(
|
||||
option.provider_id
|
||||
)
|
||||
option.price = await provider.calculatePrice(option.data, cart)
|
||||
} else {
|
||||
option.price = option.price.amount
|
||||
}
|
||||
|
||||
return option
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new shipping option.
|
||||
* @param {ShippingOption} option - the shipping option to create
|
||||
* @return {Promise} the result of the create operation
|
||||
*/
|
||||
async create(option) {
|
||||
const region = await this.regionService_.retrieve(option.region_id)
|
||||
|
||||
if (!region.fulfillment_providers.includes(option.provider_id)) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"The fulfillment provider is not available in the provided region"
|
||||
)
|
||||
}
|
||||
|
||||
const provider = await this.providerService_.retrieveProvider(
|
||||
option.provider_id
|
||||
)
|
||||
option.price = await this.validatePrice_(option.price, option)
|
||||
|
||||
const isValid = await provider.validateOption(option.data)
|
||||
if (!isValid) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"The fulfillment provider cannot validate the shipping option"
|
||||
)
|
||||
}
|
||||
|
||||
if (option.requirements) {
|
||||
option.requirements = await Promise.all(
|
||||
option.requirements.map(r => {
|
||||
return this.validateRequirement_(r)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return this.optionModel_.create(option).catch(err => {
|
||||
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef ShippingOptionPrice
|
||||
* @property {string} type - one of flat_rate, calculated
|
||||
* @property {number} value - the value if available
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validates a shipping option price
|
||||
* @param {ShippingOptionPrice} price - the price to validate
|
||||
* @param {ShippingOption} option - the option to validate against
|
||||
* @return {ShippingOptionPrice} the validated price
|
||||
*/
|
||||
async validatePrice_(price, option) {
|
||||
if (
|
||||
!price.type ||
|
||||
(price.type !== "flat_rate" && price.type !== "calculated")
|
||||
) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"The price must be of type flat_rate or calculated"
|
||||
)
|
||||
}
|
||||
|
||||
if (price.type === "calculated") {
|
||||
const provider = this.providerService_.retrieveProvider(
|
||||
option.provider_id
|
||||
)
|
||||
const canCalculate = await provider.canCalculate(option.data)
|
||||
if (!canCalculate) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"The fulfillment provider cannot calculate prices for this option"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (price.type === "flat_rate" && (!price.amount || price.amount < 0)) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"Flat rate prices must have a postive amount field."
|
||||
)
|
||||
}
|
||||
|
||||
return price
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a profile. Metadata updates and product updates should use
|
||||
* dedicated methods, e.g. `setMetadata`, `addProduct`, etc. The function
|
||||
* will throw errors if metadata or product updates are attempted.
|
||||
* @param {string} optionId - the id of the option. 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.
|
||||
*/
|
||||
async update(optionId, update) {
|
||||
const option = await this.retrieve(optionId)
|
||||
const validatedId = this.validateId_(optionId)
|
||||
|
||||
if (update.metadata) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
"Use setMetadata to update metadata fields"
|
||||
)
|
||||
}
|
||||
|
||||
if (update.region_id || update.provider_id || update.data) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"Region and Provider cannot be updated after creation"
|
||||
)
|
||||
}
|
||||
|
||||
if (update.requirements) {
|
||||
update.requirements = update.requirements.reduce((acc, r) => {
|
||||
const validated = this.validateRequirement_(r)
|
||||
|
||||
if (acc.find(raw => raw.type === validated.type)) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"Only one requirement of each type is allowed"
|
||||
)
|
||||
}
|
||||
|
||||
acc.push(validated)
|
||||
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
|
||||
if (update.price) {
|
||||
update.price = await this.validatePrice_(update.price, option)
|
||||
}
|
||||
|
||||
return this.optionModel_
|
||||
.updateOne(
|
||||
{ _id: validatedId },
|
||||
{ $set: update },
|
||||
{ runValidators: true }
|
||||
)
|
||||
.catch(err => {
|
||||
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a profile with a given profile id.
|
||||
* @param {string} optionId - the id of the profile to delete. Must be
|
||||
* castable as an ObjectId
|
||||
* @return {Promise} the result of the delete operation.
|
||||
*/
|
||||
async delete(optionId) {
|
||||
let option
|
||||
try {
|
||||
option = await this.retrieve(optionId)
|
||||
} catch (error) {
|
||||
// Delete is idempotent, but we return a promise to allow then-chaining
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return this.optionModel_.deleteOne({ _id: option._id }).catch(err => {
|
||||
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef ShippingRequirement
|
||||
* @property {string} type - one of max_subtotal, min_subtotal
|
||||
* @property {number} value - the value to match against
|
||||
*/
|
||||
|
||||
/**
|
||||
* Adds a requirement to a shipping option. Only 1 requirement of each type
|
||||
* is allowed.
|
||||
* @param {string} optionId - the option to add the requirement to.
|
||||
* @param {ShippingRequirement} requirement - the requirement for the option.
|
||||
* @return {Promise} the result of update
|
||||
*/
|
||||
async addRequirement(optionId, requirement) {
|
||||
const option = await this.retrieve(optionId)
|
||||
const validatedRequirement = this.validateRequirement_(requirement)
|
||||
|
||||
if (option.requirements.find(r => r.type === validatedRequirement.type)) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`A requirement with type: ${validatedRequirement.type} already exists`
|
||||
)
|
||||
}
|
||||
|
||||
return this.optionModel_.updateOne(
|
||||
{ _id: option._id },
|
||||
{ $push: { requirements: validatedRequirement } }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a requirement from a shipping option
|
||||
* @param {string} optionId - the shipping option to remove from
|
||||
* @param {string} requirementId - the id of the requirement to remove
|
||||
* @return {Promise} the result of update
|
||||
*/
|
||||
async removeRequirement(optionId, requirementId) {
|
||||
const option = await this.retrieve(optionId)
|
||||
|
||||
if (!option.requirements.find(r => r._id === requirementId)) {
|
||||
// Remove is idempotent
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return this.optionModel_.updateOne(
|
||||
{ _id: option._id },
|
||||
{ $pull: { requirements: { _id: requirementId } } }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorates a shipping option.
|
||||
* @param {ShippingOption} shippingOption - the shipping option to decorate.
|
||||
* @param {string[]} fields - the fields to include.
|
||||
* @param {string[]} expandFields - fields to expand.
|
||||
* @return {ShippingOption} the decorated ShippingOption.
|
||||
*/
|
||||
async decorate(shippingOption, fields, expandFields = []) {
|
||||
const requiredFields = ["_id", "metadata"]
|
||||
let decorated = _.pick(shippingOption, fields.concat(requiredFields))
|
||||
|
||||
return decorated
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated method to set metadata for a shipping option.
|
||||
* @param {string} optionId - the option to set metadata for.
|
||||
* @param {string} key - key for metadata field
|
||||
* @param {string} value - value for metadata field.
|
||||
* @return {Promise} resolves to the updated result.
|
||||
*/
|
||||
setMetadata(optionId, key, value) {
|
||||
const validatedId = this.validateId_(optionId)
|
||||
|
||||
if (typeof key !== "string") {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_ARGUMENT,
|
||||
"Key type is invalid. Metadata keys must be strings"
|
||||
)
|
||||
}
|
||||
|
||||
const keyPath = `metadata.${key}`
|
||||
return this.optionModel_
|
||||
.updateOne({ _id: validatedId }, { $set: { [keyPath]: value } })
|
||||
.catch(err => {
|
||||
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default ShippingOptionService
|
||||
382
packages/medusa/src/services/shipping-profile.js
Normal file
382
packages/medusa/src/services/shipping-profile.js
Normal file
@@ -0,0 +1,382 @@
|
||||
import mongoose from "mongoose"
|
||||
import _ from "lodash"
|
||||
import { Validator, MedusaError } from "medusa-core-utils"
|
||||
import { BaseService } from "medusa-interfaces"
|
||||
|
||||
/**
|
||||
* Provides layer to manipulate profiles.
|
||||
* @implements BaseService
|
||||
*/
|
||||
class ShippingProfileService extends BaseService {
|
||||
/** @param {
|
||||
* shippingProfileModel: (ShippingProfileModel),
|
||||
* productService: (ProductService),
|
||||
* shippingOptionService: (ProductService),
|
||||
* } */
|
||||
constructor({ shippingProfileModel, productService, shippingOptionService }) {
|
||||
super()
|
||||
|
||||
/** @private @const {ShippingProfileModel} */
|
||||
this.profileModel_ = shippingProfileModel
|
||||
|
||||
/** @private @const {ProductService} */
|
||||
this.productService_ = productService
|
||||
|
||||
/** @private @const {ShippingOptionService} */
|
||||
this.shippingOptionService_ = shippingOptionService
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
MedusaError.Types.INVALID_ARGUMENT,
|
||||
"The profileId could not be casted to an ObjectId"
|
||||
)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} selector - the query object for find
|
||||
* @return {Promise} the result of the find operation
|
||||
*/
|
||||
list(selector) {
|
||||
return this.profileModel_.find(selector)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a profile by id.
|
||||
* Throws in case of DB Error and if profile was not found.
|
||||
* @param {string} profileId - the id of the profile to get.
|
||||
* @return {Promise<Product>} the profile document.
|
||||
*/
|
||||
async retrieve(profileId) {
|
||||
const validatedId = this.validateId_(profileId)
|
||||
const profile = await this.profileModel_
|
||||
.findOne({ _id: validatedId })
|
||||
.catch(err => {
|
||||
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
|
||||
})
|
||||
|
||||
if (!profile) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`Shipping Profile with ${profileId} was not found`
|
||||
)
|
||||
}
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new shipping profile.
|
||||
* @param {ShippingProfile} profile - the shipping profile to create from
|
||||
* @return {Promise} the result of the create operation
|
||||
*/
|
||||
async create(profile) {
|
||||
if (profile.products || profile.shipping_options) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"Please add products and shipping_options after creating Shipping Profiles"
|
||||
)
|
||||
}
|
||||
return this.profileModel_.create(profile)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a profile. Metadata updates and product updates should use
|
||||
* dedicated methods, e.g. `setMetadata`, `addProduct`, etc. The function
|
||||
* will throw errors if metadata or product updates are attempted.
|
||||
* @param {string} profileId - the id of the profile. 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.
|
||||
*/
|
||||
async update(profileId, update) {
|
||||
const validatedId = this.validateId_(profileId)
|
||||
|
||||
if (update.metadata) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"Use setMetadata to update metadata fields"
|
||||
)
|
||||
}
|
||||
|
||||
if (update.products) {
|
||||
// We use the set to ensure that the array doesn't include duplicates
|
||||
const productSet = new Set(update.products)
|
||||
|
||||
// Go through each product and ensure they exist and if they are found in
|
||||
// other profiles that they are removed from there.
|
||||
update.products = await Promise.all(
|
||||
[...productSet].map(async pId => {
|
||||
const product = await this.productService_.retrieve(pId)
|
||||
|
||||
// Ensure that every product only exists in exactly one profile
|
||||
const existing = await this.profileModel_.findOne({
|
||||
products: product._id,
|
||||
})
|
||||
if (existing && existing._id !== profileId) {
|
||||
await this.removeProduct(existing._id, product._id)
|
||||
}
|
||||
|
||||
return product._id
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (update.shipping_options) {
|
||||
// No duplicates
|
||||
const optionSet = new Set(update.shipping_options)
|
||||
|
||||
update.shipping_options = await Promise.all(
|
||||
[...optionSet].map(async sId => {
|
||||
const profile = await this.retrieve(profileId)
|
||||
const shippingOption = await this.shippingOptionService_.retrieve(sId)
|
||||
|
||||
// If the shipping method exists in a different profile remove it
|
||||
const existing = await this.profileModel_.findOne({
|
||||
shipping_options: shippingOption._id,
|
||||
})
|
||||
if (existing && existing._id !== profileId) {
|
||||
await this.removeShippingOption(existing._id, shippingOption._id)
|
||||
}
|
||||
|
||||
return shippingOption._id
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return this.profileModel_
|
||||
.updateOne(
|
||||
{ _id: validatedId },
|
||||
{ $set: update },
|
||||
{ runValidators: true }
|
||||
)
|
||||
.catch(err => {
|
||||
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a profile with a given profile id.
|
||||
* @param {string} profileId - the id of the profile to delete. Must be
|
||||
* castable as an ObjectId
|
||||
* @return {Promise} the result of the delete operation.
|
||||
*/
|
||||
async delete(profileId) {
|
||||
let profile
|
||||
try {
|
||||
profile = await this.retrieve(profileId)
|
||||
} catch (error) {
|
||||
// Delete is idempotent, but we return a promise to allow then-chaining
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return this.profileModel_.deleteOne({ _id: profile._id }).catch(err => {
|
||||
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a product to a profile. The method is idempotent, so multiple calls
|
||||
* with the same product variant will have the same result.
|
||||
* @param {string} profileId - the profile to add the product to.
|
||||
* @param {string} productId - the product to add.
|
||||
* @return {Promise} the result of update
|
||||
*/
|
||||
async addProduct(profileId, productId) {
|
||||
const profile = await this.retrieve(profileId)
|
||||
const product = await this.productService_.retrieve(productId)
|
||||
|
||||
if (profile.products.find(p => p === product._id)) {
|
||||
// If the product already exists in the profile we just return an
|
||||
// empty promise for then-chaining
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return this.profileModel_.updateOne(
|
||||
{ _id: profile._id },
|
||||
{ $push: { products: product._id } }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a shipping option to the profile. The shipping option can be used to
|
||||
* fulfill the products in the products field.
|
||||
* @param {string} profileId - the profile to apply the shipping option to
|
||||
* @param {string} optionId - the option to add to the profile
|
||||
* @return {Promise} the result of the model update operation
|
||||
*/
|
||||
async addShippingOption(profileId, optionId) {
|
||||
const profile = await this.retrieve(profileId)
|
||||
const shippingOption = await this.shippingOptionService_.retrieve(optionId)
|
||||
|
||||
// Make sure that option doesn't already exist
|
||||
if (profile.shipping_options.find(o => o === shippingOption._id)) {
|
||||
// If the option already exists in the profile we just return an
|
||||
// empty promise for then-chaining
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
// If the shipping method exists in a different profile remove it
|
||||
const profiles = await this.list({ shipping_options: shippingOption._id })
|
||||
if (profiles.length > 0) {
|
||||
await this.removeShippingOption(profiles[0]._id, shippingOption._id)
|
||||
}
|
||||
|
||||
// Everything went well add the shipping option
|
||||
return this.profileModel_.updateOne(
|
||||
{ _id: profileId },
|
||||
{ $push: { shipping_options: shippingOption._id } }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a shipping option from a profile.
|
||||
* @param {string} profileId - the profile to delete an option from
|
||||
* @param {string} optionId - the option to delete
|
||||
* @return {Promise} return the result of update
|
||||
*/
|
||||
async removeShippingOption(profileId, optionId) {
|
||||
const profile = await this.retrieve(profileId)
|
||||
|
||||
return this.profileModel_.updateOne(
|
||||
{ _id: profileId },
|
||||
{ $pull: { shipping_options: optionId } }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a product from the a profile.
|
||||
* @param {string} profileId - the profile to remove the product from
|
||||
* @param {string} productId - the product to remove
|
||||
* @return {Promise} the result of update
|
||||
*/
|
||||
async removeProduct(profileId, productId) {
|
||||
const profile = await this.retrieve(profileId)
|
||||
|
||||
if (!profile.products.find(p => p === productId)) {
|
||||
// Remove is idempotent
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return this.profileModel_.updateOne(
|
||||
{ _id: profile._id },
|
||||
{ $pull: { products: productId } }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorates a profile.
|
||||
* @param {Profile} profile - the profile to decorate.
|
||||
* @param {string[]} fields - the fields to include.
|
||||
* @param {string[]} expandFields - fields to expand.
|
||||
* @return {Profile} return the decorated profile.
|
||||
*/
|
||||
async decorate(profile, fields, expandFields = []) {
|
||||
const requiredFields = ["_id", "metadata"]
|
||||
let decorated = _.pick(profile, fields.concat(requiredFields))
|
||||
|
||||
if (expandFields.includes("products")) {
|
||||
decorated.products = await Promise.all(
|
||||
profile.products.map(pId => this.productService_.retrieve(pId))
|
||||
)
|
||||
}
|
||||
|
||||
if (expandFields.includes("shipping_options")) {
|
||||
decorated.shipping_options = await Promise.all(
|
||||
profile.shipping_options.map(oId =>
|
||||
this.shippingOptionService_.retrieve(oId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return decorated
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of all the productIds in the cart.
|
||||
* @param {Cart} cart - the cart to extract products from
|
||||
* @return {[string]} a list of product ids
|
||||
*/
|
||||
getProductsInCart_(cart) {
|
||||
return cart.items.reduce((acc, next) => {
|
||||
if (Array.isArray(next.content)) {
|
||||
next.content.forEach(({ product }) => {
|
||||
if (!acc.includes(product._id)) {
|
||||
acc.push(product._id)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
if (!acc.includes(next.content.product._id)) {
|
||||
acc.push(next.content.product._id)
|
||||
}
|
||||
}
|
||||
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all the shipping profiles that cover the products in a cart, and
|
||||
* validates all options that are available for the cart.
|
||||
* @param {Cart} cart - the cart object to find shipping options for
|
||||
* @return {[ShippingOptions]} a list of the available shipping options
|
||||
*/
|
||||
async fetchCartOptions(cart) {
|
||||
const products = this.getProductsInCart_(cart)
|
||||
const profiles = await this.list({ products: { $in: products } })
|
||||
const optionIds = profiles.reduce((acc, next) =>
|
||||
acc.concat(next.shipping_options)
|
||||
)
|
||||
|
||||
const options = await Promise.all(
|
||||
optionIds.map(oId => {
|
||||
return this.shippingOptionService_
|
||||
.validateCartOption(oId, cart)
|
||||
.catch(err => {
|
||||
// If validation failed we skip the option
|
||||
return null
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
return options.filter(o => !!o)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated method to set metadata for a profile.
|
||||
* @param {string} profileId - the profile to decorate.
|
||||
* @param {string} key - key for metadata field
|
||||
* @param {string} value - value for metadata field.
|
||||
* @return {Promise} resolves to the updated result.
|
||||
*/
|
||||
setMetadata(profileId, key, value) {
|
||||
const validatedId = this.validateId_(profileId)
|
||||
|
||||
if (typeof key !== "string") {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_ARGUMENT,
|
||||
"Key type is invalid. Metadata keys must be strings"
|
||||
)
|
||||
}
|
||||
|
||||
const keyPath = `metadata.${key}`
|
||||
return this.profileModel_
|
||||
.updateOne({ _id: validatedId }, { $set: { [keyPath]: value } })
|
||||
.catch(err => {
|
||||
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default ShippingProfileService
|
||||
Reference in New Issue
Block a user