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:
Adrien de Peretti
2022-05-16 12:19:34 +02:00
committed by GitHub
parent 79345d27ec
commit edeac8ac72
13 changed files with 2095 additions and 1915 deletions

View File

@@ -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)", () => {
beforeEach(async () => {
try {

View File

@@ -8,16 +8,16 @@
"build": "babel src -d dist --extensions \".ts,.js\""
},
"dependencies": {
"@medusajs/medusa": "1.2.1-dev-1650573289860",
"@medusajs/medusa": "1.3.0-dev-1652692202580",
"faker": "^5.5.3",
"medusa-interfaces": "1.2.1-dev-1650573289860",
"medusa-interfaces": "1.3.0-dev-1652692202580",
"typeorm": "^0.2.31"
},
"devDependencies": {
"@babel/cli": "^7.12.10",
"@babel/core": "^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"
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -17,6 +17,11 @@ export default (app) => {
middlewares.wrap(require("./list-tag-usage-count").default)
)
route.get(
"/:id/variants",
middlewares.normalizeQuery(),
middlewares.wrap(require("./list-variants").default)
)
route.post(
"/:id/variants",
middlewares.wrap(require("./create-variant").default)
@@ -97,6 +102,11 @@ export const defaultAdminProductFields = [
"metadata",
]
export const defaultAdminGetProductsVariantsFields = [
"id",
"product_id"
]
export const allowedAdminProductFields = [
"id",
"title",
@@ -178,7 +188,7 @@ export * from "./delete-option"
export * from "./delete-product"
export * from "./delete-variant"
export * from "./get-product"
export * from "./get-variants"
export * from "./list-variants"
export * from "./list-products"
export * from "./list-tag-usage-count"
export * from "./list-types"

View File

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

View File

@@ -4,6 +4,7 @@ import jwt from "jsonwebtoken"
import { MockManager } from "medusa-test-utils"
import "reflect-metadata"
import supertest from "supertest"
import querystring from "querystring"
import apiLoader from "../loaders/api"
import passportLoader from "../loaders/passport"
import servicesLoader from "../loaders/services"
@@ -68,10 +69,10 @@ apiLoader({ container, app: testApp, configModule: config })
const supertestRequest = supertest(testApp)
export async function request(method, url, opts = {}) {
let { payload, headers } = opts
const { payload, query, headers = {} } = opts
const req = supertestRequest[method.toLowerCase()](url)
headers = headers || {}
const queryParams = query && querystring.stringify(query);
const req = supertestRequest[method.toLowerCase()](`${url}${queryParams ? "?" + queryParams : ''}`)
headers.Cookie = headers.Cookie || ""
if (opts.adminSession) {
if (opts.adminSession.jwt) {

View File

@@ -302,6 +302,20 @@ export const ProductVariantServiceMock = {
list: jest.fn().mockImplementation(data => {
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) => {
return Promise.resolve({})
}),

View File

@@ -108,9 +108,16 @@ export const ProductServiceMock = {
.mockReturnValue(Promise.resolve(products.productWithOptions)),
retrieveVariants: jest
.fn()
.mockReturnValue(
Promise.resolve([{ id: IdMap.getId("1") }, { id: IdMap.getId("2") }])
),
.mockImplementation((productId) => {
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) => {
if (productId === IdMap.getId("product1")) {
return Promise.resolve(products.product1)

View File

@@ -2,6 +2,7 @@ import { MedusaError } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
import { Brackets } from "typeorm"
import { formatException } from "../utils/exception-formatter"
import { defaultAdminProductsVariantsRelations } from "../api/routes/admin/products"
/**
* Provides layer to manipulate products.
@@ -385,10 +386,18 @@ class ProductService extends BaseService {
/**
* Gets all variants belonging to a product.
* @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
*/
async retrieveVariants(productId) {
const product = await this.retrieve(productId, { relations: ["variants"] })
async retrieveVariants(
productId,
config = {
skip: 0,
take: 50,
relations: defaultAdminProductsVariantsRelations,
}
) {
const product = await this.retrieve(productId, config)
return product.variants
}

View File

@@ -1,6 +1,17 @@
import { AwilixContainer } from "awilix"
import { Logger as _Logger } from "winston"
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> = {
new (...args: unknown[]): T

View File

@@ -30,14 +30,14 @@ export function getRetrieveConfig<TModel extends BaseEntity>(
): FindConfig<TModel> {
let includeFields: (keyof TModel)[] = []
if (typeof fields !== "undefined") {
const fieldSet = new Set(fields)
fieldSet.add("id")
includeFields = Array.from(fieldSet) as (keyof TModel)[]
includeFields = Array
.from(new Set([...fields, "id"]))
.map(field => (typeof field === "string") ? field.trim() : field) as (keyof TModel)[]
}
let expandFields: string[] = []
if (typeof expand !== "undefined") {
expandFields = expand
expandFields = expand.map(expandRelation => expandRelation.trim())
}
return {