feat(medusa): Add /admin/products/:id/variants end point (#1471)
* feat(medusa): Add /admin/products/:id/variants end point * test(medusa): Fix get-variants test casees * feat(medusa): Include the config to the ProdutService#retrieveVariants as a method parameter * feat(medusa): Improve get-variants endpoint * feat(medusa): Improve get-variants endpoint * test(medusa): Fix unit tests * test(medusa): Fix unit tests * feat(medusa): Improve typings * feat(medusa): Update according to feedback * feat(medusa): Update according to feedback * test(medusa): Fix list-variants tests * feat(medusa): Getting the variants from the product end point should use the productVariantService * fix(medusa): list-variants expand possibly undefined * Fix(medusa): List-variants endpoint * fix(medusa): Tests suite for list-variant * test(integration-tests): Fix yarn lock * test(integration-tests): Fix yarn lock
This commit is contained in:
@@ -1387,6 +1387,48 @@ describe("/admin/products", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("GET /admin/products/:id/variants", () => {
|
||||||
|
beforeEach(async() => {
|
||||||
|
try {
|
||||||
|
await productSeeder(dbConnection)
|
||||||
|
await adminSeeder(dbConnection)
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async() => {
|
||||||
|
const db = useDb()
|
||||||
|
await db.teardown()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return the variants related to the requested product', async () => {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const res = await api
|
||||||
|
.get("/admin/products/test-product/variants", {
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer test_token",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log(err)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200)
|
||||||
|
expect(res.data.variants.length).toBe(4)
|
||||||
|
expect(res.data.variants).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ id: "test-variant", product_id: "test-product" }),
|
||||||
|
expect.objectContaining({ id: "test-variant_1", product_id: "test-product" }),
|
||||||
|
expect.objectContaining({ id: "test-variant_2", product_id: "test-product" }),
|
||||||
|
expect.objectContaining({ id: "test-variant-sale", product_id: "test-product" }),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("updates a variant's default prices (ignores prices associated with a Price List)", () => {
|
describe("updates a variant's default prices (ignores prices associated with a Price List)", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -8,16 +8,16 @@
|
|||||||
"build": "babel src -d dist --extensions \".ts,.js\""
|
"build": "babel src -d dist --extensions \".ts,.js\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@medusajs/medusa": "1.2.1-dev-1650573289860",
|
"@medusajs/medusa": "1.3.0-dev-1652692202580",
|
||||||
"faker": "^5.5.3",
|
"faker": "^5.5.3",
|
||||||
"medusa-interfaces": "1.2.1-dev-1650573289860",
|
"medusa-interfaces": "1.3.0-dev-1652692202580",
|
||||||
"typeorm": "^0.2.31"
|
"typeorm": "^0.2.31"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/cli": "^7.12.10",
|
"@babel/cli": "^7.12.10",
|
||||||
"@babel/core": "^7.12.10",
|
"@babel/core": "^7.12.10",
|
||||||
"@babel/node": "^7.12.10",
|
"@babel/node": "^7.12.10",
|
||||||
"babel-preset-medusa-package": "1.1.19-dev-1650573289860",
|
"babel-preset-medusa-package": "1.1.19-dev-1652692202580",
|
||||||
"jest": "^26.6.3"
|
"jest": "^26.6.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1807
-1867
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
|||||||
|
import { IdMap } from "medusa-test-utils"
|
||||||
|
import { request } from "../../../../../helpers/test-request"
|
||||||
|
import { ProductVariantServiceMock } from "../../../../../services/__mocks__/product-variant"
|
||||||
|
|
||||||
|
describe("GET /admin/products/:id/variants", () => {
|
||||||
|
describe("successfully gets a product variants", () => {
|
||||||
|
let subject
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
subject = await request(
|
||||||
|
"GET",
|
||||||
|
`/admin/products/${IdMap.getId("product1")}/variants`,
|
||||||
|
{
|
||||||
|
adminSession: {
|
||||||
|
jwt: {
|
||||||
|
userId: IdMap.getId("admin_user"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should cal the get product from productService with the expected parameters without giving any config", () => {
|
||||||
|
expect(ProductVariantServiceMock.listAndCount).toHaveBeenCalledTimes(1)
|
||||||
|
expect(ProductVariantServiceMock.listAndCount).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
product_id: IdMap.getId("product1"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
relations: [],
|
||||||
|
select: ["id", "product_id"],
|
||||||
|
skip: 0,
|
||||||
|
take: 100
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should returns product decorated", () => {
|
||||||
|
expect(subject.body.variants.length).toEqual(2)
|
||||||
|
expect(subject.body.variants).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ product_id: IdMap.getId("product1") }),
|
||||||
|
expect.objectContaining({ product_id: IdMap.getId("product1") }),
|
||||||
|
]))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should call the get product from productService with the expected parameters including the config that has been given", async () => {
|
||||||
|
await request(
|
||||||
|
"GET",
|
||||||
|
`/admin/products/${IdMap.getId("product1")}/variants`,
|
||||||
|
{
|
||||||
|
adminSession: {
|
||||||
|
jwt: {
|
||||||
|
userId: IdMap.getId("admin_user"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
expand: "variants.options",
|
||||||
|
fields: "id, variants.id",
|
||||||
|
limit: 10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(ProductVariantServiceMock.listAndCount).toHaveBeenCalledTimes(2)
|
||||||
|
expect(ProductVariantServiceMock.listAndCount).toHaveBeenLastCalledWith(
|
||||||
|
{
|
||||||
|
product_id: IdMap.getId("product1"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
relations: ["variants.options"],
|
||||||
|
select: ["id", "product_id", "variants.id"],
|
||||||
|
skip: 0,
|
||||||
|
take: 10
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { ProductService } from "../../../../services"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @oas [get] /products/{id}/variants
|
|
||||||
* operationId: "GetProductsProductVariants"
|
|
||||||
* summary: "List a Product's Product Variants"
|
|
||||||
* description: "Retrieves a list of the Product Variants associated with a Product."
|
|
||||||
* x-authenticated: true
|
|
||||||
* parameters:
|
|
||||||
* - (path) id=* {string} The id of the Product.
|
|
||||||
* tags:
|
|
||||||
* - Product
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: OK
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* properties:
|
|
||||||
* variants:
|
|
||||||
* type: array
|
|
||||||
* items:
|
|
||||||
* $ref: "#/components/schemas/product_variant"
|
|
||||||
*/
|
|
||||||
export default async (req, res) => {
|
|
||||||
const { id } = req.params
|
|
||||||
|
|
||||||
const productService: ProductService = req.scope.resolve("productService")
|
|
||||||
const variants = await productService.retrieveVariants(id)
|
|
||||||
|
|
||||||
res.json({ variants })
|
|
||||||
}
|
|
||||||
@@ -17,6 +17,11 @@ export default (app) => {
|
|||||||
middlewares.wrap(require("./list-tag-usage-count").default)
|
middlewares.wrap(require("./list-tag-usage-count").default)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
route.get(
|
||||||
|
"/:id/variants",
|
||||||
|
middlewares.normalizeQuery(),
|
||||||
|
middlewares.wrap(require("./list-variants").default)
|
||||||
|
)
|
||||||
route.post(
|
route.post(
|
||||||
"/:id/variants",
|
"/:id/variants",
|
||||||
middlewares.wrap(require("./create-variant").default)
|
middlewares.wrap(require("./create-variant").default)
|
||||||
@@ -97,6 +102,11 @@ export const defaultAdminProductFields = [
|
|||||||
"metadata",
|
"metadata",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export const defaultAdminGetProductsVariantsFields = [
|
||||||
|
"id",
|
||||||
|
"product_id"
|
||||||
|
]
|
||||||
|
|
||||||
export const allowedAdminProductFields = [
|
export const allowedAdminProductFields = [
|
||||||
"id",
|
"id",
|
||||||
"title",
|
"title",
|
||||||
@@ -178,7 +188,7 @@ export * from "./delete-option"
|
|||||||
export * from "./delete-product"
|
export * from "./delete-product"
|
||||||
export * from "./delete-variant"
|
export * from "./delete-variant"
|
||||||
export * from "./get-product"
|
export * from "./get-product"
|
||||||
export * from "./get-variants"
|
export * from "./list-variants"
|
||||||
export * from "./list-products"
|
export * from "./list-products"
|
||||||
export * from "./list-tag-usage-count"
|
export * from "./list-tag-usage-count"
|
||||||
export * from "./list-types"
|
export * from "./list-types"
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { Request, Response } from "express"
|
||||||
|
import { ProductVariantService } from "../../../../services"
|
||||||
|
import { validator } from "../../../../utils/validator"
|
||||||
|
import { IsNumber, IsOptional, IsString } from "class-validator"
|
||||||
|
import { Type } from "class-transformer"
|
||||||
|
import { getRetrieveConfig } from "../../../../utils/get-query-config"
|
||||||
|
import { ProductVariant } from "../../../../models"
|
||||||
|
import { defaultAdminGetProductsVariantsFields } from "./index"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @oas [get] /products/{id}/variants
|
||||||
|
* operationId: "GetProductsProductVariants"
|
||||||
|
* summary: "List a Product's Product Variants"
|
||||||
|
* description: "Retrieves a list of the Product Variants associated with a Product."
|
||||||
|
* x-authenticated: true
|
||||||
|
* parameters:
|
||||||
|
* - (path) id=* {string} Id of the product to search for the variants.
|
||||||
|
* - (query) fields {string} Comma separated string of the column to select.
|
||||||
|
* - (query) expand {string} Comma separated string of the relations to include.
|
||||||
|
* - (query) offset {string} How many products to skip in the result.
|
||||||
|
* - (query) limit {string} Limit the number of products returned.
|
||||||
|
* tags:
|
||||||
|
* - Product
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: OK
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* properties:
|
||||||
|
* variants:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* $ref: "#/components/schemas/product_variant"
|
||||||
|
*/
|
||||||
|
export default async (req: Request, res: Response) => {
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
const { expand, fields, limit, offset } = await validator(
|
||||||
|
AdminGetProductsVariantsParams,
|
||||||
|
req.query
|
||||||
|
)
|
||||||
|
|
||||||
|
const queryConfig = getRetrieveConfig<ProductVariant>(
|
||||||
|
defaultAdminGetProductsVariantsFields as (keyof ProductVariant)[],
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
...new Set([
|
||||||
|
...defaultAdminGetProductsVariantsFields,
|
||||||
|
...(fields?.split(",") ?? []),
|
||||||
|
]),
|
||||||
|
] as (keyof ProductVariant)[],
|
||||||
|
expand ? expand?.split(",") : undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
const productVariantService: ProductVariantService = req.scope.resolve(
|
||||||
|
"productVariantService"
|
||||||
|
)
|
||||||
|
const [variants, count] = await productVariantService.listAndCount(
|
||||||
|
{
|
||||||
|
product_id: id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...queryConfig,
|
||||||
|
skip: offset,
|
||||||
|
take: limit,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
count,
|
||||||
|
variants,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdminGetProductsVariantsParams {
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
fields?: string
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
expand?: string
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
offset?: number = 0
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
limit?: number = 100
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import jwt from "jsonwebtoken"
|
|||||||
import { MockManager } from "medusa-test-utils"
|
import { MockManager } from "medusa-test-utils"
|
||||||
import "reflect-metadata"
|
import "reflect-metadata"
|
||||||
import supertest from "supertest"
|
import supertest from "supertest"
|
||||||
|
import querystring from "querystring"
|
||||||
import apiLoader from "../loaders/api"
|
import apiLoader from "../loaders/api"
|
||||||
import passportLoader from "../loaders/passport"
|
import passportLoader from "../loaders/passport"
|
||||||
import servicesLoader from "../loaders/services"
|
import servicesLoader from "../loaders/services"
|
||||||
@@ -68,10 +69,10 @@ apiLoader({ container, app: testApp, configModule: config })
|
|||||||
const supertestRequest = supertest(testApp)
|
const supertestRequest = supertest(testApp)
|
||||||
|
|
||||||
export async function request(method, url, opts = {}) {
|
export async function request(method, url, opts = {}) {
|
||||||
let { payload, headers } = opts
|
const { payload, query, headers = {} } = opts
|
||||||
|
|
||||||
const req = supertestRequest[method.toLowerCase()](url)
|
const queryParams = query && querystring.stringify(query);
|
||||||
headers = headers || {}
|
const req = supertestRequest[method.toLowerCase()](`${url}${queryParams ? "?" + queryParams : ''}`)
|
||||||
headers.Cookie = headers.Cookie || ""
|
headers.Cookie = headers.Cookie || ""
|
||||||
if (opts.adminSession) {
|
if (opts.adminSession) {
|
||||||
if (opts.adminSession.jwt) {
|
if (opts.adminSession.jwt) {
|
||||||
|
|||||||
@@ -302,6 +302,20 @@ export const ProductVariantServiceMock = {
|
|||||||
list: jest.fn().mockImplementation(data => {
|
list: jest.fn().mockImplementation(data => {
|
||||||
return Promise.resolve([testVariant])
|
return Promise.resolve([testVariant])
|
||||||
}),
|
}),
|
||||||
|
listAndCount: jest.fn().mockImplementation(({ product_id }) => {
|
||||||
|
if (product_id === IdMap.getId("product1")) {
|
||||||
|
return Promise.resolve( [
|
||||||
|
[
|
||||||
|
{ id: IdMap.getId("1"), product_id: IdMap.getId("product1") },
|
||||||
|
{ id: IdMap.getId("2"), product_id: IdMap.getId("product1") }
|
||||||
|
],
|
||||||
|
2
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}),
|
||||||
deleteOptionValue: jest.fn().mockImplementation((variantId, optionId) => {
|
deleteOptionValue: jest.fn().mockImplementation((variantId, optionId) => {
|
||||||
return Promise.resolve({})
|
return Promise.resolve({})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -108,9 +108,16 @@ export const ProductServiceMock = {
|
|||||||
.mockReturnValue(Promise.resolve(products.productWithOptions)),
|
.mockReturnValue(Promise.resolve(products.productWithOptions)),
|
||||||
retrieveVariants: jest
|
retrieveVariants: jest
|
||||||
.fn()
|
.fn()
|
||||||
.mockReturnValue(
|
.mockImplementation((productId) => {
|
||||||
Promise.resolve([{ id: IdMap.getId("1") }, { id: IdMap.getId("2") }])
|
if (productId === IdMap.getId("product1")) {
|
||||||
),
|
return Promise.resolve([
|
||||||
|
{ id: IdMap.getId("1"), product_id: IdMap.getId("product1") },
|
||||||
|
{ id: IdMap.getId("2"), product_id: IdMap.getId("product1") }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}),
|
||||||
retrieve: jest.fn().mockImplementation((productId) => {
|
retrieve: jest.fn().mockImplementation((productId) => {
|
||||||
if (productId === IdMap.getId("product1")) {
|
if (productId === IdMap.getId("product1")) {
|
||||||
return Promise.resolve(products.product1)
|
return Promise.resolve(products.product1)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { MedusaError } from "medusa-core-utils"
|
|||||||
import { BaseService } from "medusa-interfaces"
|
import { BaseService } from "medusa-interfaces"
|
||||||
import { Brackets } from "typeorm"
|
import { Brackets } from "typeorm"
|
||||||
import { formatException } from "../utils/exception-formatter"
|
import { formatException } from "../utils/exception-formatter"
|
||||||
|
import { defaultAdminProductsVariantsRelations } from "../api/routes/admin/products"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides layer to manipulate products.
|
* Provides layer to manipulate products.
|
||||||
@@ -385,10 +386,18 @@ class ProductService extends BaseService {
|
|||||||
/**
|
/**
|
||||||
* Gets all variants belonging to a product.
|
* Gets all variants belonging to a product.
|
||||||
* @param {string} productId - the id of the product to get variants from.
|
* @param {string} productId - the id of the product to get variants from.
|
||||||
|
* @param {FindConfig<Product>} config - The config to select and configure relations etc...
|
||||||
* @return {Promise} an array of variants
|
* @return {Promise} an array of variants
|
||||||
*/
|
*/
|
||||||
async retrieveVariants(productId) {
|
async retrieveVariants(
|
||||||
const product = await this.retrieve(productId, { relations: ["variants"] })
|
productId,
|
||||||
|
config = {
|
||||||
|
skip: 0,
|
||||||
|
take: 50,
|
||||||
|
relations: defaultAdminProductsVariantsRelations,
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const product = await this.retrieve(productId, config)
|
||||||
return product.variants
|
return product.variants
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import { AwilixContainer } from "awilix"
|
import { AwilixContainer } from "awilix"
|
||||||
import { Logger as _Logger } from "winston"
|
import { Logger as _Logger } from "winston"
|
||||||
import { LoggerOptions } from "typeorm"
|
import { LoggerOptions } from "typeorm"
|
||||||
|
import { Customer, User } from "../models"
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
user?: User | Customer
|
||||||
|
scope: MedusaContainer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type ClassConstructor<T> = {
|
export type ClassConstructor<T> = {
|
||||||
new (...args: unknown[]): T
|
new (...args: unknown[]): T
|
||||||
|
|||||||
@@ -30,14 +30,14 @@ export function getRetrieveConfig<TModel extends BaseEntity>(
|
|||||||
): FindConfig<TModel> {
|
): FindConfig<TModel> {
|
||||||
let includeFields: (keyof TModel)[] = []
|
let includeFields: (keyof TModel)[] = []
|
||||||
if (typeof fields !== "undefined") {
|
if (typeof fields !== "undefined") {
|
||||||
const fieldSet = new Set(fields)
|
includeFields = Array
|
||||||
fieldSet.add("id")
|
.from(new Set([...fields, "id"]))
|
||||||
includeFields = Array.from(fieldSet) as (keyof TModel)[]
|
.map(field => (typeof field === "string") ? field.trim() : field) as (keyof TModel)[]
|
||||||
}
|
}
|
||||||
|
|
||||||
let expandFields: string[] = []
|
let expandFields: string[] = []
|
||||||
if (typeof expand !== "undefined") {
|
if (typeof expand !== "undefined") {
|
||||||
expandFields = expand
|
expandFields = expand.map(expandRelation => expandRelation.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user