diff --git a/integration-tests/api/__tests__/line-item-adjustments/ff-sales-channel.js b/integration-tests/api/__tests__/line-item-adjustments/ff-sales-channel.js new file mode 100644 index 0000000000..be8dbb9baf --- /dev/null +++ b/integration-tests/api/__tests__/line-item-adjustments/ff-sales-channel.js @@ -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", + }) + }) + }) +}) diff --git a/integration-tests/yarn.lock b/integration-tests/yarn.lock index 1fd8e97b7f..0f05a126df 100644 --- a/integration-tests/yarn.lock +++ b/integration-tests/yarn.lock @@ -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 diff --git a/packages/medusa/src/api/routes/admin/draft-orders/create-line-item.ts b/packages/medusa/src/api/routes/admin/draft-orders/create-line-item.ts index bbbaa27711..d681b172d7 100644 --- a/packages/medusa/src/api/routes/admin/draft-orders/create-line-item.ts +++ b/packages/medusa/src/api/routes/admin/draft-orders/create-line-item.ts @@ -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({ diff --git a/packages/medusa/src/api/routes/store/carts/create-cart.ts b/packages/medusa/src/api/routes/store/carts/create-cart.ts index 1bb8e54f8c..f5a062aabf 100644 --- a/packages/medusa/src/api/routes/store/carts/create-cart.ts +++ b/packages/medusa/src/api/routes/store/carts/create-cart.ts @@ -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"), + }) }) ) } diff --git a/packages/medusa/src/api/routes/store/carts/create-line-item.ts b/packages/medusa/src/api/routes/store/carts/create-line-item.ts index eaa069a431..eed62b2c78 100644 --- a/packages/medusa/src/api/routes/store/carts/create-line-item.ts +++ b/packages/medusa/src/api/routes/store/carts/create-line-item.ts @@ -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"], diff --git a/packages/medusa/src/repositories/sales-channel.ts b/packages/medusa/src/repositories/sales-channel.ts index a2d97b3c2c..5a4bd77063 100644 --- a/packages/medusa/src/repositories/sales-channel.ts +++ b/packages/medusa/src/repositories/sales-channel.ts @@ -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 { - public async getFreeTextSearchResultsAndCount( + public async getFreeTextSearchResultsAndCount( q: string, - options: ExtendedFindConfig> = { where: {} }, + options: ExtendedFindConfig> = { + where: {}, + } ): Promise<[SalesChannel[], number]> { const options_ = { ...options } delete options_?.where?.name @@ -17,8 +25,9 @@ export class SalesChannelRepository extends Repository { .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) diff --git a/packages/medusa/src/services/__mocks__/line-item-adjustment.js b/packages/medusa/src/services/__mocks__/line-item-adjustment.js index d5ea7e3fd2..8e0df20bc5 100644 --- a/packages/medusa/src/services/__mocks__/line-item-adjustment.js +++ b/packages/medusa/src/services/__mocks__/line-item-adjustment.js @@ -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) => { diff --git a/packages/medusa/src/services/__tests__/cart.js b/packages/medusa/src/services/__tests__/cart.js index f134cb98aa..d67b1a91b2 100644 --- a/packages/medusa/src/services/__tests__/cart.js +++ b/packages/medusa/src/services/__tests__/cart.js @@ -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(), diff --git a/packages/medusa/src/services/cart.ts b/packages/medusa/src/services/cart.ts index 24da409701..d5afadc559 100644 --- a/packages/medusa/src/services/cart.ts +++ b/packages/medusa/src/services/cart.ts @@ -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 { 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 { 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 { * 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 { 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 { + 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 { + async addLineItem( + cartId: string, + lineItem: LineItem, + config = { validateSalesChannels: true } + ): Promise { return await this.atomicPhase_( async (transactionManager: EntityManager) => { const cart = await this.retrieve(cartId, { @@ -588,6 +623,17 @@ class CartService extends TransactionBaseService { ], }) + 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 { /** * 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(