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
This commit is contained in:
Philip Korsholm
2023-06-05 20:14:05 +02:00
committed by GitHub
parent 4b5b7b5148
commit eadf13cb21
12 changed files with 312 additions and 48 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---
feat(medusa): add allowed relation to expand params for product and variant endpoints

View File

@@ -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({})
)
})
})

View File

@@ -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,
}),
],
})
)
})
})

View File

@@ -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()

View File

@@ -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,
}),
],
})
)
})
})
})

View File

@@ -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
}

View File

@@ -105,6 +105,7 @@ export const allowedStoreProductsFields = [
export const allowedStoreProductsRelations = [
...defaultStoreProductsRelations,
"variants.title",
"variants.inventory_items",
"variants.prices.amount",
"sales_channels",
]

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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

View File

@@ -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

View File

@@ -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