From eadf13cb2101b2b12753428df0fb775f828cd9ac Mon Sep 17 00:00:00 2001 From: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> Date: Mon, 5 Jun 2023 20:14:05 +0200 Subject: [PATCH] feat(medusa): variants expand inventory_items (#4203) * add expand params for inventory items to product and variant endpoints in store * add changeset * update integration test naming * make priceSeelctionParams extends findParams and adjust api accordingly --- .changeset/quiet-flowers-prove.md | 5 + .../inventory/products/get-product.js | 107 ++++++++++++++++++ .../inventory/products/get-variant.js | 101 +++++++++++++++++ .../inventory/products/list-products.js | 21 ++++ .../inventory/products/list-variants.js | 26 ++++- .../api/routes/store/products/get-product.ts | 11 +- .../src/api/routes/store/products/index.ts | 1 + .../routes/store/products/list-products.ts | 31 ++--- .../api/routes/store/variants/get-variant.ts | 7 +- .../src/api/routes/store/variants/index.ts | 30 ++++- .../routes/store/variants/list-variants.ts | 16 +-- packages/medusa/src/types/price-selection.ts | 4 +- 12 files changed, 312 insertions(+), 48 deletions(-) create mode 100644 .changeset/quiet-flowers-prove.md create mode 100644 integration-tests/plugins/__tests__/inventory/products/get-product.js create mode 100644 integration-tests/plugins/__tests__/inventory/products/get-variant.js diff --git a/.changeset/quiet-flowers-prove.md b/.changeset/quiet-flowers-prove.md new file mode 100644 index 0000000000..b1c61373a6 --- /dev/null +++ b/.changeset/quiet-flowers-prove.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +feat(medusa): add allowed relation to expand params for product and variant endpoints diff --git a/integration-tests/plugins/__tests__/inventory/products/get-product.js b/integration-tests/plugins/__tests__/inventory/products/get-product.js new file mode 100644 index 0000000000..54ca2507d4 --- /dev/null +++ b/integration-tests/plugins/__tests__/inventory/products/get-product.js @@ -0,0 +1,107 @@ +const path = require("path") + +const { bootstrapApp } = require("../../../../helpers/bootstrap-app") +const { initDb, useDb } = require("../../../../helpers/use-db") +const { setPort, useApi } = require("../../../../helpers/use-api") + +const { + ProductVariantInventoryService, + ProductVariantService, +} = require("@medusajs/medusa") + +const adminSeeder = require("../../../helpers/admin-seeder") + +jest.setTimeout(30000) + +const { simpleProductFactory } = require("../../../factories") +const { simpleSalesChannelFactory } = require("../../../../api/factories") + +const adminHeaders = { headers: { Authorization: "Bearer test_token" } } + +describe("Get products", () => { + let appContainer + let dbConnection + let express + const productId = "test-product" + const variantId = "test-variant" + let invItem + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd }) + const { container, app, port } = await bootstrapApp({ cwd }) + appContainer = container + + setPort(port) + express = app.listen(port, (err) => { + process.send(port) + }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + express.close() + }) + + afterEach(async () => { + jest.clearAllMocks() + const db = useDb() + return await db.teardown() + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + + const productVariantInventoryService = appContainer.resolve( + "productVariantInventoryService" + ) + const inventoryService = appContainer.resolve("inventoryService") + + await simpleProductFactory( + dbConnection, + { + id: productId, + status: "published", + variants: [{ id: variantId }], + }, + 100 + ) + + invItem = await inventoryService.createInventoryItem({ + sku: "test-sku", + }) + await productVariantInventoryService.attachInventoryItem( + variantId, + invItem.id + ) + }) + + it("Expands inventory items when getting product with expand parameters", async () => { + const api = useApi() + + const res = await api.get( + `/store/products/${productId}?expand=variants,variants.inventory_items`, + adminHeaders + ) + + expect(res.status).toEqual(200) + expect(res.data.product).toEqual( + expect.objectContaining({ + id: productId, + variants: [ + expect.objectContaining({ + id: variantId, + inventory_items: [ + expect.objectContaining({ + inventory_item_id: invItem.id, + variant_id: variantId, + }), + ], + }), + ], + }), + expect.objectContaining({}) + ) + }) +}) diff --git a/integration-tests/plugins/__tests__/inventory/products/get-variant.js b/integration-tests/plugins/__tests__/inventory/products/get-variant.js new file mode 100644 index 0000000000..86a58ab7fc --- /dev/null +++ b/integration-tests/plugins/__tests__/inventory/products/get-variant.js @@ -0,0 +1,101 @@ +const path = require("path") + +const { bootstrapApp } = require("../../../../helpers/bootstrap-app") +const { initDb, useDb } = require("../../../../helpers/use-db") +const { setPort, useApi } = require("../../../../helpers/use-api") + +const { + ProductVariantInventoryService, + ProductVariantService, +} = require("@medusajs/medusa") + +const adminSeeder = require("../../../helpers/admin-seeder") + +jest.setTimeout(30000) + +const { simpleProductFactory } = require("../../../factories") +const { simpleSalesChannelFactory } = require("../../../../api/factories") + +const adminHeaders = { headers: { Authorization: "Bearer test_token" } } + +describe("Get variant", () => { + let appContainer + let dbConnection + let express + const productId = "test-product" + const variantId = "test-variant" + let invItem + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd }) + const { container, app, port } = await bootstrapApp({ cwd }) + appContainer = container + + setPort(port) + express = app.listen(port, (err) => { + process.send(port) + }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + express.close() + }) + + afterEach(async () => { + jest.clearAllMocks() + const db = useDb() + return await db.teardown() + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + + const productVariantInventoryService = appContainer.resolve( + "productVariantInventoryService" + ) + const inventoryService = appContainer.resolve("inventoryService") + + await simpleProductFactory( + dbConnection, + { + id: productId, + status: "published", + variants: [{ id: variantId }], + }, + 100 + ) + + invItem = await inventoryService.createInventoryItem({ + sku: "test-sku", + }) + await productVariantInventoryService.attachInventoryItem( + variantId, + invItem.id + ) + }) + + it("Expands inventory items when getting variant with expand parameters", async () => { + const api = useApi() + + const res = await api.get( + `/store/variants/${variantId}?expand=inventory_items`, + adminHeaders + ) + + expect(res.status).toEqual(200) + expect(res.data.variant).toEqual( + expect.objectContaining({ + id: variantId, + inventory_items: [ + expect.objectContaining({ + inventory_item_id: invItem.id, + variant_id: variantId, + }), + ], + }) + ) + }) +}) diff --git a/integration-tests/plugins/__tests__/inventory/products/list-products.js b/integration-tests/plugins/__tests__/inventory/products/list-products.js index f605b67979..8c3faaef42 100644 --- a/integration-tests/plugins/__tests__/inventory/products/list-products.js +++ b/integration-tests/plugins/__tests__/inventory/products/list-products.js @@ -199,6 +199,27 @@ describe("Create Variant", () => { ) }) + it("includes inventory items when property is expanded", async () => { + const api = useApi() + + const result = await api.get( + `/store/products?expand=variants,variants.inventory_items` + ) + + expect(result.status).toEqual(200) + expect(result.data.products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + variants: expect.arrayContaining([ + expect.objectContaining({ + inventory_items: [expect.any(Object)], + }), + ]), + }), + ]) + ) + }) + it("lists location availability correctly for store", async () => { const api = useApi() diff --git a/integration-tests/plugins/__tests__/inventory/products/list-variants.js b/integration-tests/plugins/__tests__/inventory/products/list-variants.js index 2fc403838c..60a25907d8 100644 --- a/integration-tests/plugins/__tests__/inventory/products/list-variants.js +++ b/integration-tests/plugins/__tests__/inventory/products/list-variants.js @@ -48,6 +48,7 @@ describe("List Variants", () => { describe("Inventory Items", () => { const variantId = "test-variant" + let invItem beforeEach(async () => { await adminSeeder(dbConnection) @@ -78,7 +79,7 @@ describe("List Variants", () => { location.id ) - const invItem = await inventoryService.createInventoryItem({ + invItem = await inventoryService.createInventoryItem({ sku: "test-sku", }) const invItemId = invItem.id @@ -103,5 +104,28 @@ describe("List Variants", () => { expect.objectContaining({ id: variantId, inventory_quantity: 10 }) ) }) + + it("expands inventory_items when querying with expand parameter", async () => { + const api = useApi() + + const listVariantsRes = await api.get( + `/admin/variants?expand=inventory_items`, + adminHeaders + ) + + expect(listVariantsRes.status).toEqual(200) + expect(listVariantsRes.data.variants.length).toEqual(1) + expect(listVariantsRes.data.variants[0]).toEqual( + expect.objectContaining({ + id: variantId, + inventory_items: [ + expect.objectContaining({ + inventory_item_id: invItem.id, + variant_id: variantId, + }), + ], + }) + ) + }) }) }) diff --git a/packages/medusa/src/api/routes/store/products/get-product.ts b/packages/medusa/src/api/routes/store/products/get-product.ts index 1883b1f916..c967ad1786 100644 --- a/packages/medusa/src/api/routes/store/products/get-product.ts +++ b/packages/medusa/src/api/routes/store/products/get-product.ts @@ -1,4 +1,3 @@ -import { IsOptional, IsString } from "class-validator" import { CartService, PricingService, @@ -6,6 +5,8 @@ import { ProductVariantInventoryService, RegionService, } from "../../../../services" +import { IsOptional, IsString } from "class-validator" + import { PriceSelectionParams } from "../../../../types/price-selection" import { cleanResponseData } from "../../../../utils/clean-response-data" @@ -148,12 +149,4 @@ export class StoreGetProductsProductParams extends PriceSelectionParams { @IsString() @IsOptional() sales_channel_id?: string - - @IsString() - @IsOptional() - fields?: string - - @IsString() - @IsOptional() - expand?: string } diff --git a/packages/medusa/src/api/routes/store/products/index.ts b/packages/medusa/src/api/routes/store/products/index.ts index db0ad37d3b..5410cfc35c 100644 --- a/packages/medusa/src/api/routes/store/products/index.ts +++ b/packages/medusa/src/api/routes/store/products/index.ts @@ -105,6 +105,7 @@ export const allowedStoreProductsFields = [ export const allowedStoreProductsRelations = [ ...defaultStoreProductsRelations, "variants.title", + "variants.inventory_items", "variants.prices.amount", "sales_channels", ] diff --git a/packages/medusa/src/api/routes/store/products/list-products.ts b/packages/medusa/src/api/routes/store/products/list-products.ts index dc7c1c399b..d12595c7cc 100644 --- a/packages/medusa/src/api/routes/store/products/list-products.ts +++ b/packages/medusa/src/api/routes/store/products/list-products.ts @@ -1,4 +1,8 @@ -import { Transform, Type } from "class-transformer" +import { + CartService, + ProductService, + ProductVariantInventoryService, +} from "../../../../services" import { IsArray, IsBoolean, @@ -7,20 +11,17 @@ import { IsString, ValidateNested, } from "class-validator" -import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels" -import { - CartService, - ProductService, - ProductVariantInventoryService, -} from "../../../../services" -import PricingService from "../../../../services/pricing" +import { Transform, Type } from "class-transformer" + import { DateComparisonOperator } from "../../../../types/common" -import { PriceSelectionParams } from "../../../../types/price-selection" -import { cleanResponseData } from "../../../../utils/clean-response-data" import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators" -import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean" import { IsType } from "../../../../utils/validators/is-type" +import { PriceSelectionParams } from "../../../../types/price-selection" +import PricingService from "../../../../services/pricing" +import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels" +import { cleanResponseData } from "../../../../utils/clean-response-data" import { defaultStoreCategoryScope } from "../product-categories" +import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean" /** * @oas [get] /store/products @@ -283,14 +284,6 @@ export default async (req, res) => { } export class StoreGetProductsPaginationParams extends PriceSelectionParams { - @IsString() - @IsOptional() - fields?: string - - @IsString() - @IsOptional() - expand?: string - @IsNumber() @IsOptional() @Type(() => Number) diff --git a/packages/medusa/src/api/routes/store/variants/get-variant.ts b/packages/medusa/src/api/routes/store/variants/get-variant.ts index da83ff4aed..af2a9e9a7a 100644 --- a/packages/medusa/src/api/routes/store/variants/get-variant.ts +++ b/packages/medusa/src/api/routes/store/variants/get-variant.ts @@ -5,11 +5,12 @@ import { ProductVariantService, RegionService, } from "../../../../services" +import { IsOptional, IsString } from "class-validator" +import { FindParams } from "../../../../types/common" import { PriceSelectionParams } from "../../../../types/price-selection" import { defaultStoreVariantRelations } from "." import { validator } from "../../../../utils/validator" -import { IsOptional, IsString } from "class-validator" /** * @oas [get] /store/variants/{variant_id} @@ -75,9 +76,7 @@ export default async (req, res) => { const customer_id = req.user?.customer_id - const rawVariant = await variantService.retrieve(id, { - relations: defaultStoreVariantRelations, - }) + const rawVariant = await variantService.retrieve(id, req.retrieveConfig) let sales_channel_id = validated.sales_channel_id if (req.publishableApiKeyScopes?.sales_channel_ids.length === 1) { diff --git a/packages/medusa/src/api/routes/store/variants/index.ts b/packages/medusa/src/api/routes/store/variants/index.ts index 492cd4394b..a24a2f83d3 100644 --- a/packages/medusa/src/api/routes/store/variants/index.ts +++ b/packages/medusa/src/api/routes/store/variants/index.ts @@ -1,9 +1,12 @@ +import middlewares, { transformStoreQuery } from "../../../middlewares" + +import { PricedVariant } from "../../../../types/pricing" import { Router } from "express" +import { StoreGetVariantsParams } from "./list-variants" +import { StoreGetVariantsVariantParams } from "./get-variant" import { extendRequestParams } from "../../../middlewares/publishable-api-key/extend-request-params" -import middlewares from "../../../middlewares" import { validateProductVariantSalesChannelAssociation } from "../../../middlewares/publishable-api-key/validate-variant-sales-channel-association" import { validateSalesChannelParam } from "../../../middlewares/publishable-api-key/validate-sales-channel-param" -import { PricedVariant } from "../../../../types/pricing" const route = Router() @@ -12,14 +15,33 @@ export default (app) => { route.use("/:id", validateProductVariantSalesChannelAssociation) - route.get("/", middlewares.wrap(require("./list-variants").default)) - route.get("/:id", middlewares.wrap(require("./get-variant").default)) + route.get( + "/", + transformStoreQuery(StoreGetVariantsParams, { + defaultRelations: defaultStoreVariantRelations, + allowedRelations: allowedStoreVariantRelations, + isList: true, + }), + middlewares.wrap(require("./list-variants").default) + ) + route.get( + "/:id", + transformStoreQuery(StoreGetVariantsVariantParams, { + defaultRelations: defaultStoreVariantRelations, + allowedRelations: allowedStoreVariantRelations, + }), + middlewares.wrap(require("./get-variant").default) + ) return app } export const defaultStoreVariantRelations = ["prices", "options", "product"] +export const allowedStoreVariantRelations = [ + ...defaultStoreVariantRelations, + "inventory_items", +] /** * @schema StoreVariantsRes * type: object diff --git a/packages/medusa/src/api/routes/store/variants/list-variants.ts b/packages/medusa/src/api/routes/store/variants/list-variants.ts index 6c9bc4a764..609b5cd020 100644 --- a/packages/medusa/src/api/routes/store/variants/list-variants.ts +++ b/packages/medusa/src/api/routes/store/variants/list-variants.ts @@ -1,4 +1,3 @@ -import { IsInt, IsOptional, IsString } from "class-validator" import { CartService, PricingService, @@ -6,15 +5,16 @@ import { ProductVariantService, RegionService, } from "../../../../services" +import { IsInt, IsOptional, IsString } from "class-validator" -import { Type } from "class-transformer" -import { omit } from "lodash" -import { defaultStoreVariantRelations } from "." +import { FilterableProductVariantProps } from "../../../../types/product-variant" +import { IsType } from "../../../../utils/validators/is-type" import { NumericalComparisonOperator } from "../../../../types/common" import { PriceSelectionParams } from "../../../../types/price-selection" -import { FilterableProductVariantProps } from "../../../../types/product-variant" +import { Type } from "class-transformer" +import { defaultStoreVariantRelations } from "." +import { omit } from "lodash" import { validator } from "../../../../utils/validator" -import { IsType } from "../../../../utils/validators/is-type" /** * @oas [get] /store/variants @@ -183,10 +183,6 @@ export class StoreGetVariantsParams extends PriceSelectionParams { @Type(() => Number) offset?: number = 0 - @IsOptional() - @IsString() - expand?: string - @IsOptional() @IsString() ids?: string diff --git a/packages/medusa/src/types/price-selection.ts b/packages/medusa/src/types/price-selection.ts index 53c8c4a159..16f0333d86 100644 --- a/packages/medusa/src/types/price-selection.ts +++ b/packages/medusa/src/types/price-selection.ts @@ -1,6 +1,8 @@ import { IsOptional, IsString } from "class-validator" -export class PriceSelectionParams { +import { FindParams } from "./common" + +export class PriceSelectionParams extends FindParams { @IsOptional() @IsString() cart_id?: string