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
@@ -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 {
+3 -3
View File
@@ -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"
} }
} }
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 -3
View File
@@ -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)
+11 -2
View File
@@ -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
} }
+11
View File
@@ -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 {