diff --git a/.changeset/mean-ghosts-live.md b/.changeset/mean-ghosts-live.md new file mode 100644 index 000000000000..83f64d2dd742 --- /dev/null +++ b/.changeset/mean-ghosts-live.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +fix(medusa): Fixes payloads associated with shipping profile requests, as well as fixes to the shippingProfileService. Also adds test suite for shipping profiles. diff --git a/integration-tests/api/__tests__/admin/shipping-profile.js b/integration-tests/api/__tests__/admin/shipping-profile.js new file mode 100644 index 000000000000..2abfefe8fb8f --- /dev/null +++ b/integration-tests/api/__tests__/admin/shipping-profile.js @@ -0,0 +1,346 @@ +const path = require("path") + +const setupServer = require("../../../helpers/setup-server") +const { useApi } = require("../../../helpers/use-api") +const { initDb, useDb } = require("../../../helpers/use-db") +const { + simpleProductFactory, + simpleShippingOptionFactory, + simpleShippingProfileFactory, +} = require("../../factories") +const adminSeeder = require("../../helpers/admin-seeder") + +const adminReqConfig = { + headers: { + Authorization: "Bearer test_token", + }, +} + +jest.setTimeout(30000) + +describe("/admin/shipping-profiles", () => { + let medusaProcess + let dbConnection + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd }) + medusaProcess = await setupServer({ cwd, verbose: false }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + + medusaProcess.kill() + }) + + describe("GET /admin/shipping-profiles", () => { + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("lists shipping profiles", async () => { + const api = useApi() + + const { + data: { shipping_profiles }, + status, + } = await api.get("/admin/shipping-profiles", adminReqConfig) + + expect(status).toEqual(200) + + // Should contain default and gift_card profiles + expect(shipping_profiles.length).toEqual(2) + }) + + it("gets a shipping profile by id", async () => { + const api = useApi() + + const profile = await simpleShippingProfileFactory(dbConnection) + + const { + data: { shipping_profile }, + status, + } = await api.get( + `/admin/shipping-profiles/${profile.id}`, + adminReqConfig + ) + + expect(status).toEqual(200) + expect(shipping_profile).toEqual( + expect.objectContaining({ + ...profile, + updated_at: expect.any(String), + created_at: expect.any(String), + }) + ) + }) + }) + + describe("POST /admin/shipping-profiles", () => { + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("creates a custom shipping profile", async () => { + const api = useApi() + + const payload = { + name: "test-profile-2023", + type: "custom", + } + + const { + data: { shipping_profile }, + status, + } = await api.post("/admin/shipping-profiles", payload, adminReqConfig) + + expect(status).toEqual(200) + expect(shipping_profile).toEqual( + expect.objectContaining({ + id: expect.any(String), + created_at: expect.any(String), + updated_at: expect.any(String), + deleted_at: null, + ...payload, + }) + ) + }) + + it("creates a default shipping profile", async () => { + const api = useApi() + + const payload = { + name: "test-profile-2023", + type: "default", + } + + const { + data: { shipping_profile }, + status, + } = await api.post("/admin/shipping-profiles", payload, adminReqConfig) + + expect(status).toEqual(200) + expect(shipping_profile).toEqual( + expect.objectContaining({ + id: expect.any(String), + created_at: expect.any(String), + updated_at: expect.any(String), + deleted_at: null, + ...payload, + }) + ) + }) + + it("creates a gift_card shipping profile", async () => { + const api = useApi() + + const payload = { + name: "test-profile-2023", + type: "gift_card", + } + + const { + data: { shipping_profile }, + status, + } = await api.post("/admin/shipping-profiles", payload, adminReqConfig) + + expect(status).toEqual(200) + expect(shipping_profile).toEqual( + expect.objectContaining({ + id: expect.any(String), + created_at: expect.any(String), + updated_at: expect.any(String), + deleted_at: null, + ...payload, + }) + ) + }) + + it("creates a shipping profile with metadata", async () => { + const api = useApi() + + const payload = { + name: "test-profile-2023", + type: "default", + metadata: { + custom_key: "custom_value", + }, + } + + const { + data: { shipping_profile }, + status, + } = await api.post("/admin/shipping-profiles", payload, adminReqConfig) + + expect(status).toEqual(200) + expect(shipping_profile).toEqual( + expect.objectContaining({ + id: expect.any(String), + created_at: expect.any(String), + updated_at: expect.any(String), + deleted_at: null, + ...payload, + }) + ) + }) + + it("fails to create a shipping profile with invalid type", async () => { + const api = useApi() + expect.assertions(2) + + const payload = { + name: "test-profile-2023", + type: "invalid", + } + + await api + .post("/admin/shipping-profiles", payload, adminReqConfig) + .catch((err) => { + expect(err.response.status).toEqual(400) + expect(err.response.data.message).toEqual( + "type must be one of 'default', 'custom', 'gift_card'" + ) + }) + }) + + it("updates a shipping profile", async () => { + const api = useApi() + + const testProducts = await Promise.all( + [...Array(5).keys()].map(async () => { + return await simpleProductFactory(dbConnection) + }) + ) + + const testShippingOptions = await Promise.all( + [...Array(5).keys()].map(async () => { + return await simpleShippingOptionFactory(dbConnection) + }) + ) + + const payload = { + name: "test-profile-2023", + type: "custom", + metadata: { + my_key: "my_value", + }, + } + + const { + data: { shipping_profile: created }, + } = await api.post("/admin/shipping-profiles", payload, adminReqConfig) + + const updatePayload = { + name: "test-profile-2023-updated", + products: testProducts.map((p) => p.id), + shipping_options: testShippingOptions.map((o) => o.id), + metadata: { + my_key: "", + my_new_key: "my_new_value", + }, + } + + const { + data: { shipping_profile }, + status, + } = await api.post( + `/admin/shipping-profiles/${created.id}`, + updatePayload, + adminReqConfig + ) + + expect(status).toEqual(200) + expect(shipping_profile).toEqual( + expect.objectContaining({ + name: "test-profile-2023-updated", + created_at: expect.any(String), + updated_at: expect.any(String), + metadata: { + my_new_key: "my_new_value", + }, + deleted_at: null, + type: "custom", + }) + ) + + const { + data: { products }, + } = await api.get(`/admin/products`, adminReqConfig) + + expect(products.length).toEqual(5) + expect(products).toEqual( + expect.arrayContaining( + testProducts.map((p) => { + return expect.objectContaining({ + id: p.id, + profile_id: shipping_profile.id, + }) + }) + ) + ) + + const { + data: { shipping_options }, + } = await api.get(`/admin/shipping-options`, adminReqConfig) + + const numberOfShippingOptionsWithProfile = shipping_options.filter( + (so) => so.profile_id === shipping_profile.id + ).length + + expect(numberOfShippingOptionsWithProfile).toEqual(5) + expect(shipping_options).toEqual( + expect.arrayContaining( + testShippingOptions.map((o) => { + return expect.objectContaining({ + id: o.id, + profile_id: shipping_profile.id, + }) + }) + ) + ) + }) + }) + + describe("DELETE /admin/shipping-profiles", () => { + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("deletes a shipping profile", async () => { + expect.assertions(2) + + const api = useApi() + + const profile = await simpleShippingProfileFactory(dbConnection) + + const { status } = await api.delete( + `/admin/shipping-profiles/${profile.id}`, + adminReqConfig + ) + + expect(status).toEqual(200) + await api + .get(`/admin/shipping-profiles/${profile.id}`, adminReqConfig) + .catch((err) => { + expect(err.response.status).toEqual(404) + }) + }) + }) +}) diff --git a/integration-tests/api/factories/index.ts b/integration-tests/api/factories/index.ts index b0e578e81e14..6647208a5b24 100644 --- a/integration-tests/api/factories/index.ts +++ b/integration-tests/api/factories/index.ts @@ -1,25 +1,25 @@ -export * from "./simple-gift-card-factory" -export * from "./simple-payment-factory" export * from "./simple-batch-job-factory" -export * from "./simple-discount-factory" -export * from "./simple-order-factory" export * from "./simple-cart-factory" -export * from "./simple-region-factory" +export * from "./simple-custom-shipping-option-factory" +export * from "./simple-customer-factory" +export * from "./simple-discount-factory" +export * from "./simple-gift-card-factory" export * from "./simple-line-item-factory" +export * from "./simple-order-edit-factory" +export * from "./simple-order-factory" +export * from "./simple-order-item-change-factory" +export * from "./simple-payment-collection-factory" +export * from "./simple-payment-factory" +export * from "./simple-price-list-factory" +export * from "./simple-product-category-factory" export * from "./simple-product-factory" -export * from "./simple-product-variant-factory" export * from "./simple-product-tax-rate-factory" -export * from "./simple-shipping-tax-rate-factory" -export * from "./simple-tax-rate-factory" -export * from "./simple-shipping-option-factory" -export * from "./simple-shipping-method-factory" export * from "./simple-product-type-tax-rate-factory" -export * from "./simple-price-list-factory" -export * from "./simple-batch-job-factory" +export * from "./simple-product-variant-factory" +export * from "./simple-region-factory" export * from "./simple-sales-channel-factory" -export * from "./simple-custom-shipping-option-factory" -export * from "./simple-payment-collection-factory" -export * from "./simple-order-edit-factory" -export * from "./simple-order-item-change-factory" -export * from "./simple-customer-factory" -export * from "./simple-product-category-factory" +export * from "./simple-shipping-method-factory" +export * from "./simple-shipping-option-factory" +export * from "./simple-shipping-profile-factory" +export * from "./simple-shipping-tax-rate-factory" +export * from "./simple-tax-rate-factory" diff --git a/integration-tests/api/factories/simple-shipping-option-factory.ts b/integration-tests/api/factories/simple-shipping-option-factory.ts index d77901a27119..f3462eb0c552 100644 --- a/integration-tests/api/factories/simple-shipping-option-factory.ts +++ b/integration-tests/api/factories/simple-shipping-option-factory.ts @@ -7,28 +7,29 @@ import { } from "@medusajs/medusa" import faker from "faker" import { Connection } from "typeorm" +import { simpleRegionFactory } from "./simple-region-factory" export type ShippingOptionFactoryData = { id?: string name?: string - region_id: string + region_id?: string is_return?: boolean is_giftcard?: boolean price?: number price_type?: ShippingOptionPriceType includes_tax?: boolean data?: object - requirements: ShippingOptionRequirementData[] + requirements?: ShippingOptionRequirementData[] } type ShippingOptionRequirementData = { - type: 'min_subtotal' | 'max_subtotal' + type: "min_subtotal" | "max_subtotal" amount: number } export const simpleShippingOptionFactory = async ( connection: Connection, - data: ShippingOptionFactoryData, + data: ShippingOptionFactoryData = {}, seed?: number ): Promise => { if (typeof seed !== "undefined") { @@ -44,11 +45,18 @@ export const simpleShippingOptionFactory = async ( type: ShippingProfileType.GIFT_CARD, }) + let region_id = data.region_id + + if (!region_id) { + const { id } = await simpleRegionFactory(connection) + region_id = id + } + const shippingOptionData = { id: data.id ?? `simple-so-${Math.random() * 1000}`, name: data.name || "Test Method", is_return: data.is_return ?? false, - region_id: data.region_id, + region_id: region_id, provider_id: "test-ful", profile_id: data.is_giftcard ? gcProfile.id : defaultProfile.id, price_type: data.price_type ?? ShippingOptionPriceType.FLAT_RATE, diff --git a/integration-tests/api/factories/simple-shipping-profile-factory.ts b/integration-tests/api/factories/simple-shipping-profile-factory.ts new file mode 100644 index 000000000000..ffae97894035 --- /dev/null +++ b/integration-tests/api/factories/simple-shipping-profile-factory.ts @@ -0,0 +1,35 @@ +import { ShippingProfile, ShippingProfileType } from "@medusajs/medusa" +import faker from "faker" +import { Connection } from "typeorm" + +export type ShippingProfileFactoryData = { + id?: string + name?: string + type?: ShippingProfileType + metadata?: Record +} + +export const simpleShippingProfileFactory = async ( + connection: Connection, + data: ShippingOptionFactoryData = {}, + seed?: number +): Promise => { + if (typeof seed !== "undefined") { + faker.seed(seed) + } + + const manager = connection.manager + + const shippingProfileData = { + id: data.id ?? `simple-sp-${Math.random() * 1000}`, + name: data.name || `sp-${Math.random() * 1000}`, + type: data.type || ShippingProfileType.DEFAULT, + metadata: data.metadata, + products: [], + shipping_options: [], + } + + const created = manager.create(ShippingProfile, shippingProfileData) + + return await manager.save(created) +} diff --git a/packages/medusa/src/api/routes/admin/shipping-profiles/__tests__/create-shipping-profile.js b/packages/medusa/src/api/routes/admin/shipping-profiles/__tests__/create-shipping-profile.js index 9ce1b96d646a..37c19ed827ae 100644 --- a/packages/medusa/src/api/routes/admin/shipping-profiles/__tests__/create-shipping-profile.js +++ b/packages/medusa/src/api/routes/admin/shipping-profiles/__tests__/create-shipping-profile.js @@ -10,6 +10,7 @@ describe("POST /admin/shipping-profiles", () => { subject = await request("POST", "/admin/shipping-profiles", { payload: { name: "Test Profile", + type: "default", }, adminSession: { jwt: { @@ -27,6 +28,7 @@ describe("POST /admin/shipping-profiles", () => { expect(ShippingProfileServiceMock.create).toHaveBeenCalledTimes(1) expect(ShippingProfileServiceMock.create).toHaveBeenCalledWith({ name: "Test Profile", + type: "default", }) }) }) diff --git a/packages/medusa/src/api/routes/admin/shipping-profiles/create-shipping-profile.ts b/packages/medusa/src/api/routes/admin/shipping-profiles/create-shipping-profile.ts index 4f4b5696dae0..4b53042154df 100644 --- a/packages/medusa/src/api/routes/admin/shipping-profiles/create-shipping-profile.ts +++ b/packages/medusa/src/api/routes/admin/shipping-profiles/create-shipping-profile.ts @@ -1,7 +1,8 @@ -import { IsString } from "class-validator" +import { IsEnum, IsObject, IsOptional, IsString } from "class-validator" +import { EntityManager } from "typeorm" +import { ShippingProfileType } from "../../../../models" import { ShippingProfileService } from "../../../../services" import { validator } from "../../../../utils/validator" -import { EntityManager } from "typeorm" /** * @oas [post] /shipping-profiles @@ -84,12 +85,26 @@ export default async (req, res) => { * type: object * required: * - name + * - type * properties: * name: - * description: "The name of the Shipping Profile" + * description: The name of the Shipping Profile + * type: string + * type: + * description: The type of the Shipping Profile * type: string + * enum: [default, gift_card, custom] */ export class AdminPostShippingProfilesReq { @IsString() name: string + + @IsEnum(ShippingProfileType, { + message: "type must be one of 'default', 'custom', 'gift_card'", + }) + type: ShippingProfileType + + @IsOptional() + @IsObject() + metadata?: Record } diff --git a/packages/medusa/src/api/routes/admin/shipping-profiles/update-shipping-profile.ts b/packages/medusa/src/api/routes/admin/shipping-profiles/update-shipping-profile.ts index 2f73275b77a9..46f4bae74df6 100644 --- a/packages/medusa/src/api/routes/admin/shipping-profiles/update-shipping-profile.ts +++ b/packages/medusa/src/api/routes/admin/shipping-profiles/update-shipping-profile.ts @@ -1,8 +1,15 @@ -import { IsOptional, IsString } from "class-validator" +import { + IsArray, + IsEnum, + IsObject, + IsOptional, + IsString, +} from "class-validator" +import { EntityManager } from "typeorm" +import { ShippingProfileType } from "../../../../models" import { ShippingProfileService } from "../../../../services" import { validator } from "../../../../utils/validator" -import { EntityManager } from "typeorm" /** * @oas [post] /shipping-profiles/{id} @@ -93,11 +100,44 @@ export default async (req, res) => { * type: object * properties: * name: - * description: "The name of the Shipping Profile" + * description: The name of the Shipping Profile + * type: string + * metadata: + * description: An optional set of key-value pairs with additional information. + * type: object + * type: + * description: The type of the Shipping Profile * type: string + * enum: [default, gift_card, custom] + * products: + * description: An optional array of product ids to associate with the Shipping Profile + * type: array + * shipping_options: + * description: An optional array of shipping option ids to associate with the Shipping Profile + * type: array */ export class AdminPostShippingProfilesProfileReq { @IsString() @IsOptional() name?: string + + @IsOptional() + @IsObject() + metadata?: Record + + @IsOptional() + @IsEnum(ShippingProfileType, { + message: "type must be one of 'default', 'custom', 'gift_card'", + }) + type?: ShippingProfileType + + @IsOptional() + @IsArray() + @IsString({ each: true }) + products?: string[] + + @IsOptional() + @IsArray() + @IsString({ each: true }) + shipping_options?: string[] } diff --git a/packages/medusa/src/repositories/product.ts b/packages/medusa/src/repositories/product.ts index 82d912341897..5b60858ad306 100644 --- a/packages/medusa/src/repositories/product.ts +++ b/packages/medusa/src/repositories/product.ts @@ -6,7 +6,7 @@ import { In, Repository, } from "typeorm" -import { PriceList, Product, SalesChannel, ProductCategory } from "../models" +import { PriceList, Product, ProductCategory, SalesChannel } from "../models" import { ExtendedFindConfig, Selector, @@ -540,6 +540,25 @@ export class ProductRepository extends Repository { ) } + /** + * Upserts shipping profile for products + * @param productIds IDs of products to update + * @param shippingProfileId ID of shipping profile to assign to products + * @returns updated products + */ + public async upsertShippingProfile( + productIds: string[], + shippingProfileId: string + ): Promise { + await this.createQueryBuilder() + .update(Product) + .set({ profile_id: shippingProfileId }) + .where({ id: In(productIds) }) + .execute() + + return await this.findByIds(productIds) + } + private _cleanOptions( options: FindWithoutRelationsOptions ): WithRequiredProperty { diff --git a/packages/medusa/src/repositories/shipping-option.ts b/packages/medusa/src/repositories/shipping-option.ts index eb9a7d9c5d16..51a0d941fbea 100644 --- a/packages/medusa/src/repositories/shipping-option.ts +++ b/packages/medusa/src/repositories/shipping-option.ts @@ -1,5 +1,18 @@ -import { EntityRepository, Repository } from "typeorm" +import { EntityRepository, In, Repository } from "typeorm" import { ShippingOption } from "../models/shipping-option" @EntityRepository(ShippingOption) -export class ShippingOptionRepository extends Repository {} +export class ShippingOptionRepository extends Repository { + public async upsertShippingProfile( + shippingOptionIds: string[], + shippingProfileId: string + ): Promise { + await this.createQueryBuilder() + .update(ShippingOption) + .set({ profile_id: shippingProfileId }) + .where({ id: In(shippingOptionIds) }) + .execute() + + return this.findByIds(shippingOptionIds) + } +} diff --git a/packages/medusa/src/services/__mocks__/product.js b/packages/medusa/src/services/__mocks__/product.js index f9c9e330e988..a00b5b2ce5c4 100644 --- a/packages/medusa/src/services/__mocks__/product.js +++ b/packages/medusa/src/services/__mocks__/product.js @@ -124,6 +124,7 @@ export const ProductServiceMock = { deleteOption: jest .fn() .mockReturnValue(Promise.resolve(products.productWithOptions)), + updateshippingProfiles: jest.fn().mockReturnValue(Promise.resolve()), retrieveVariants: jest.fn().mockImplementation((productId) => { if (productId === IdMap.getId("product1")) { return Promise.resolve([ diff --git a/packages/medusa/src/services/__mocks__/shipping-option.js b/packages/medusa/src/services/__mocks__/shipping-option.js index b866ec978231..e5c1a0ed5c6e 100644 --- a/packages/medusa/src/services/__mocks__/shipping-option.js +++ b/packages/medusa/src/services/__mocks__/shipping-option.js @@ -89,6 +89,7 @@ export const ShippingOptionServiceMock = { return Promise.resolve(undefined) }), update: jest.fn().mockReturnValue(Promise.resolve()), + updateShippingprofile: jest.fn().mockReturnValue(Promise.resolve()), listAndCount: jest.fn().mockImplementation((data) => { if (data.region_id === IdMap.getId("region-france")) { return Promise.resolve([[shippingOptions.franceShipping], 1]) diff --git a/packages/medusa/src/services/__mocks__/shipping-profile.js b/packages/medusa/src/services/__mocks__/shipping-profile.js index c29a205d5def..36cc7827d5ff 100644 --- a/packages/medusa/src/services/__mocks__/shipping-profile.js +++ b/packages/medusa/src/services/__mocks__/shipping-profile.js @@ -19,10 +19,10 @@ export const ShippingProfileServiceMock = { withTransaction: function () { return this }, - update: jest.fn().mockImplementation(data => { + update: jest.fn().mockImplementation((data) => { return Promise.resolve() }), - create: jest.fn().mockImplementation(data => { + create: jest.fn().mockImplementation((data) => { return Promise.resolve(data) }), createDefault: jest.fn().mockImplementation(() => { @@ -31,7 +31,7 @@ export const ShippingProfileServiceMock = { createGiftCardDefault: jest.fn().mockImplementation(() => { return Promise.resolve() }), - retrieve: jest.fn().mockImplementation(data => { + retrieve: jest.fn().mockImplementation((data) => { if (data === IdMap.getId("default")) { return Promise.resolve(profiles.default) } @@ -40,13 +40,13 @@ export const ShippingProfileServiceMock = { } return Promise.resolve(profiles.default) }), - retrieveGiftCardDefault: jest.fn().mockImplementation(data => { + retrieveGiftCardDefault: jest.fn().mockImplementation((data) => { return Promise.resolve({ id: IdMap.getId("giftCardProfile") }) }), - retrieveDefault: jest.fn().mockImplementation(data => { + retrieveDefault: jest.fn().mockImplementation((data) => { return Promise.resolve({ id: IdMap.getId("default_shipping_profile") }) }), - list: jest.fn().mockImplementation(selector => { + list: jest.fn().mockImplementation((selector) => { if (!selector) { return Promise.resolve([]) } @@ -135,7 +135,7 @@ export const ShippingProfileServiceMock = { ]) } }), - decorate: jest.fn().mockImplementation(d => Promise.resolve(d)), + decorate: jest.fn().mockImplementation((d) => Promise.resolve(d)), addShippingOption: jest.fn().mockImplementation(() => Promise.resolve()), removeShippingOption: jest.fn().mockImplementation(() => Promise.resolve()), addProduct: jest.fn().mockImplementation(() => Promise.resolve()), diff --git a/packages/medusa/src/services/__tests__/shipping-profile.js b/packages/medusa/src/services/__tests__/shipping-profile.js index c69472141170..4a3310b1c12e 100644 --- a/packages/medusa/src/services/__tests__/shipping-profile.js +++ b/packages/medusa/src/services/__tests__/shipping-profile.js @@ -35,14 +35,14 @@ describe("ShippingProfileService", () => { }) const productService = { - update: jest.fn(), + updateShippingProfile: jest.fn(), withTransaction: function () { return this }, } const shippingOptionService = { - update: jest.fn(), + updateShippingProfile: jest.fn(), withTransaction: function () { return this }, @@ -75,10 +75,11 @@ describe("ShippingProfileService", () => { products: [IdMap.getId("product1")], }) - expect(productService.update).toBeCalledTimes(1) - expect(productService.update).toBeCalledWith(IdMap.getId("product1"), { - profile_id: id, - }) + expect(productService.updateShippingProfile).toBeCalledTimes(1) + expect(productService.updateShippingProfile).toBeCalledWith( + [IdMap.getId("product1")], + id + ) }) it("calls updateOne with shipping options", async () => { @@ -88,10 +89,10 @@ describe("ShippingProfileService", () => { shipping_options: [IdMap.getId("validId")], }) - expect(shippingOptionService.update).toBeCalledTimes(1) - expect(shippingOptionService.update).toBeCalledWith( - IdMap.getId("validId"), - { profile_id: id } + expect(shippingOptionService.updateShippingProfile).toBeCalledTimes(1) + expect(shippingOptionService.updateShippingProfile).toBeCalledWith( + [IdMap.getId("validId")], + id ) }) }) @@ -125,7 +126,7 @@ describe("ShippingProfileService", () => { const profRepo = MockRepository({ findOne: () => Promise.resolve({}) }) const productService = { - update: jest.fn(), + updateShippingProfile: jest.fn(), withTransaction: function () { return this }, @@ -142,15 +143,15 @@ describe("ShippingProfileService", () => { }) it("add product to profile successfully", async () => { - await profileService.addProduct( - IdMap.getId("validId"), - IdMap.getId("product2") - ) + await profileService.addProduct(IdMap.getId("validId"), [ + IdMap.getId("product2"), + ]) - expect(productService.update).toBeCalledTimes(1) - expect(productService.update).toBeCalledWith(IdMap.getId("product2"), { - profile_id: IdMap.getId("validId"), - }) + expect(productService.updateShippingProfile).toBeCalledTimes(1) + expect(productService.updateShippingProfile).toBeCalledWith( + [IdMap.getId("product2")], + IdMap.getId("validId") + ) }) }) @@ -300,7 +301,7 @@ describe("ShippingProfileService", () => { const profRepo = MockRepository({ findOne: () => Promise.resolve({}) }) const shippingOptionService = { - update: jest.fn(), + updateShippingProfile: jest.fn(), withTransaction: function () { return this }, @@ -317,15 +318,14 @@ describe("ShippingProfileService", () => { }) it("add shipping option to profile successfully", async () => { - await profileService.addShippingOption( - IdMap.getId("validId"), - IdMap.getId("freeShipping") - ) - - expect(shippingOptionService.update).toBeCalledTimes(1) - expect(shippingOptionService.update).toBeCalledWith( + await profileService.addShippingOption(IdMap.getId("validId"), [ IdMap.getId("freeShipping"), - { profile_id: IdMap.getId("validId") } + ]) + + expect(shippingOptionService.updateShippingProfile).toBeCalledTimes(1) + expect(shippingOptionService.updateShippingProfile).toBeCalledWith( + [IdMap.getId("freeShipping")], + IdMap.getId("validId") ) }) }) diff --git a/packages/medusa/src/services/product.ts b/packages/medusa/src/services/product.ts index d45b363989ba..15afb5805572 100644 --- a/packages/medusa/src/services/product.ts +++ b/packages/medusa/src/services/product.ts @@ -1,18 +1,18 @@ import { FlagRouter } from "../utils/flag-router" import { isDefined, MedusaError } from "medusa-core-utils" -import { EntityManager, In } from "typeorm" +import { EntityManager } from "typeorm" import { ProductVariantService, SearchService } from "." import { TransactionBaseService } from "../interfaces" import SalesChannelFeatureFlag from "../loaders/feature-flags/sales-channels" import { Product, + ProductCategory, ProductOption, ProductTag, ProductType, ProductVariant, SalesChannel, - ProductCategory, } from "../models" import { ImageRepository } from "../repositories/image" import { @@ -33,7 +33,7 @@ import { ProductSelector, UpdateProductInput, } from "../types/product" -import { buildQuery, setMetadata } from "../utils" +import { buildQuery, isString, setMetadata } from "../utils" import EventBusService from "./event-bus" type InjectedDependencies = { @@ -446,7 +446,9 @@ class ProductService extends TransactionBaseService { if (categories?.length) { const categoryIds = categories.map((c) => c.id) - const categoryRecords = categoryIds.map((id) => ({ id } as ProductCategory)) + const categoryRecords = categoryIds.map( + (id) => ({ id } as ProductCategory) + ) product.categories = categoryRecords } @@ -560,7 +562,9 @@ class ProductService extends TransactionBaseService { if (categories?.length) { const categoryIds = categories.map((c) => c.id) - const categoryRecords = categoryIds.map((id) => ({ id } as ProductCategory)) + const categoryRecords = categoryIds.map( + (id) => ({ id } as ProductCategory) + ) product.categories = categoryRecords } @@ -869,6 +873,31 @@ class ProductService extends TransactionBaseService { }) } + /** + * + * @param productIds ID or IDs of the products to update + * @param profileId Shipping profile ID to update the shipping options with + * @returns updated shipping options + */ + async updateShippingProfile( + productIds: string | string[], + profileId: string + ): Promise { + return await this.atomicPhase_(async (manager) => { + const productRepo = manager.getCustomRepository(this.productRepository_) + + const ids = isString(productIds) ? [productIds] : productIds + + const products = await productRepo.upsertShippingProfile(ids, profileId) + + await this.eventBus_ + .withTransaction(manager) + .emit(ProductService.Events.UPDATED, products) + + return products + }) + } + /** * Creates a query object to be used for list queries. * @param selector - the selector to create the query from diff --git a/packages/medusa/src/services/shipping-option.ts b/packages/medusa/src/services/shipping-option.ts index 7decefc9b204..c672fb59ef04 100644 --- a/packages/medusa/src/services/shipping-option.ts +++ b/packages/medusa/src/services/shipping-option.ts @@ -21,7 +21,7 @@ import { UpdateShippingOptionInput, ValidatePriceTypeAndAmountInput, } from "../types/shipping-options" -import { buildQuery, setMetadata } from "../utils" +import { buildQuery, isString, setMetadata } from "../utils" import { FlagRouter } from "../utils/flag-router" import FulfillmentProviderService from "./fulfillment-provider" import RegionService from "./region" @@ -663,6 +663,10 @@ class ShippingOptionService extends TransactionBaseService { optionWithValidatedPrice.admin_only = update.admin_only } + if (isDefined(update.profile_id)) { + optionWithValidatedPrice.profile_id = update.profile_id + } + if ( this.featureFlagRouter_.isFeatureEnabled( TaxInclusivePricingFeatureFlag.key @@ -754,6 +758,25 @@ class ShippingOptionService extends TransactionBaseService { }) } + /** + * + * @param optionIds ID or IDs of the shipping options to update + * @param profileId Shipping profile ID to update the shipping options with + * @returns updated shipping options + */ + async updateShippingProfile( + optionIds: string | string[], + profileId: string + ): Promise { + return await this.atomicPhase_(async (manager) => { + const optionRepo = manager.getCustomRepository(this.optionRepository_) + + const ids = isString(optionIds) ? [optionIds] : optionIds + + return await optionRepo.upsertShippingProfile(ids, profileId) + }) + } + /** * Returns the amount to be paid for a shipping method. Will ask the * fulfillment provider to calculate the price if the shipping option has the diff --git a/packages/medusa/src/services/shipping-profile.ts b/packages/medusa/src/services/shipping-profile.ts index 49a34192c90c..d427f73e7738 100644 --- a/packages/medusa/src/services/shipping-profile.ts +++ b/packages/medusa/src/services/shipping-profile.ts @@ -15,7 +15,7 @@ import { CreateShippingProfile, UpdateShippingProfile, } from "../types/shipping-profile" -import { buildQuery, setMetadata } from "../utils" +import { buildQuery, isString, setMetadata } from "../utils" import CustomShippingOptionService from "./custom-shipping-option" import ProductService from "./product" import ShippingOptionService from "./shipping-option" @@ -260,7 +260,14 @@ class ShippingProfileService extends TransactionBaseService { ) } - const created = profileRepository.create(profile) + const { metadata, ...rest } = profile + + const created = profileRepository.create(rest) + + if (metadata) { + created.metadata = setMetadata(created, metadata) + } + const result = await profileRepository.save(created) return result }) @@ -284,7 +291,7 @@ class ShippingProfileService extends TransactionBaseService { this.shippingProfileRepository_ ) - const profile = await this.retrieve(profileId, { + let profile = await this.retrieve(profileId, { relations: [ "products", "products.profile", @@ -295,27 +302,16 @@ class ShippingProfileService extends TransactionBaseService { const { metadata, products, shipping_options, ...rest } = update - if (metadata) { - profile.metadata = setMetadata(profile, metadata) - } - if (products) { - const productServiceTx = this.productService_.withTransaction(manager) - for (const pId of products) { - await productServiceTx.update(pId, { - profile_id: profile.id, - }) - } + profile = await this.addProduct(profile.id, products) } if (shipping_options) { - const shippingOptionServiceTx = - this.shippingOptionService_.withTransaction(manager) - for (const oId of shipping_options) { - await shippingOptionServiceTx.update(oId, { - profile_id: profile.id, - }) - } + profile = await this.addShippingOption(profile.id, shipping_options) + } + + if (metadata) { + profile.metadata = setMetadata(profile, metadata) } for (const [key, value] of Object.entries(rest)) { @@ -352,22 +348,31 @@ class ShippingProfileService extends TransactionBaseService { } /** - * Adds a product to a profile. The method is idempotent, so multiple calls - * with the same product variant will have the same result. - * @param profileId - the profile to add the product to. - * @param productId - the product to add. + * Adds a product of an array of products to the profile. + * @param profileId - the profile to add the products to. + * @param productId - the ID of the product or multiple products to add. * @return the result of update */ async addProduct( profileId: string, - productId: string + productId: string | string[] ): Promise { return await this.atomicPhase_(async (manager) => { - await this.productService_ - .withTransaction(manager) - .update(productId, { profile_id: profileId }) + const productServiceTx = this.productService_.withTransaction(manager) - return await this.retrieve(profileId) + await productServiceTx.updateShippingProfile( + isString(productId) ? [productId] : productId, + profileId + ) + + return await this.retrieve(profileId, { + relations: [ + "products", + "products.profile", + "shipping_options", + "shipping_options.profile", + ], + }) }) } @@ -375,20 +380,30 @@ class ShippingProfileService extends TransactionBaseService { * Adds a shipping option to the profile. The shipping option can be used to * fulfill the products in the products field. * @param profileId - the profile to apply the shipping option to - * @param optionId - the option to add to the profile + * @param optionId - the ID of the option or multiple options to add to the profile * @return the result of the model update operation */ async addShippingOption( profileId: string, - optionId: string + optionId: string | string[] ): Promise { return await this.atomicPhase_(async (manager) => { - await this.shippingOptionService_ - .withTransaction(manager) - .update(optionId, { profile_id: profileId }) + const shippingOptionServiceTx = + this.shippingOptionService_.withTransaction(manager) - const updated = await this.retrieve(profileId) - return updated + await shippingOptionServiceTx.updateShippingProfile( + isString(optionId) ? [optionId] : optionId, + profileId + ) + + return await this.retrieve(profileId, { + relations: [ + "products", + "products.profile", + "shipping_options", + "shipping_options.profile", + ], + }) }) } diff --git a/packages/medusa/src/types/shipping-profile.ts b/packages/medusa/src/types/shipping-profile.ts index 2a4db42f3761..e813ff027f46 100644 --- a/packages/medusa/src/types/shipping-profile.ts +++ b/packages/medusa/src/types/shipping-profile.ts @@ -1,7 +1,9 @@ -import { Product, ShippingOption, ShippingProfileType } from "../models" +import { ShippingProfileType } from "../models" export type CreateShippingProfile = { name: string + type: ShippingProfileType + metadata?: Record } export type UpdateShippingProfile = {