diff --git a/integration-tests/api/__tests__/store/products.js b/integration-tests/api/__tests__/store/products.js index 718709972a..fb5af35f7d 100644 --- a/integration-tests/api/__tests__/store/products.js +++ b/integration-tests/api/__tests__/store/products.js @@ -4,9 +4,10 @@ const setupServer = require("../../../helpers/setup-server") const { useApi } = require("../../../helpers/use-api") const { initDb, useDb } = require("../../../helpers/use-db") -const productSeeder = require("../../helpers/product-seeder") +const productSeeder = require("../../helpers/store-product-seeder") const adminSeeder = require("../../helpers/admin-seeder") jest.setTimeout(30000) + describe("/store/products", () => { let medusaProcess let dbConnection @@ -23,6 +24,204 @@ describe("/store/products", () => { medusaProcess.kill() }) + describe("GET /store/products", () => { + 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("returns a list of products in collection", async () => { + const api = useApi() + + const notExpected = [ + expect.objectContaining({ collection_id: "test-collection" }), + expect.objectContaining({ collection_id: "test-collection1" }), + ] + + const response = await api + .get("/store/products?collection_id[]=test-collection2") + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.products).toEqual([ + expect.objectContaining({ + id: "test-product_filtering_2", + collection_id: "test-collection2", + }), + ]) + + for (const notExpect of notExpected) { + expect(response.data.products).toEqual( + expect.not.arrayContaining([notExpect]) + ) + } + }) + + it("returns a list of products in with a given tag", async () => { + const api = useApi() + + const notExpected = [expect.objectContaining({ id: "tag4" })] + + const response = await api + .get("/store/products?tags[]=tag3") + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.products).toEqual([ + expect.objectContaining({ + id: "test-product_filtering_1", + collection_id: "test-collection1", + }), + ]) + + for (const notExpect of notExpected) { + expect(response.data.products).toEqual( + expect.not.arrayContaining([notExpect]) + ) + } + }) + + it("returns gift card product", async () => { + const api = useApi() + + const response = await api + .get("/store/products?is_giftcard=true") + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.products.length).toEqual(1) + expect(response.data.products).toEqual([ + expect.objectContaining({ + id: "giftcard", + is_giftcard: true, + }), + ]) + }) + + it("returns non gift card products", async () => { + const api = useApi() + + const response = await api + .get("/store/products?is_giftcard=false") + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + + expect(response.data.products).toEqual( + expect.not.arrayContaining([ + expect.objectContaining({ is_giftcard: true }), + ]) + ) + }) + + it("returns product with tag", async () => { + const api = useApi() + + const notExpected = [expect.objectContaining({ id: "tag4" })] + + const response = await api + .get("/store/products?tags[]=tag3") + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.products).toEqual([ + expect.objectContaining({ + id: "test-product_filtering_1", + collection_id: "test-collection1", + }), + ]) + + for (const notExpect of notExpected) { + expect(response.data.products).toEqual( + expect.not.arrayContaining([notExpect]) + ) + } + }) + + it("returns a list of products in with a given handle", async () => { + const api = useApi() + + const notExpected = [ + expect.objectContaining({ handle: "test-product_filtering_1" }), + ] + + const response = await api + .get("/store/products?handle=test-product_filtering_2") + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.products).toEqual([ + expect.objectContaining({ + id: "test-product_filtering_2", + handle: "test-product_filtering_2", + }), + ]) + + for (const notExpect of notExpected) { + expect(response.data.products).toEqual( + expect.not.arrayContaining([notExpect]) + ) + } + }) + + it("returns only published products", async () => { + const api = useApi() + + const notExpected = [ + expect.objectContaining({ status: "proposed" }), + expect.objectContaining({ status: "draft" }), + expect.objectContaining({ status: "rejected" }), + ] + + const response = await api.get("/store/products").catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.products).toEqual([ + expect.objectContaining({ + id: "giftcard", + }), + expect.objectContaining({ + id: "test-product_filtering_1", + collection_id: "test-collection1", + }), + expect.objectContaining({ + id: "test-product_filtering_2", + collection_id: "test-collection2", + }), + ]) + + for (const notExpect of notExpected) { + expect(response.data.products).toEqual( + expect.not.arrayContaining([notExpect]) + ) + } + }) + }) + describe("/store/products/:id", () => { beforeEach(async () => { try { diff --git a/integration-tests/api/helpers/store-product-seeder.js b/integration-tests/api/helpers/store-product-seeder.js new file mode 100644 index 0000000000..c0b232085c --- /dev/null +++ b/integration-tests/api/helpers/store-product-seeder.js @@ -0,0 +1,287 @@ +const { + ProductCollection, + ProductTag, + ProductType, + ProductOption, + Region, + Product, + ShippingProfile, + ProductVariant, + Image, +} = require("@medusajs/medusa") + +module.exports = async (connection, data = {}) => { + const manager = connection.manager + + const defaultProfile = await manager.findOne(ShippingProfile, { + type: "default", + }) + + const coll = manager.create(ProductCollection, { + id: "test-collection", + handle: "test-collection", + title: "Test collection", + }) + + await manager.save(coll) + + const coll1 = manager.create(ProductCollection, { + id: "test-collection1", + handle: "test-collection1", + title: "Test collection 1", + }) + + await manager.save(coll1) + + const coll2 = manager.create(ProductCollection, { + id: "test-collection2", + handle: "test-collection2", + title: "Test collection 2", + }) + + await manager.save(coll2) + + const tag = manager.create(ProductTag, { + id: "tag1", + value: "123", + }) + + await manager.save(tag) + + const tag3 = manager.create(ProductTag, { + id: "tag3", + value: "123", + }) + + await manager.save(tag3) + + const tag4 = manager.create(ProductTag, { + id: "tag4", + value: "123", + }) + + await manager.save(tag4) + + const type = manager.create(ProductType, { + id: "test-type", + value: "test-type", + }) + + await manager.save(type) + + const image = manager.create(Image, { + id: "test-image", + url: "test-image.png", + }) + + await manager.save(image) + + await manager.insert(Region, { + id: "test-region", + name: "Test Region", + currency_code: "usd", + tax_rate: 0, + }) + + const p = manager.create(Product, { + id: "test-product", + handle: "test-product", + title: "Test product", + profile_id: defaultProfile.id, + description: "test-product-description", + collection_id: "test-collection", + type: { id: "test-type", value: "test-type" }, + tags: [ + { id: "tag1", value: "123" }, + { tag: "tag2", value: "456" }, + ], + }) + + p.images = [image] + + await manager.save(p) + + await manager.save(ProductOption, { + id: "test-option", + title: "test-option", + product_id: "test-product", + }) + + const variant1 = await manager.create(ProductVariant, { + id: "test-variant", + inventory_quantity: 10, + title: "Test variant", + variant_rank: 0, + sku: "test-sku", + ean: "test-ean", + upc: "test-upc", + barcode: "test-barcode", + product_id: "test-product", + prices: [{ id: "test-price", currency_code: "usd", amount: 100 }], + options: [ + { + id: "test-variant-option", + value: "Default variant", + option_id: "test-option", + }, + ], + }) + + await manager.save(variant1) + + const variant2 = await manager.create(ProductVariant, { + id: "test-variant_1", + inventory_quantity: 10, + title: "Test variant rank (1)", + variant_rank: 2, + sku: "test-sku1", + ean: "test-ean1", + upc: "test-upc1", + barcode: "test-barcode 1", + product_id: "test-product", + prices: [{ id: "test-price1", currency_code: "usd", amount: 100 }], + options: [ + { + id: "test-variant-option-1", + value: "Default variant 1", + option_id: "test-option", + }, + ], + }) + + await manager.save(variant2) + + const variant3 = await manager.create(ProductVariant, { + id: "test-variant_2", + inventory_quantity: 10, + title: "Test variant rank (2)", + variant_rank: 1, + sku: "test-sku2", + ean: "test-ean2", + upc: "test-upc2", + product_id: "test-product", + prices: [{ id: "test-price2", currency_code: "usd", amount: 100 }], + options: [ + { + id: "test-variant-option-2", + value: "Default variant 2", + option_id: "test-option", + }, + ], + }) + + await manager.save(variant3) + + const p1 = manager.create(Product, { + id: "test-product1", + handle: "test-product1", + title: "Test product1", + profile_id: defaultProfile.id, + description: "test-product-description1", + collection_id: "test-collection", + type: { id: "test-type", value: "test-type" }, + tags: [ + { id: "tag1", value: "123" }, + { tag: "tag2", value: "456" }, + ], + }) + + await manager.save(p1) + + const variant4 = await manager.create(ProductVariant, { + id: "test-variant_3", + inventory_quantity: 10, + title: "Test variant rank (2)", + variant_rank: 1, + sku: "test-sku3", + ean: "test-ean3", + upc: "test-upc3", + product_id: "test-product1", + prices: [{ id: "test-price3", currency_code: "usd", amount: 100 }], + options: [ + { + id: "test-variant-option-3", + value: "Default variant 3", + option_id: "test-option", + }, + ], + }) + + await manager.save(variant4) + + const variant5 = await manager.create(ProductVariant, { + id: "test-variant_4", + inventory_quantity: 10, + title: "Test variant rank (2)", + variant_rank: 0, + sku: "test-sku4", + ean: "test-ean4", + upc: "test-upc4", + product_id: "test-product1", + prices: [{ id: "test-price4", currency_code: "usd", amount: 100 }], + options: [ + { + id: "test-variant-option-4", + value: "Default variant 4", + option_id: "test-option", + }, + ], + }) + + await manager.save(variant5) + + const product1 = manager.create(Product, { + id: "test-product_filtering_1", + handle: "test-product_filtering_1", + title: "Test product filtering 1", + profile_id: defaultProfile.id, + description: "test-product-description", + type: { id: "test-type", value: "test-type" }, + collection_id: "test-collection1", + status: "published", + tags: [{ id: "tag3", value: "123" }], + }) + + await manager.save(product1) + + const product2 = manager.create(Product, { + id: "test-product_filtering_2", + handle: "test-product_filtering_2", + title: "Test product filtering 2", + profile_id: defaultProfile.id, + description: "test-product-description", + type: { id: "test-type", value: "test-type" }, + collection_id: "test-collection2", + status: "published", + tags: [{ id: "tag4", value: "1234" }], + }) + + await manager.save(product2) + + const product3 = manager.create(Product, { + id: "test-product_filtering_3", + handle: "test-product_filtering_3", + title: "Test product filtering 3", + profile_id: defaultProfile.id, + description: "test-product-description", + type: { id: "test-type", value: "test-type" }, + collection_id: "test-collection1", + status: "draft", + tags: [{ id: "tag4", value: "1234" }], + }) + + await manager.save(product3) + + const gift_card = manager.create(Product, { + id: "giftcard", + handle: "giftcard", + is_giftcard: true, + title: "giftcard", + profile_id: defaultProfile.id, + description: "test-product-description", + type: { id: "test-type", value: "test-type" }, + status: "published", + }) + + await manager.save(gift_card) +} diff --git a/packages/medusa/src/api/routes/admin/invites/__tests__/create-invite.js b/packages/medusa/src/api/routes/admin/invites/__tests__/create-invite.js index 8d973334ec..860df5f46d 100644 --- a/packages/medusa/src/api/routes/admin/invites/__tests__/create-invite.js +++ b/packages/medusa/src/api/routes/admin/invites/__tests__/create-invite.js @@ -1,3 +1,4 @@ +import { IdMap } from "medusa-test-utils" import { request } from "../../../../../helpers/test-request" import { InviteServiceMock } from "../../../../../services/__mocks__/invite" import { UserRole } from "../../../../../types/user" @@ -9,9 +10,9 @@ describe("POST /invites", () => { beforeAll(async () => { subject = await request("POST", `/admin/invites`, { payload: { - role: "", + role: "admin", }, - session: { + adminSession: { jwt: { userId: "test_user", }, @@ -25,6 +26,10 @@ describe("POST /invites", () => { it("throws when role is empty", () => { expect(subject.error).toBeTruthy() + expect(subject.error.text).toEqual( + `{"type":"invalid_data","message":"user must be an email"}` + ) + expect(subject.error.status).toEqual(400) }) }) @@ -37,9 +42,9 @@ describe("POST /invites", () => { role: "admin", user: "lebron@james.com", }, - session: { + adminSession: { jwt: { - userId: "test_user", + userId: IdMap.getId("admin_user"), }, }, }) 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 fb09979d6a..cf9e0a7af7 100644 --- a/packages/medusa/src/api/routes/store/products/list-products.ts +++ b/packages/medusa/src/api/routes/store/products/list-products.ts @@ -1,14 +1,40 @@ -import { Type } from "class-transformer" -import { IsBoolean, IsInt, IsNumber, IsOptional } from "class-validator" +import { Transform, Type } from "class-transformer" +import { + IsArray, + IsBoolean, + IsNumber, + IsOptional, + IsString, + ValidateNested, +} from "class-validator" +import { omit, pickBy, identity } from "lodash" +import { MedusaError } from "medusa-core-utils" import { defaultStoreProductsRelations } from "." import { ProductService } from "../../../../services" +import { DateComparisonOperator } from "../../../../types/common" import { validator } from "../../../../utils/validator" +import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean" /** * @oas [get] /products * operationId: GetProducts * summary: List Products * description: "Retrieves a list of Products." + * parameters: + * - (query) q {string} Query used for searching products. + * - (query) id {string} Id of the product to search for. + * - (query) collection_id {string[]} Collection ids to search for. + * - (query) tags {string[]} Tags to search for. + * - (query) title {string} to search for. + * - (query) description {string} to search for. + * - (query) handle {string} to search for. + * - (query) is_giftcard {string} Search for giftcards using is_giftcard=true. + * - (query) type {string} to search for. + * - (query) created_at {DateComparisonOperator} Date comparison for when resulting products was created, i.e. less than, greater than etc. + * - (query) updated_at {DateComparisonOperator} Date comparison for when resulting products was updated, i.e. less than, greater than etc. + * - (query) deleted_at {DateComparisonOperator} Date comparison for when resulting products was deleted, i.e. less than, greater than etc. + * - (query) offset {string} How many products to skip in the result. + * - (query) limit {string} Limit the number of products returned. * tags: * - Product * responses: @@ -37,13 +63,13 @@ export default async (req, res) => { const validated = await validator(StoreGetProductsParams, req.query) - const selector = {} + const filterableFields: StoreGetProductsParams = omit(validated, [ + "limit", + "offset", + ]) - if (validated.is_giftcard && validated.is_giftcard === true) { - selector["is_giftcard"] = validated.is_giftcard - } - - selector["status"] = ["published"] + // get only published products for store endpoint + filterableFields["status"] = ["published"] const listConfig = { relations: defaultStoreProductsRelations, @@ -52,7 +78,7 @@ export default async (req, res) => { } const [products, count] = await productService.listAndCount( - selector, + pickBy(filterableFields, (val) => typeof val !== "undefined"), listConfig ) @@ -64,17 +90,68 @@ export default async (req, res) => { }) } -export class StoreGetProductsParams { - @IsInt() +export class StoreGetProductsPaginationParams { + @IsNumber() + @IsOptional() @Type(() => Number) - limit = 100 + offset?: number = 0 - @IsInt() + @IsNumber() + @IsOptional() @Type(() => Number) - offset = 0 + limit?: number = 100 +} + +export class StoreGetProductsParams extends StoreGetProductsPaginationParams { + @IsString() + @IsOptional() + id?: string + + @IsString() + @IsOptional() + q?: string + + @IsArray() + @IsOptional() + collection_id?: string[] + + @IsArray() + @IsOptional() + tags?: string[] + + @IsString() + @IsOptional() + title?: string + + @IsString() + @IsOptional() + description?: string + + @IsString() + @IsOptional() + handle?: string + + @IsBoolean() + @IsOptional() + @Transform(({ value }) => optionalBooleanMapper.get(value.toLowerCase())) + is_giftcard?: boolean + + @IsString() + @IsOptional() + type?: string @IsOptional() - @IsBoolean() - @Type(() => Boolean) - is_giftcard?: boolean + @ValidateNested() + @Type(() => DateComparisonOperator) + created_at?: DateComparisonOperator + + @IsOptional() + @ValidateNested() + @Type(() => DateComparisonOperator) + updated_at?: DateComparisonOperator + + @ValidateNested() + @IsOptional() + @Type(() => DateComparisonOperator) + deleted_at?: DateComparisonOperator }