feat(medusa): Validate LineItems in SalesChannel (#1871)

* wip: validate line item SC

* fix: repository type, remove relation, use sc id, check if cart has associated sc

* feat: setup tests and seeder, change entity retrieval in cart service method

* feat: remove repo usage and method, use Adrien's method from product service to check sc association, add test cases, add seeder entities, accept flag for validating sc on the endpoint

* feat: add a unit test to ensure validation method is called if flag is passed

* feat: allow `validate_sales_channels` flag in other relevant endpoints

* fix: typo

* fix: flag rename

* fix: correct FF in test suites

* fix: address PR feedback

* fix: change error message

* feat: remove query params, guard with FF, refactor

* feat: guard validation in the service

* refactor: rename validation method

* refactor: reorganise tests

* wip: cleanup test file

* wip: revert cart seeder changes use factories

* fix: remove seeder, update mocks

* fix: method name

* fix: units, validate by default

* git: resolve merge conflicts

* refactor: separate line item sales chanel units

Co-authored-by: fPolic <frane@medusajs.com>
This commit is contained in:
Frane Polić
2022-07-27 21:39:06 +02:00
committed by GitHub
parent 204dd23a39
commit 3fbe8d7d08
9 changed files with 387 additions and 28 deletions

View File

@@ -0,0 +1,144 @@
const path = require("path")
const { useDb } = require("../../../helpers/use-db")
const { useApi } = require("../../../helpers/use-api")
const { simpleCartFactory, simpleProductFactory } = require("../../factories")
const startServerWithEnvironment =
require("../../../helpers/start-server-with-environment").default
jest.setTimeout(30000)
describe("Line Item - Sales Channel", () => {
let dbConnection
let medusaProcess
const doAfterEach = async () => {
const db = useDb()
return await db.teardown()
}
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", ".."))
try {
const [process, connection] = await startServerWithEnvironment({
cwd,
env: { MEDUSA_FF_SALES_CHANNELS: true },
verbose: false,
})
dbConnection = connection
medusaProcess = process
} catch (error) {
console.log(error)
}
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
medusaProcess.kill()
})
beforeEach(async () => {
try {
await simpleProductFactory(dbConnection, {
id: "test-product-in-sales-channel",
title: "test product belonging to a channel",
sales_channels: [
{
id: "main-sales-channel",
name: "Main sales channel",
description: "Main sales channel",
is_disabled: false,
},
],
variants: [
{
id: "test-variant-sales-channel",
title: "test variant in sales channel",
product_id: "test-product-in-sales-channel",
inventory_quantity: 1000,
prices: [
{
currency: "usd",
amount: 59,
},
],
},
],
})
await simpleProductFactory(dbConnection, {
id: "test-product-no-sales-channel",
variants: [
{
id: "test-variant-no-sales-channel",
},
],
})
await simpleCartFactory(dbConnection, {
id: "test-cart-with-sales-channel",
sales_channel: {
id: "main-sales-channel",
},
})
} catch (err) {
console.log(err)
throw err
}
})
afterEach(async () => {
await doAfterEach()
})
describe("Adding line item with associated sales channel to a cart", () => {
it("adding line item to a cart with associated sales channel returns 400", async () => {
const api = useApi()
const response = await api
.post(
"/store/carts/test-cart-with-sales-channel/line-items",
{
variant_id: "test-variant-no-sales-channel", // variant's product doesn't belong to a sales channel
quantity: 1,
},
{ withCredentials: true }
)
.catch((err) => err.response)
expect(response.status).toEqual(400)
expect(response.data.type).toEqual("invalid_data")
})
it("adding line item successfully if product and cart belong to the same sales channel", async () => {
const api = useApi()
const response = await api
.post(
"/store/carts/test-cart-with-sales-channel/line-items",
{
variant_id: "test-variant-sales-channel",
quantity: 1,
},
{ withCredentials: true }
)
.catch((err) => console.log(err))
expect(response.status).toEqual(200)
expect(response.data.cart).toMatchObject({
id: "test-cart-with-sales-channel",
items: [
expect.objectContaining({
cart_id: "test-cart-with-sales-channel",
description: "test variant in sales channel",
title: "test product belonging to a channel",
variant_id: "test-variant-sales-channel",
}),
],
sales_channel_id: "main-sales-channel",
})
})
})
})

View File

@@ -1,13 +1,29 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
# This file is generated by running "yarn install" inside your project.
# Manual changes might be lost - proceed with caution!
__metadata:
version: 6
cacheKey: 8c0
"@faker-js/faker@^5.5.3":
version "5.5.3"
resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-5.5.3.tgz#18e3af6b8eae7984072bbeb0c0858474d7c4cefe"
integrity sha512-R11tGE6yIFwqpaIqcfkcg7AICXzFg14+5h5v0TfF/9+RMDL6jhzCy/pxHVOfbALGdtVYdt6JdR21tuxEgl34dw==
"@faker-js/faker@npm:^5.5.3":
version: 5.5.3
resolution: "@faker-js/faker@npm:5.5.3"
checksum: 3f7fbf0b0cfe23c7750ab79b123be8f845e5f376ec28bf43b7b017983b6fc3a9dc22543c4eea52e30cc119699c0f47f62a2c02e9eae9b6a20b75955e9c3eb887
languageName: node
linkType: hard
dotenv@^10.0.0:
version "10.0.0"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-10.0.0.tgz#3d4227b8fb95f81096cdd2b66653fb2c7085ba81"
integrity sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==
"dotenv@npm:^10.0.0":
version: 10.0.0
resolution: "dotenv@npm:10.0.0"
checksum: 2d8d4ba64bfaff7931402aa5e8cbb8eba0acbc99fe9ae442300199af021079eafa7171ce90e150821a5cb3d74f0057721fbe7ec201a6044b68c8a7615f8c123f
languageName: node
linkType: hard
"integration-tests@workspace:.":
version: 0.0.0-use.local
resolution: "integration-tests@workspace:."
dependencies:
"@faker-js/faker": ^5.5.3
dotenv: ^10.0.0
languageName: unknown
linkType: soft

View File

@@ -1,4 +1,10 @@
import { IsInt, IsObject, IsOptional, IsString } from "class-validator"
import {
IsBoolean,
IsInt,
IsObject,
IsOptional,
IsString,
} from "class-validator"
import { MedusaError } from "medusa-core-utils"
import { EntityManager } from "typeorm"
import {
@@ -12,6 +18,7 @@ import {
LineItemService,
} from "../../../../services"
import { validator } from "../../../../utils/validator"
import { FlagRouter } from "../../../../utils/flag-router"
/**
* @oas [post] /draft-orders/{id}/line-items
* operationId: "PostDraftOrdersDraftOrderLineItems"
@@ -92,7 +99,7 @@ export default async (req, res) => {
await cartService
.withTransaction(manager)
.addLineItem(draftOrder.cart_id, line)
.addLineItem(draftOrder.cart_id, line, { validateSalesChannels: false })
} else {
// custom line items can be added to a draft order
await lineItemService.withTransaction(manager).create({

View File

@@ -1,6 +1,7 @@
import { Type } from "class-transformer"
import {
IsArray,
IsBoolean,
IsInt,
IsNotEmpty,
IsOptional,
@@ -16,6 +17,7 @@ import { CartService, LineItemService, RegionService } from "../../../../service
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels";
import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators";
import { FlagRouter } from "../../../../utils/flag-router"
/**
* @oas [post] /carts
@@ -77,6 +79,7 @@ export default async (req, res) => {
const cartService: CartService = req.scope.resolve("cartService")
const regionService: RegionService = req.scope.resolve("regionService")
const entityManager: EntityManager = req.scope.resolve("manager")
const featureFlagRouter: FlagRouter = req.scope.resolve("featureFlagRouter")
await entityManager.transaction(async (manager) => {
let regionId: string
@@ -116,7 +119,10 @@ export default async (req, res) => {
})
await cartService
.withTransaction(manager)
.addLineItem(cart.id, lineItem)
.addLineItem(cart.id, lineItem, {
validateSalesChannels:
featureFlagRouter.isFeatureEnabled("sales_channels"),
})
})
)
}

View File

@@ -4,6 +4,7 @@ import { defaultStoreCartFields, defaultStoreCartRelations } from "."
import { CartService, LineItemService } from "../../../../services"
import { validator } from "../../../../utils/validator"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
import { FlagRouter } from "../../../../utils/flag-router"
/**
* @oas [post] /carts/{id}/line-items
@@ -34,10 +35,12 @@ export default async (req, res) => {
const customerId = req.user?.customer_id
const validated = await validator(StorePostCartsCartLineItemsReq, req.body)
const manager: EntityManager = req.scope.resolve("manager")
const lineItemService: LineItemService = req.scope.resolve("lineItemService")
const cartService: CartService = req.scope.resolve("cartService")
const manager: EntityManager = req.scope.resolve("manager")
const featureFlagRouter: FlagRouter = req.scope.resolve("featureFlagRouter")
await manager.transaction(async (m) => {
const txCartService = cartService.withTransaction(m)
const cart = await txCartService.retrieve(id)
@@ -48,7 +51,11 @@ export default async (req, res) => {
customer_id: customerId || cart.customer_id,
metadata: validated.metadata,
})
await txCartService.addLineItem(id, line)
await txCartService.addLineItem(id, line, {
validateSalesChannels:
featureFlagRouter.isFeatureEnabled("sales_channels"),
})
const updated = await txCartService.retrieve(id, {
relations: ["payment_sessions"],

View File

@@ -1,12 +1,20 @@
import { Brackets, DeleteResult, EntityRepository, In, Repository } from "typeorm"
import {
Brackets,
DeleteResult,
EntityRepository,
In,
Repository,
} from "typeorm"
import { SalesChannel } from "../models"
import { ExtendedFindConfig, Selector } from "../types/common";
import { ExtendedFindConfig, Selector } from "../types/common"
@EntityRepository(SalesChannel)
export class SalesChannelRepository extends Repository<SalesChannel> {
public async getFreeTextSearchResultsAndCount(
public async getFreeTextSearchResultsAndCount(
q: string,
options: ExtendedFindConfig<SalesChannel, Selector<SalesChannel>> = { where: {} },
options: ExtendedFindConfig<SalesChannel, Selector<SalesChannel>> = {
where: {},
}
): Promise<[SalesChannel[], number]> {
const options_ = { ...options }
delete options_?.where?.name
@@ -17,8 +25,9 @@ export class SalesChannelRepository extends Repository<SalesChannel> {
.where(options_.where)
.andWhere(
new Brackets((qb) => {
qb.where(`sales_channel.description ILIKE :q`, { q: `%${q}%` })
.orWhere(`sales_channel.name ILIKE :q`, { q: `%${q}%` })
qb.where(`sales_channel.description ILIKE :q`, {
q: `%${q}%`,
}).orWhere(`sales_channel.name ILIKE :q`, { q: `%${q}%` })
})
)
.skip(options.skip)

View File

@@ -2,7 +2,7 @@ import { IdMap } from "medusa-test-utils"
import { MedusaError } from "medusa-core-utils"
export const LineItemAdjustmentServiceMock = {
withTransaction: function() {
withTransaction: function () {
return this
},
create: jest.fn().mockImplementation((data) => {

View File

@@ -4,7 +4,7 @@ import { IdMap, MockManager, MockRepository } from "medusa-test-utils"
import CartService from "../cart"
import { InventoryServiceMock } from "../__mocks__/inventory"
import { LineItemAdjustmentServiceMock } from "../__mocks__/line-item-adjustment"
import { FlagRouter } from "../../utils/flag-router";
import { FlagRouter } from "../../utils/flag-router"
const eventBusService = {
emit: jest.fn(),
@@ -354,6 +354,13 @@ describe("CartService", () => {
},
}
const productVariantService = {
retrieve: jest.fn(),
withTransaction: function () {
return this
},
}
const inventoryService = {
...InventoryServiceMock,
confirmInventory: jest.fn().mockImplementation((variantId, _quantity) => {
@@ -397,6 +404,7 @@ describe("CartService", () => {
})
},
})
const cartService = new CartService({
manager: MockManager,
totalsService,
@@ -406,6 +414,7 @@ describe("CartService", () => {
eventBusService,
shippingOptionService,
inventoryService,
productVariantService,
lineItemAdjustmentService: LineItemAdjustmentServiceMock,
featureFlagRouter: new FlagRouter({}),
})
@@ -562,6 +571,119 @@ describe("CartService", () => {
})
})
describe("addLineItem w. SalesChannel", () => {
const lineItemService = {
update: jest.fn(),
create: jest.fn(),
withTransaction: function () {
return this
},
}
const shippingOptionService = {
deleteShippingMethods: jest.fn(),
withTransaction: function () {
return this
},
}
const productVariantService = {
retrieve: jest.fn(),
withTransaction: function () {
return this
},
}
const inventoryService = {
...InventoryServiceMock,
confirmInventory: jest.fn().mockImplementation((variantId, _quantity) => {
if (variantId !== IdMap.getId("cannot-cover")) {
return true
} else {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`Variant with id: ${variantId} does not have the required inventory`
)
}
}),
}
const cartRepository = MockRepository({
findOneWithRelations: (rels, q) => {
if (q.where.id === IdMap.getId("cartWithLine")) {
return Promise.resolve({
id: IdMap.getId("cartWithLine"),
items: [
{
id: IdMap.getId("merger"),
title: "will merge",
variant_id: IdMap.getId("existing"),
should_merge: true,
quantity: 1,
},
],
})
}
return Promise.resolve({
id: IdMap.getId("emptyCart"),
shipping_methods: [
{
shipping_option: {
profile_id: IdMap.getId("testProfile"),
},
},
],
items: [],
})
},
})
const cartService = new CartService({
manager: MockManager,
totalsService,
cartRepository,
lineItemService,
lineItemRepository: MockRepository(),
eventBusService,
shippingOptionService,
inventoryService,
productVariantService,
lineItemAdjustmentService: LineItemAdjustmentServiceMock,
featureFlagRouter: new FlagRouter({ sales_channels: true }),
})
beforeEach(() => {
jest.clearAllMocks()
})
it("validates if cart and variant's product belong to the same sales channel if flag is passed", async () => {
const validateSpy = jest
.spyOn(cartService, "validateLineItem")
.mockImplementation(() => Promise.resolve(true))
const lineItem = {
title: "New Line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
variant_id: IdMap.getId("can-cover"),
unit_price: 123,
quantity: 10,
}
await cartService.addLineItem(IdMap.getId("cartWithLine"), lineItem, {
validateSalesChannels: false,
})
expect(cartService.validateLineItem).not.toHaveBeenCalled()
await cartService.addLineItem(IdMap.getId("cartWithLine"), lineItem)
expect(cartService.validateLineItem).toHaveBeenCalledTimes(1)
validateSpy.mockClear()
})
})
describe("removeLineItem", () => {
const lineItemService = {
delete: jest.fn(),

View File

@@ -12,7 +12,6 @@ import {
Discount,
LineItem,
ShippingMethod,
User,
SalesChannel,
} from "../models"
import { AddressRepository } from "../repositories/address"
@@ -45,8 +44,8 @@ import TaxProviderService from "./tax-provider"
import TotalsService from "./totals"
import SalesChannelFeatureFlag from "../loaders/feature-flags/sales-channels"
import { FlagRouter } from "../utils/flag-router"
import SalesChannelService from "./sales-channel"
import StoreService from "./store"
import { SalesChannelService } from "./index"
type InjectedDependencies = {
manager: EntityManager
@@ -101,7 +100,6 @@ class CartService extends TransactionBaseService<CartService> {
protected readonly eventBus_: EventBusService
protected readonly productVariantService_: ProductVariantService
protected readonly productService_: ProductService
protected readonly featureFlagRouter_: FlagRouter
protected readonly storeService_: StoreService
protected readonly salesChannelService_: SalesChannelService
protected readonly regionService_: RegionService
@@ -117,6 +115,7 @@ class CartService extends TransactionBaseService<CartService> {
protected readonly customShippingOptionService_: CustomShippingOptionService
protected readonly priceSelectionStrategy_: IPriceSelectionStrategy
protected readonly lineItemAdjustmentService_: LineItemAdjustmentService
protected readonly featureFlagRouter_: FlagRouter
constructor({
manager,
@@ -540,7 +539,7 @@ class CartService extends TransactionBaseService<CartService> {
* shipping methods.
* @param shippingMethods - the set of shipping methods to check from
* @param lineItem - the line item
* @return boolean representing wheter shipping method is validated
* @return boolean representing whether shipping method is validated
*/
protected validateLineItemShipping_(
shippingMethods: ShippingMethod[],
@@ -566,13 +565,49 @@ class CartService extends TransactionBaseService<CartService> {
return false
}
/**
* Check if line item's variant belongs to the cart's sales channel.
*
* @param cart - the cart for the line item
* @param lineItem - the line item being added
* @return a boolean indicating validation result
*/
protected async validateLineItem(
cart: Cart,
lineItem: LineItem
): Promise<boolean> {
if (!cart.sales_channel_id) {
return true
}
const lineItemVariant = await this.productVariantService_
.withTransaction(this.manager_)
.retrieve(lineItem.variant_id)
return !!(
await this.productService_
.withTransaction(this.manager_)
.filterProductsBySalesChannel(
[lineItemVariant.product_id],
cart.sales_channel_id
)
).length
}
/**
* Adds a line item to the cart.
* @param cartId - the id of the cart that we will add to
* @param lineItem - the line item to add.
* @param config
* validateSalesChannels - should check if product belongs to the same sales chanel as cart
* (if cart has associated sales channel)
* @return the result of the update operation
*/
async addLineItem(cartId: string, lineItem: LineItem): Promise<Cart> {
async addLineItem(
cartId: string,
lineItem: LineItem,
config = { validateSalesChannels: true }
): Promise<Cart> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const cart = await this.retrieve(cartId, {
@@ -588,6 +623,17 @@ class CartService extends TransactionBaseService<CartService> {
],
})
if (this.featureFlagRouter_.isFeatureEnabled("sales_channels")) {
if (config.validateSalesChannels) {
if (!(await this.validateLineItem(cart, lineItem))) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`The product "${lineItem.title}" must belongs to the sales channel on which the cart has been created.`
)
}
}
}
let currentItem: LineItem | undefined
if (lineItem.should_merge) {
currentItem = cart.items.find((item) => {
@@ -942,8 +988,10 @@ class CartService extends TransactionBaseService<CartService> {
/**
* Remove the cart line item that does not belongs to the newly assigned sales channel
*
* @param cart - The cart being updated
* @param newSalesChannelId - The new sales channel being assigned to the cart
* @return void
* @protected
*/
protected async onSalesChannelChange(