diff --git a/integration-tests/api/__tests__/admin/publishable-api-key.js b/integration-tests/api/__tests__/admin/publishable-api-key.js deleted file mode 100644 index 7a3956e61c..0000000000 --- a/integration-tests/api/__tests__/admin/publishable-api-key.js +++ /dev/null @@ -1,1132 +0,0 @@ -const path = require("path") -const { IdMap } = require("medusa-test-utils") - -const { useApi } = require("../../../environment-helpers/use-api") -const { useDb, initDb } = require("../../../environment-helpers/use-db") -const adminSeeder = require("../../../helpers/admin-seeder") -const { - simplePublishableApiKeyFactory, -} = require("../../../factories/simple-publishable-api-key-factory") -const { - simpleSalesChannelFactory, - simpleProductFactory, - simpleRegionFactory, -} = require("../../../factories") -const setupServer = require("../../../environment-helpers/setup-server") - -jest.setTimeout(50000) - -const adminHeaders = { - headers: { - "x-medusa-access-token": "test_token", - }, -} - -const customerData = { - email: "medusa@test.hr", - password: "medusatest", - first_name: "medusa", - last_name: "medusa", -} - -describe("Publishable API keys", () => { - let medusaProcess - let dbConnection - const adminUserId = "admin_user" - - beforeAll(async () => { - const cwd = path.resolve(path.join(__dirname, "..", "..")) - dbConnection = await initDb({ cwd }) - medusaProcess = await setupServer({ cwd }) - }) - - afterAll(async () => { - const db = useDb() - await db.shutdown() - - medusaProcess.kill() - }) - - describe("GET /admin/publishable-api-keys/:id", () => { - const pubKeyId = IdMap.getId("pubkey-get-id") - beforeEach(async () => { - await adminSeeder(dbConnection) - - await simplePublishableApiKeyFactory(dbConnection, { - id: pubKeyId, - created_by: adminUserId, - }) - }) - - afterEach(async () => { - const db = useDb() - return await db.teardown() - }) - - it("retrieve a publishable key by id ", async () => { - const api = useApi() - - const response = await api.get( - `/admin/publishable-api-keys/${pubKeyId}`, - adminHeaders - ) - - expect(response.status).toBe(200) - - expect(response.data.publishable_api_key).toMatchObject({ - id: pubKeyId, - created_at: expect.any(String), - updated_at: expect.any(String), - created_by: adminUserId, - revoked_by: null, - revoked_at: null, - }) - }) - }) - - describe("GET /admin/publishable-api-keys", () => { - beforeEach(async () => { - await adminSeeder(dbConnection) - - await simplePublishableApiKeyFactory(dbConnection, { - title: "just a title", - }) - await simplePublishableApiKeyFactory(dbConnection, { - title: "special title 1", - }) - await simplePublishableApiKeyFactory(dbConnection, { - title: "special title 2", - }) - }) - - afterEach(async () => { - const db = useDb() - return await db.teardown() - }) - - it("list publishable keys", async () => { - const api = useApi() - - const response = await api.get( - `/admin/publishable-api-keys?limit=2`, - adminHeaders - ) - - expect(response.data.count).toBe(3) - expect(response.data.limit).toBe(2) - expect(response.data.offset).toBe(0) - expect(response.data.publishable_api_keys).toHaveLength(2) - }) - - it("list publishable keys with query search", async () => { - const api = useApi() - - const response = await api.get( - `/admin/publishable-api-keys?q=special`, - adminHeaders - ) - - expect(response.data.count).toBe(2) - expect(response.data.limit).toBe(20) - expect(response.data.offset).toBe(0) - expect(response.data.publishable_api_keys).toHaveLength(2) - expect(response.data.publishable_api_keys).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - title: "special title 1", - }), - expect.objectContaining({ - title: "special title 2", - }), - ]) - ) - }) - }) - - describe("POST /admin/publishable-api-keys", () => { - beforeEach(async () => { - await adminSeeder(dbConnection) - }) - - afterEach(async () => { - const db = useDb() - return await db.teardown() - }) - - it("crete a publishable keys", async () => { - const api = useApi() - - const response = await api.post( - `/admin/publishable-api-keys`, - { title: "Store api key" }, - adminHeaders - ) - - expect(response.status).toBe(200) - expect(response.data.publishable_api_key).toMatchObject({ - created_by: expect.any(String), - id: expect.any(String), - title: "Store api key", - revoked_by: null, - revoked_at: null, - created_at: expect.any(String), - updated_at: expect.any(String), - }) - }) - }) - - describe("POST /admin/publishable-api-keys/:id", () => { - const pubKeyId = IdMap.getId("pubkey-get-id-update") - - beforeEach(async () => { - await adminSeeder(dbConnection) - - await simplePublishableApiKeyFactory(dbConnection, { - id: pubKeyId, - title: "Initial key title", - }) - }) - - afterEach(async () => { - const db = useDb() - return await db.teardown() - }) - - it("update a publishable key", async () => { - const api = useApi() - - const response = await api.post( - `/admin/publishable-api-keys/${pubKeyId}`, - { title: "Changed title" }, - adminHeaders - ) - - expect(response.status).toBe(200) - expect(response.data.publishable_api_key).toMatchObject({ - id: pubKeyId, - title: "Changed title", - revoked_by: null, - revoked_at: null, - created_at: expect.any(String), - updated_at: expect.any(String), - }) - }) - }) - - describe("POST /admin/publishable-api-keys/:id/revoke", () => { - const pubKeyId = IdMap.getId("pubkey-get-id") - beforeEach(async () => { - await adminSeeder(dbConnection) - - await simplePublishableApiKeyFactory(dbConnection, { - id: pubKeyId, - }) - }) - - afterEach(async () => { - const db = useDb() - return await db.teardown() - }) - - it("revoke a publishable key", async () => { - const api = useApi() - - const response = await api.post( - `/admin/publishable-api-keys/${pubKeyId}/revoke`, - {}, - adminHeaders - ) - - expect(response.status).toBe(200) - - expect(response.data.publishable_api_key).toMatchObject({ - id: pubKeyId, - created_at: expect.any(String), - updated_at: expect.any(String), - revoked_by: adminUserId, - revoked_at: expect.any(String), - }) - }) - }) - - describe("DELETE /admin/publishable-api-keys/:id", () => { - const pubKeyId = IdMap.getId("pubkey-get-id") - beforeEach(async () => { - await adminSeeder(dbConnection) - - await simplePublishableApiKeyFactory(dbConnection, { - id: pubKeyId, - }) - }) - - afterEach(async () => { - const db = useDb() - return await db.teardown() - }) - - it("delete a publishable key", async () => { - const api = useApi() - - const response1 = await api.delete( - `/admin/publishable-api-keys/${pubKeyId}`, - adminHeaders - ) - - expect(response1.status).toBe(200) - expect(response1.data).toEqual({ - id: pubKeyId, - object: "publishable_api_key", - deleted: true, - }) - - try { - await api.get(`/admin/publishable-api-keys/${pubKeyId}`, adminHeaders) - } catch (e) { - expect(e.response.status).toBe(404) - } - }) - }) - - describe("POST /admin/publishable-api-keys/:id/sales-channels/batch", () => { - const pubKeyId = IdMap.getId("pubkey-get-id-batch") - let salesChannel1 - let salesChannel2 - - beforeEach(async () => { - await adminSeeder(dbConnection) - - await simplePublishableApiKeyFactory(dbConnection, { - id: pubKeyId, - }) - - salesChannel1 = await simpleSalesChannelFactory(dbConnection, { - name: "test name", - description: "test description", - }) - - salesChannel2 = await simpleSalesChannelFactory(dbConnection, { - name: "test name 2", - description: "test description 2", - }) - }) - - afterEach(async () => { - const db = useDb() - return await db.teardown() - }) - - it("add sales channels to the publishable api key scope", async () => { - const api = useApi() - - const response = await api.post( - `/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`, - { - sales_channel_ids: [ - { id: salesChannel1.id }, - { id: salesChannel2.id }, - ], - }, - adminHeaders - ) - - const mappings = await dbConnection.manager.query( - `SELECT * - FROM publishable_api_key_sales_channel - WHERE publishable_key_id = '${pubKeyId}'` - ) - - expect(response.status).toBe(200) - - expect(mappings).toEqual([ - expect.objectContaining({ - sales_channel_id: salesChannel1.id, - publishable_key_id: pubKeyId, - }), - expect.objectContaining({ - sales_channel_id: salesChannel2.id, - publishable_key_id: pubKeyId, - }), - ]) - - expect(response.data.publishable_api_key).toMatchObject({ - id: pubKeyId, - created_at: expect.any(String), - updated_at: expect.any(String), - }) - }) - }) - - describe("DELETE /admin/publishable-api-keys/:id/sales-channels/batch", () => { - const pubKeyId = IdMap.getId("pubkey-get-id-batch-v2") - let salesChannel1 - let salesChannel2 - let salesChannel3 - - beforeEach(async () => { - await adminSeeder(dbConnection) - - await simplePublishableApiKeyFactory(dbConnection, { - id: pubKeyId, - }) - - salesChannel1 = await simpleSalesChannelFactory(dbConnection, { - name: "test name", - description: "test description", - }) - - salesChannel2 = await simpleSalesChannelFactory(dbConnection, { - name: "test name 2", - description: "test description 2", - }) - - salesChannel3 = await simpleSalesChannelFactory(dbConnection, { - name: "test name 3", - description: "test description 3", - }) - - await dbConnection.manager.query( - `INSERT INTO publishable_api_key_sales_channel - (id, publishable_key_id, sales_channel_id) - VALUES ('pksc-1','${pubKeyId}', '${salesChannel1.id}'), - ('pksc-2','${pubKeyId}', '${salesChannel2.id}'), - ('pksc-3','${pubKeyId}', '${salesChannel3.id}');` - ) - }) - - afterEach(async () => { - const db = useDb() - return await db.teardown() - }) - - it("remove sales channels from the publishable api key scope", async () => { - const api = useApi() - - const response = await api.delete( - `/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`, - { - data: { - sales_channel_ids: [ - { id: salesChannel1.id }, - { id: salesChannel2.id }, - ], - }, - ...adminHeaders, - } - ) - - const mappings = await dbConnection.manager.query( - `SELECT * - FROM publishable_api_key_sales_channel - WHERE publishable_key_id = '${pubKeyId}';` - ) - - expect(response.status).toBe(200) - - expect(mappings).toEqual([ - expect.objectContaining({ - sales_channel_id: salesChannel3.id, - publishable_key_id: pubKeyId, - }), - ]) - - expect(response.data.publishable_api_key).toMatchObject({ - id: pubKeyId, - created_at: expect.any(String), - updated_at: expect.any(String), - }) - }) - }) - - describe("GET /admin/publishable-api-keys/:id/sales-channels", () => { - const pubKeyId = IdMap.getId("pubkey-get-id-batch-v2") - let salesChannel1 - let salesChannel2 - let salesChannel3 - - beforeEach(async () => { - await adminSeeder(dbConnection) - - await simplePublishableApiKeyFactory(dbConnection, { - id: pubKeyId, - }) - - salesChannel1 = await simpleSalesChannelFactory(dbConnection, { - name: "test name", - description: "test description", - }) - - salesChannel2 = await simpleSalesChannelFactory(dbConnection, { - name: "test name 2", - description: "test description 2", - }) - - salesChannel3 = await simpleSalesChannelFactory(dbConnection, { - name: "test name 3", - description: "test description 3", - }) - - await dbConnection.manager.query( - `INSERT INTO publishable_api_key_sales_channel - (id, publishable_key_id, sales_channel_id) - VALUES ('pksc-1', '${pubKeyId}', '${salesChannel1.id}'), - ('pksc-2', '${pubKeyId}', '${salesChannel2.id}');` - ) - }) - - afterEach(async () => { - const db = useDb() - return await db.teardown() - }) - - it("list sales channels from the publishable api key", async () => { - const api = useApi() - - const response = await api.get( - `/admin/publishable-api-keys/${pubKeyId}/sales-channels`, - adminHeaders - ) - - expect(response.status).toBe(200) - expect(response.data.sales_channels.length).toEqual(2) - expect(response.data.sales_channels).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: salesChannel1.id, - deleted_at: null, - name: "test name", - description: "test description", - is_disabled: false, - }), - expect.objectContaining({ - id: salesChannel2.id, - deleted_at: null, - name: "test name 2", - description: "test description 2", - is_disabled: false, - }), - ]) - ) - }) - - it("list sales channels from the publishable api key with free text search filter", async () => { - const api = useApi() - - const response = await api.get( - `/admin/publishable-api-keys/${pubKeyId}/sales-channels?q=2`, - adminHeaders - ) - - expect(response.status).toBe(200) - expect(response.data.sales_channels.length).toEqual(1) - expect(response.data.sales_channels).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: salesChannel2.id, - deleted_at: null, - name: "test name 2", - description: "test description 2", - is_disabled: false, - }), - ]) - ) - }) - }) - - describe("GET /store/products", () => { - const pubKeyId = IdMap.getId("pubkey-get-id") - - let salesChannel1 - let salesChannel2 - let product1 - let product2 - let product3 - - beforeEach(async () => { - await adminSeeder(dbConnection) - - salesChannel1 = await simpleSalesChannelFactory(dbConnection, { - name: "salesChannel1", - description: "salesChannel1", - }) - - salesChannel2 = await simpleSalesChannelFactory(dbConnection, { - name: "salesChannel2", - description: "salesChannel2", - }) - - product1 = await simpleProductFactory(dbConnection, { - title: "prod 1", - status: "published", - sales_channels: [salesChannel1], - }) - - product2 = await simpleProductFactory(dbConnection, { - title: "prod 2", - status: "published", - sales_channels: [salesChannel2], - }) - - product3 = await simpleProductFactory(dbConnection, { - title: "prod 3", - status: "published", - }) - - await simplePublishableApiKeyFactory(dbConnection, { - id: pubKeyId, - created_by: adminUserId, - }) - }) - - afterEach(async () => { - const db = useDb() - await db.teardown() - }) - - it("returns products from a specific channel associated with a publishable key", async () => { - const api = useApi() - - await api.post( - `/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`, - { - sales_channel_ids: [{ id: salesChannel1.id }], - }, - adminHeaders - ) - - const response = await api.get(`/store/products`, { - headers: { - "x-medusa-access-token": "test_token", - "x-publishable-api-key": pubKeyId, - }, - }) - - expect(response.data.products.length).toBe(1) - expect(response.data.products).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: product1.id, - }), - ]) - ) - }) - - it("returns products from multiples sales channels associated with a publishable key", async () => { - const api = useApi() - - await api.post( - `/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`, - { - sales_channel_ids: [ - { id: salesChannel1.id }, - { id: salesChannel2.id }, - ], - }, - adminHeaders - ) - - const response = await api.get(`/store/products`, { - headers: { - "x-medusa-access-token": "test_token", - "x-publishable-api-key": pubKeyId, - }, - }) - - expect(response.data.products.length).toBe(2) - expect(response.data.products).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: product2.id, - }), - expect.objectContaining({ - id: product1.id, - }), - ]) - ) - }) - - it("SC param overrides PK channels (but SK still needs to be in the PK's scope", async () => { - const api = useApi() - - await api.post( - `/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`, - { - sales_channel_ids: [ - { id: salesChannel1.id }, - { id: salesChannel2.id }, - ], - }, - adminHeaders - ) - - const response = await api.get( - `/store/products?sales_channel_id[0]=${salesChannel2.id}`, - { - headers: { - "x-medusa-access-token": "test_token", - "x-publishable-api-key": pubKeyId, - }, - } - ) - - expect(response.data.products.length).toBe(1) - expect(response.data.products).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: product2.id, - }), - ]) - ) - }) - - it("returns default product from default sales channel if PK is not passed", async () => { - const api = useApi() - - await api.post( - `/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`, - { - sales_channel_ids: [ - { id: salesChannel1.id }, - { id: salesChannel2.id }, - ], - }, - adminHeaders - ) - - const response = await api.get(`/store/products`, { - headers: { - "x-medusa-access-token": "test_token", - // "x-publishable-api-key": pubKeyId, - }, - }) - - expect(response.data.products.length).toBe(1) - expect(response.data.products).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: product3.id, - }), - ]) - ) - }) - - it("returns all products if passed PK doesn't have associated channels", async () => { - const api = useApi() - - const response = await api.get(`/store/products`, { - headers: { - "x-medusa-access-token": "test_token", - "x-publishable-api-key": pubKeyId, - }, - }) - - expect(response.data.products.length).toBe(3) - expect(response.data.products).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: product1.id, - }), - expect.objectContaining({ - id: product2.id, - }), - expect.objectContaining({ - id: product3.id, - }), - ]) - ) - }) - - it("throws because sales channel param is not in the scope of passed PK", async () => { - const api = useApi() - - await api.post( - `/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`, - { - sales_channel_ids: [{ id: salesChannel1.id }], - }, - adminHeaders - ) - - try { - await api.get( - `/store/products?sales_channel_id[]=${salesChannel2.id}`, - { - headers: { - "x-medusa-access-token": "test_token", - "x-publishable-api-key": pubKeyId, - }, - } - ) - } catch (error) { - expect(error.response.status).toEqual(400) - expect(error.response.data.errors[0]).toEqual( - `Provided sales channel id param: ${salesChannel2.id} is not associated with the Publishable API Key passed in the header of the request.` - ) - } - }) - }) - - describe("GET /store/products/:id", () => { - const pubKeyId = IdMap.getId("pubkey-get-id") - - let salesChannel1 - let product1 - let product2 - - beforeEach(async () => { - await adminSeeder(dbConnection) - - salesChannel1 = await simpleSalesChannelFactory(dbConnection, { - name: "salesChannel1", - description: "salesChannel1", - }) - - product1 = await simpleProductFactory(dbConnection, { - title: "prod 1", - status: "published", - sales_channels: [salesChannel1], - }) - - product2 = await simpleProductFactory(dbConnection, { - title: "prod 2", - status: "published", - }) - - await simplePublishableApiKeyFactory(dbConnection, { - id: pubKeyId, - created_by: adminUserId, - }) - }) - - afterEach(async () => { - const db = useDb() - await db.teardown() - }) - - it("retrieve a products from a specific channel associated with a publishable key", async () => { - const api = useApi() - - await api.post( - `/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`, - { - sales_channel_ids: [{ id: salesChannel1.id }], - }, - adminHeaders - ) - - const response = await api.get(`/store/products/${product1.id}`, { - headers: { - "x-medusa-access-token": "test_token", - "x-publishable-api-key": pubKeyId, - }, - }) - - expect(response.data.product).toEqual( - expect.objectContaining({ - id: product1.id, - }) - ) - }) - - it("return 400 because requested product is not in the SC associated with a publishable key", async () => { - const api = useApi() - - await api.post( - `/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`, - { - sales_channel_ids: [{ id: salesChannel1.id }], - }, - adminHeaders - ) - - const response = await api - .get(`/store/products/${product2.id}`, { - headers: { - "x-medusa-access-token": "test_token", - "x-publishable-api-key": pubKeyId, - }, - }) - .catch((err) => { - return err.response - }) - - expect(response.status).toEqual(400) - }) - - it("should return 404 when the requested variant doesn't exist", async () => { - const api = useApi() - - await api.post( - `/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`, - { - sales_channel_ids: [{ id: salesChannel1.id }], - }, - adminHeaders - ) - - const response = await api - .get(`/store/variants/does-not-exist`, { - headers: { - "x-medusa-access-token": "test_token", - "x-publishable-api-key": pubKeyId, - }, - }) - .catch((err) => { - return err.response - }) - - expect(response.status).toEqual(404) - expect(response.data.message).toEqual( - "Variant with id: does-not-exist was not found" - ) - }) - - it("should return 404 when the requested product doesn't exist", async () => { - const api = useApi() - - await api.post( - `/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`, - { - sales_channel_ids: [{ id: salesChannel1.id }], - }, - adminHeaders - ) - - const response = await api - .get(`/store/products/does-not-exist`, { - headers: { - "x-medusa-access-token": "test_token", - "x-publishable-api-key": pubKeyId, - }, - }) - .catch((err) => { - return err.response - }) - - expect(response.status).toEqual(404) - expect(response.data.message).toEqual( - "Product with id: does-not-exist was not found" - ) - }) - - it("correctly returns a product if passed PK has no associated SCs", async () => { - const api = useApi() - - let response = await api - .get(`/store/products/${product1.id}`, { - headers: { - "x-medusa-access-token": "test_token", - "x-publishable-api-key": pubKeyId, - }, - }) - .catch((err) => { - return err.response - }) - - expect(response.status).toEqual(200) - - response = await api - .get(`/store/products/${product2.id}`, { - headers: { - "x-medusa-access-token": "test_token", - "x-publishable-api-key": pubKeyId, - }, - }) - .catch((err) => { - return err.response - }) - - expect(response.status).toEqual(200) - }) - }) - - describe("POST /store/carts/:id", () => { - let product - const pubKeyId = IdMap.getId("pubkey-get-id") - - beforeEach(async () => { - await adminSeeder(dbConnection) - - await simpleRegionFactory(dbConnection, { - id: "test-region", - }) - - await simplePublishableApiKeyFactory(dbConnection, { - id: pubKeyId, - created_by: adminUserId, - }) - - product = await simpleProductFactory(dbConnection, { - sales_channels: [ - { - id: "sales-channel", - name: "Sales channel", - description: "Sales channel", - }, - { - id: "sales-channel2", - name: "Sales channel2", - description: "Sales channel2", - }, - ], - }) - }) - - afterEach(async () => { - const db = useDb() - return await db.teardown() - }) - - it("should assign sales channel to order on cart completion if PK is present in the header", async () => { - const api = useApi() - - await api.post( - `/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`, - { - sales_channel_ids: [{ id: "sales-channel" }], - }, - adminHeaders - ) - - const customerRes = await api.post("/store/customers", customerData, { - withCredentials: true, - }) - - const createCartRes = await api.post( - "/store/carts", - { - region_id: "test-region", - items: [ - { - variant_id: product.variants[0].id, - quantity: 1, - }, - ], - }, - { - headers: { - "x-medusa-access-token": "test_token", - "x-publishable-api-key": pubKeyId, - }, - } - ) - - const cart = createCartRes.data.cart - - await api.post(`/store/carts/${cart.id}`, { - customer_id: customerRes.data.customer.id, - }) - - await api.post(`/store/carts/${cart.id}/payment-sessions`) - - const createdOrder = await api.post( - `/store/carts/${cart.id}/complete-cart` - ) - - expect(createdOrder.data.type).toEqual("order") - expect(createdOrder.status).toEqual(200) - expect(createdOrder.data.data).toEqual( - expect.objectContaining({ - sales_channel_id: "sales-channel", - }) - ) - }) - - it("SC from params defines where product is assigned (passed SC still has to be in the scope of PK from the header)", async () => { - const api = useApi() - - await api.post( - `/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`, - { - sales_channel_ids: [ - { id: "sales-channel" }, - { id: "sales-channel2" }, - ], - }, - adminHeaders - ) - - const customerRes = await api.post("/store/customers", customerData, { - withCredentials: true, - }) - - const createCartRes = await api.post( - "/store/carts", - { - sales_channel_id: "sales-channel2", - region_id: "test-region", - items: [ - { - variant_id: product.variants[0].id, - quantity: 1, - }, - ], - }, - { - headers: { - "x-medusa-access-token": "test_token", - "x-publishable-api-key": pubKeyId, - }, - } - ) - - const cart = createCartRes.data.cart - - await api.post(`/store/carts/${cart.id}`, { - customer_id: customerRes.data.customer.id, - }) - - await api.post(`/store/carts/${cart.id}/payment-sessions`) - - const createdOrder = await api.post( - `/store/carts/${cart.id}/complete-cart` - ) - - expect(createdOrder.data.type).toEqual("order") - expect(createdOrder.status).toEqual(200) - expect(createdOrder.data.data).toEqual( - expect.objectContaining({ - sales_channel_id: "sales-channel2", - }) - ) - }) - - it("should throw because SC id in the body is not in the scope of PK from the header", async () => { - const api = useApi() - - await api.post( - `/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`, - { - sales_channel_ids: [{ id: "sales-channel" }], - }, - adminHeaders - ) - - try { - await api.post( - "/store/carts", - { - sales_channel_id: "sales-channel2", // SC not in the PK scope - region_id: "test-region", - items: [ - { - variant_id: product.variants[0].id, - quantity: 1, - }, - ], - }, - { - headers: { - "x-medusa-access-token": "test_token", - "x-publishable-api-key": pubKeyId, - }, - } - ) - } catch (error) { - expect(error.response.status).toEqual(400) - expect(error.response.data.errors[0]).toEqual( - `Provided sales channel id param: sales-channel2 is not associated with the Publishable API Key passed in the header of the request.` - ) - } - }) - }) -}) diff --git a/integration-tests/api/__tests__/admin/store.js b/integration-tests/api/__tests__/admin/store.js deleted file mode 100644 index 4abe04741f..0000000000 --- a/integration-tests/api/__tests__/admin/store.js +++ /dev/null @@ -1,283 +0,0 @@ -const { - createAdminUser, - adminHeaders, -} = require("../../../helpers/create-admin-user") -const { breaking } = require("../../../helpers/breaking") -const { ModuleRegistrationName } = require("@medusajs/modules-sdk") -const { medusaIntegrationTestRunner } = require("medusa-test-utils") - -jest.setTimeout(90000) - -medusaIntegrationTestRunner({ - testSuite: ({ dbConnection, getContainer, api }) => { - describe("/admin/store", () => { - let dbStore - let container - - // Note: The tests rely on the loader running and populating clean data before and after the test, so we have to do this in a beforeEach - beforeEach(async () => { - container = getContainer() - - await createAdminUser(dbConnection, adminHeaders, container) - await breaking( - async () => { - const { Store } = require("@medusajs/medusa/dist/models/store") - const manager = dbConnection.manager - dbStore = await manager.findOne(Store, { - where: { name: "Medusa Store" }, - }) - await manager.query( - `INSERT INTO store_currencies (store_id, currency_code) - VALUES ('${dbStore.id}', 'dkk')` - ) - }, - async () => { - const service = container.resolve(ModuleRegistrationName.STORE) - dbStore = await service.create({ - supported_currency_codes: ["usd", "dkk"], - default_currency_code: "usd", - default_sales_channel_id: "sc_12345", - }) - } - ) - }) - - describe("Store creation", () => { - it("has created store with default currency", async () => { - const store = await breaking( - () => - api.get("/admin/store", adminHeaders).then((r) => r.data.store), - () => - api - .get("/admin/stores", adminHeaders) - .then((r) => - r.data.stores.find( - (s) => s.default_sales_channel_id === "sc_12345" - ) - ) - ) - - expect(store).toEqual( - expect.objectContaining({ - id: expect.any(String), - name: "Medusa Store", - default_currency_code: "usd", - default_sales_channel_id: expect.any(String), - ...breaking( - () => ({ - default_sales_channel: expect.objectContaining({ - id: expect.any(String), - }), - currencies: expect.arrayContaining([ - expect.objectContaining({ - code: "usd", - }), - ]), - modules: expect.any(Array), - feature_flags: expect.any(Array), - }), - () => ({ - supported_currency_codes: ["usd", "dkk"], - }) - ), - created_at: expect.any(String), - updated_at: expect.any(String), - }) - ) - }) - }) - - describe("POST /admin/store", () => { - it("fails to update default currency if not in store currencies", async () => { - try { - await api.post( - breaking( - () => "/admin/store", - () => `/admin/stores/${dbStore.id}` - ), - { - default_currency_code: "eur", - }, - adminHeaders - ) - } catch (e) { - expect(e.response.data).toEqual( - expect.objectContaining({ - type: "invalid_data", - message: "Store does not have currency: eur", - }) - ) - expect(e.response.status).toBe(400) - } - }) - - it("fails to remove default currency from currencies without replacing it", async () => { - try { - await api.post( - breaking( - () => "/admin/store", - () => `/admin/stores/${dbStore.id}` - ), - breaking( - () => ({ - currencies: ["usd"], - }), - () => ({ supported_currency_codes: ["dkk"] }) - ), - adminHeaders - ) - } catch (e) { - expect(e.response.data).toEqual( - expect.objectContaining({ - type: "invalid_data", - message: - "You are not allowed to remove default currency from store currencies without replacing it as well", - }) - ) - expect(e.response.status).toBe(400) - } - }) - - it("successfully updates default currency code", async () => { - const response = await api - .post( - breaking( - () => "/admin/store", - () => `/admin/stores/${dbStore.id}` - ), - { - default_currency_code: "dkk", - }, - adminHeaders - ) - .catch((err) => console.log(err)) - - expect(response.status).toEqual(200) - expect(response.data.store).toEqual( - expect.objectContaining({ - id: expect.any(String), - name: "Medusa Store", - default_currency_code: "dkk", - created_at: expect.any(String), - updated_at: expect.any(String), - }) - ) - }) - - it("successfully updates default currency and store currencies", async () => { - const response = await api.post( - breaking( - () => "/admin/store", - () => `/admin/stores/${dbStore.id}` - ), - { - default_currency_code: "jpy", - ...breaking( - () => ({ currencies: ["jpy", "usd"] }), - () => ({ supported_currency_codes: ["jpy", "usd"] }) - ), - }, - adminHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data.store).toEqual( - expect.objectContaining({ - id: expect.any(String), - name: "Medusa Store", - default_sales_channel_id: expect.any(String), - ...breaking( - () => ({ - currencies: expect.arrayContaining([ - expect.objectContaining({ - code: "jpy", - }), - expect.objectContaining({ - code: "usd", - }), - ]), - }), - () => ({ supported_currency_codes: ["jpy", "usd"] }) - ), - default_currency_code: "jpy", - created_at: expect.any(String), - updated_at: expect.any(String), - }) - ) - }) - - it("successfully updates and store currencies", async () => { - const response = await api.post( - breaking( - () => "/admin/store", - () => `/admin/stores/${dbStore.id}` - ), - breaking( - () => ({ - currencies: ["jpy", "usd"], - }), - () => ({ - supported_currency_codes: ["jpy", "usd"], - }) - ), - adminHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data.store).toEqual( - expect.objectContaining({ - id: expect.any(String), - default_sales_channel_id: expect.any(String), - name: "Medusa Store", - ...breaking( - () => ({ - currencies: expect.arrayContaining([ - expect.objectContaining({ - code: "jpy", - }), - expect.objectContaining({ - code: "usd", - }), - ]), - }), - () => ({ supported_currency_codes: ["jpy", "usd"] }) - ), - default_currency_code: "usd", - created_at: expect.any(String), - updated_at: expect.any(String), - }) - ) - }) - }) - - describe("GET /admin/store", () => { - it("supports searching of stores", async () => { - await breaking( - () => {}, - async () => { - const service = container.resolve(ModuleRegistrationName.STORE) - const secondStore = await service.create({ - name: "Second Store", - supported_currency_codes: ["eur"], - default_currency_code: "eur", - }) - - const response = await api.get( - "/admin/stores?q=second", - adminHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data.stores).toEqual([ - expect.objectContaining({ - id: secondStore.id, - name: "Second Store", - }), - ]) - } - ) - }) - }) - }) - }, -}) diff --git a/integration-tests/api/__tests__/admin/variant.js b/integration-tests/api/__tests__/admin/variant.js deleted file mode 100644 index 7f4966e7f9..0000000000 --- a/integration-tests/api/__tests__/admin/variant.js +++ /dev/null @@ -1,293 +0,0 @@ -const path = require("path") - -const setupServer = require("../../../environment-helpers/setup-server") -const { useApi } = require("../../../environment-helpers/use-api") -const { initDb, useDb } = require("../../../environment-helpers/use-db") -const { simpleProductFactory } = require("../../../factories") - -const adminSeeder = require("../../../helpers/admin-seeder") -const adminVariantsSeeder = require("../../../helpers/admin-variants-seeder") -const productSeeder = require("../../../helpers/product-seeder") - -const adminHeaders = { - headers: { - "x-medusa-access-token": "test_token", - }, -} - -jest.setTimeout(30000) - -describe("/admin/products", () => { - let medusaProcess - let dbConnection - - beforeAll(async () => { - const cwd = path.resolve(path.join(__dirname, "..", "..")) - dbConnection = await initDb({ cwd }) - medusaProcess = await setupServer({ cwd }) - }) - - afterAll(async () => { - const db = useDb() - await db.shutdown() - - medusaProcess.kill() - }) - - describe("GET /admin/product-variants", () => { - beforeEach(async () => { - await productSeeder(dbConnection) - await adminSeeder(dbConnection) - }) - - afterEach(async () => { - const db = useDb() - await db.teardown() - }) - - it("lists all product variants", async () => { - const api = useApi() - - const response = await api - .get("/admin/variants/", adminHeaders) - .catch((err) => { - console.log(err) - }) - - expect(response.status).toEqual(200) - expect(response.data.variants).toEqual( - expect.arrayContaining([ - expect.objectContaining( - { - id: "test-variant", - }, - { - id: "test-variant_2", - }, - { - id: "test-variant_1", - } - ), - ]) - ) - }) - - it("lists all product variants matching a specific sku", async () => { - const api = useApi() - const response = await api - .get("/admin/variants?q=sku2", adminHeaders) - .catch((err) => { - console.log(err) - }) - - expect(response.status).toEqual(200) - expect(response.data.variants.length).toEqual(1) - expect(response.data.variants).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - sku: "test-sku2", - }), - ]) - ) - }) - - it("lists all product variants matching a specific variant title", async () => { - const api = useApi() - const response = await api - .get("/admin/variants?q=rank (1)", adminHeaders) - .catch((err) => { - console.log(err) - }) - - expect(response.status).toEqual(200) - expect(response.data.variants.length).toEqual(1) - expect(response.data.variants).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: "test-variant_1", - sku: "test-sku1", - }), - ]) - ) - }) - - it("lists all product variants matching a specific product title", async () => { - const api = useApi() - - const response = await api - .get("/admin/variants?q=Test product1", adminHeaders) - .catch((err) => { - console.log(err) - }) - - expect(response.status).toEqual(200) - expect(response.data.variants.length).toEqual(2) - expect(response.data.variants).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - product_id: "test-product1", - id: "test-variant_3", - sku: "test-sku3", - }), - expect.objectContaining({ - product_id: "test-product1", - id: "test-variant_4", - sku: "test-sku4", - }), - ]) - ) - }) - }) - - describe("GET /admin/variants price selection strategy", () => { - beforeEach(async () => { - try { - await adminVariantsSeeder(dbConnection) - } catch (err) { - console.log(err) - } - await adminSeeder(dbConnection) - }) - - afterEach(async () => { - const db = useDb() - await db.teardown() - }) - - it("selects prices based on the passed currency code", async () => { - const api = useApi() - - const response = await api.get( - "/admin/variants?id=test-variant¤cy_code=usd", - adminHeaders - ) - - expect(response.data).toMatchSnapshot({ - variants: [ - { - id: "test-variant", - original_price: 100, - calculated_price: 80, - calculated_price_type: "sale", - original_price_incl_tax: null, - calculated_price_incl_tax: null, - original_tax: null, - calculated_tax: null, - options: expect.any(Array), - prices: expect.any(Array), - product: expect.any(Object), - created_at: expect.any(String), - updated_at: expect.any(String), - }, - ], - }) - }) - - it("selects prices based on the passed region id", async () => { - const api = useApi() - - const response = await api.get( - "/admin/variants?id=test-variant®ion_id=reg-europe", - adminHeaders - ) - - expect(response.data).toMatchSnapshot({ - variants: [ - { - id: "test-variant", - original_price: 100, - calculated_price: 80, - calculated_price_type: "sale", - original_price_incl_tax: 100, - calculated_price_incl_tax: 80, - original_tax: 0, - calculated_tax: 0, - options: expect.any(Array), - prices: expect.any(Array), - product: expect.any(Object), - created_at: expect.any(String), - updated_at: expect.any(String), - }, - ], - }) - }) - - it("selects prices based on the passed region id and customer id", async () => { - const api = useApi() - - const response = await api.get( - "/admin/variants?id=test-variant®ion_id=reg-europe&customer_id=test-customer", - adminHeaders - ) - - expect(response.data).toMatchSnapshot({ - variants: [ - { - id: "test-variant", - original_price: 100, - calculated_price: 40, - calculated_price_type: "sale", - original_price_incl_tax: 100, - calculated_price_incl_tax: 40, - original_tax: 0, - calculated_tax: 0, - prices: expect.any(Array), - options: expect.any(Array), - product: expect.any(Object), - created_at: expect.any(String), - updated_at: expect.any(String), - }, - ], - }) - }) - - it("returns a list of variants matching the given ids", async () => { - const api = useApi() - - const productData = { - id: "test-product_filtering_by_variant_id", - title: "Test product filtering by variant id", - handle: "test-product_filtering_by_variant_id", - options: [ - { - id: "test-product_filtering_by_variant_id-option", - title: "Size", - }, - ], - variants: [], - } - - for (let i = 0; i < 25; i++) { - productData.variants.push({ - product_id: productData.id, - sku: `test-product_filtering_by_variant_id-${i}`, - title: `test-product_filtering_by_variant_id-${i}`, - options: [ - { - option_id: "test-product_filtering_by_variant_id-option", - value: `Large-${i}`, - }, - ], - }) - } - - const product = await simpleProductFactory(dbConnection, productData) - - const variantIds = product.variants.map((v) => v.id) - const qs = "id[]=" + variantIds.join("&id[]=") - - const response = await api - .get("/admin/variants?limit=30&" + qs, adminHeaders) - .catch((err) => { - console.log(err) - }) - - expect(response.status).toEqual(200) - expect(response.data.variants.length).toEqual(variantIds.length) - - for (const variant of response.data.variants) { - expect(variantIds).toContain(variant.id) - } - }) - }) -}) diff --git a/integration-tests/helpers/fixtures.ts b/integration-tests/helpers/fixtures.ts new file mode 100644 index 0000000000..cd05c87dd5 --- /dev/null +++ b/integration-tests/helpers/fixtures.ts @@ -0,0 +1,42 @@ +import { HttpTypes } from "@medusajs/types" + +export const getProductFixture = ( + overrides: Partial +) => ({ + title: "Test fixture", + description: "test-product-description", + status: "draft", + // BREAKING: Images input changed from string[] to {url: string}[] + images: [{ url: "test-image.png" }, { url: "test-image-2.png" }], + tags: [{ value: "123" }, { value: "456" }], + // BREAKING: Options input changed from {title: string}[] to {title: string, values: string[]}[] + options: [ + { title: "size", values: ["large", "small"] }, + { title: "color", values: ["green"] }, + ], + variants: [ + { + title: "Test variant", + prices: [ + { + currency_code: "usd", + amount: 100, + }, + { + currency_code: "eur", + amount: 45, + }, + { + currency_code: "dkk", + amount: 30, + }, + ], + // BREAKING: Options input changed from {value: string}[] to {[optionTitle]: optionValue} map + options: { + size: "large", + color: "green", + }, + }, + ], + ...(overrides ?? {}), +}) diff --git a/integration-tests/http/__tests__/api-key/admin/publishable-key.spec.ts b/integration-tests/http/__tests__/api-key/admin/publishable-key.spec.ts new file mode 100644 index 0000000000..aec367cb9b --- /dev/null +++ b/integration-tests/http/__tests__/api-key/admin/publishable-key.spec.ts @@ -0,0 +1,291 @@ +import { medusaIntegrationTestRunner } from "medusa-test-utils" +import { + adminHeaders, + createAdminUser, +} from "../../../../helpers/create-admin-user" + +jest.setTimeout(50000) + +medusaIntegrationTestRunner({ + env: {}, + testSuite: ({ dbConnection, getContainer, api }) => { + describe("Publishable Keys - Admin", () => { + let pubKey1 + let pubKey2 + + beforeEach(async () => { + await createAdminUser(dbConnection, adminHeaders, getContainer()) + + // BREAKING: Before the ID of the token was used in request headers, now there is a separate `token` field that should be used + pubKey1 = ( + await api.post( + "/admin/api-keys", + { title: "sample key", type: "publishable" }, + adminHeaders + ) + ).data.api_key + pubKey2 = ( + await api.post( + "/admin/api-keys", + // BREAKING: The type field is now required + { title: "just a title", type: "publishable" }, + adminHeaders + ) + ).data.api_key + }) + + // BREAKING: The URL changed from /admin/publishable-api-keys to /admin/api-keys, as well as the response field + describe("GET /admin/api-keys/:id", () => { + it("retrieve a publishable key by id ", async () => { + const response = await api.get( + `/admin/api-keys/${pubKey1.id}`, + adminHeaders + ) + + expect(response.status).toBe(200) + + expect(response.data.api_key).toMatchObject({ + id: pubKey1.id, + created_at: expect.any(String), + created_by: expect.stringContaining("user_"), + revoked_by: null, + revoked_at: null, + }) + }) + }) + + describe("GET /admin/api-keys", () => { + it("list publishable keys", async () => { + const response = await api.get( + `/admin/api-keys?limit=2`, + adminHeaders + ) + + expect(response.data.count).toBe(2) + expect(response.data.limit).toBe(2) + expect(response.data.offset).toBe(0) + expect(response.data.api_keys).toHaveLength(2) + }) + + it("list publishable keys with query search", async () => { + const response = await api.get( + `/admin/api-keys?q=sample`, + adminHeaders + ) + + expect(response.data.count).toBe(1) + expect(response.data.limit).toBe(20) + expect(response.data.offset).toBe(0) + expect(response.data.api_keys).toHaveLength(1) + expect(response.data.api_keys).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + title: "sample key", + }), + ]) + ) + }) + }) + + describe("POST /admin/api-keys", () => { + it("crete a publishable keys", async () => { + const response = await api.post( + `/admin/api-keys`, + { title: "Store api key", type: "publishable" }, + adminHeaders + ) + + expect(response.status).toBe(200) + expect(response.data.api_key).toMatchObject({ + created_by: expect.any(String), + id: expect.any(String), + title: "Store api key", + revoked_by: null, + revoked_at: null, + created_at: expect.any(String), + }) + }) + }) + + describe("POST /admin/api-keys/:id", () => { + it("update a publishable key", async () => { + const response = await api.post( + `/admin/api-keys/${pubKey1.id}`, + { title: "Changed title" }, + adminHeaders + ) + + expect(response.status).toBe(200) + expect(response.data.api_key).toMatchObject({ + id: pubKey1.id, + title: "Changed title", + revoked_by: null, + revoked_at: null, + created_at: expect.any(String), + updated_at: expect.any(String), + }) + }) + }) + + describe("DELETE /admin/api-keys/:id", () => { + it("delete a publishable key", async () => { + const response1 = await api.delete( + `/admin/api-keys/${pubKey1.id}`, + adminHeaders + ) + + expect(response1.status).toBe(200) + expect(response1.data).toEqual({ + id: pubKey1.id, + object: "api_key", + deleted: true, + }) + + const err = await api + .get(`/admin/api-keys/${pubKey1.id}`, adminHeaders) + .catch((e) => e) + + expect(err.response.status).toBe(404) + }) + }) + + describe("POST /admin/api-keys/:id/revoke", () => { + it("revoke a publishable key", async () => { + const response = await api.post( + `/admin/api-keys/${pubKey1.id}/revoke`, + {}, + adminHeaders + ) + + expect(response.status).toBe(200) + + expect(response.data.api_key).toMatchObject({ + id: pubKey1.id, + created_at: expect.any(String), + updated_at: expect.any(String), + revoked_by: expect.stringContaining("user_"), + revoked_at: expect.any(String), + }) + }) + }) + + // BREAKING: The GET /admin/api-keys/:id/sales-channels endpoint was removed. + // It was replaced by the GET /admin/sales-channels endpoint where you can filter by publishable key + // BREAKING: Batch route and input changed (no more batch suffix, and the input takes ids to add and remove) + describe("Add /admin/api-keys/:id/sales-channels", () => { + let salesChannel1 + let salesChannel2 + + beforeEach(async () => { + salesChannel1 = ( + await api.post( + "/admin/sales-channels", + { + name: "test name", + description: "test description", + }, + adminHeaders + ) + ).data.sales_channel + + salesChannel2 = ( + await api.post( + "/admin/sales-channels", + { + name: "test name 2", + description: "test description 2", + }, + adminHeaders + ) + ).data.sales_channel + }) + + it("add sales channels to the publishable api key scope", async () => { + const response = await api.post( + `/admin/api-keys/${pubKey1.id}/sales-channels`, + { + add: [salesChannel1.id, salesChannel2.id], + }, + adminHeaders + ) + + expect(response.status).toBe(200) + const keyWithChannels = ( + await api.get(`/admin/api-keys/${pubKey1.id}`, adminHeaders) + ).data.api_key + + expect(keyWithChannels.sales_channels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: salesChannel1.id, + }), + expect.objectContaining({ + id: salesChannel2.id, + }), + ]) + ) + }) + + it("remove sales channels from the publishable api key scope", async () => { + await api.post( + `/admin/api-keys/${pubKey1.id}/sales-channels`, + { + add: [salesChannel1.id, salesChannel2.id], + }, + adminHeaders + ) + + const response = await api.post( + `/admin/api-keys/${pubKey1.id}/sales-channels`, + { + remove: [salesChannel1.id], + }, + adminHeaders + ) + + expect(response.status).toBe(200) + const keyWithChannels = ( + await api.get(`/admin/api-keys/${pubKey1.id}`, adminHeaders) + ).data.api_key + + expect(keyWithChannels.sales_channels).toEqual([ + expect.objectContaining({ + id: salesChannel2.id, + }), + ]) + }) + + it("list sales channels from the publishable api key", async () => { + await api.post( + `/admin/api-keys/${pubKey1.id}/sales-channels`, + { + add: [salesChannel1.id, salesChannel2.id], + }, + adminHeaders + ) + + const response = await api.get( + `/admin/api-keys/${pubKey1.id}`, + adminHeaders + ) + + const salesChannels = response.data.api_key.sales_channels + expect(response.status).toBe(200) + expect(salesChannels.length).toEqual(2) + expect(salesChannels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: salesChannel1.id, + name: "test name", + }), + expect.objectContaining({ + id: salesChannel2.id, + name: "test name 2", + }), + ]) + ) + }) + }) + }) + }, +}) diff --git a/integration-tests/http/__tests__/cart/store/cart.spec.ts b/integration-tests/http/__tests__/cart/store/cart.spec.ts new file mode 100644 index 0000000000..b9eaf33ec9 --- /dev/null +++ b/integration-tests/http/__tests__/cart/store/cart.spec.ts @@ -0,0 +1,212 @@ +import { medusaIntegrationTestRunner } from "medusa-test-utils" +import { + adminHeaders, + createAdminUser, +} from "../../../../helpers/create-admin-user" + +jest.setTimeout(50000) + +medusaIntegrationTestRunner({ + testSuite: ({ dbConnection, getContainer, api }) => { + beforeEach(async () => { + await createAdminUser(dbConnection, adminHeaders, getContainer()) + }) + + describe("noop", () => { + it("noop", () => {}) + }) + }, +}) + +// TODO: Implement the tests below, which were migrated from v1. +// describe("POST /store/carts/:id", () => { +// let product +// const pubKeyId = IdMap.getId("pubkey-get-id") + +// beforeEach(async () => { +// await adminSeeder(dbConnection) + +// await simpleRegionFactory(dbConnection, { +// id: "test-region", +// }) + +// await simplePublishableApiKeyFactory(dbConnection, { +// id: pubKeyId, +// created_by: adminUserId, +// }) + +// product = await simpleProductFactory(dbConnection, { +// sales_channels: [ +// { +// id: "sales-channel", +// name: "Sales channel", +// description: "Sales channel", +// }, +// { +// id: "sales-channel2", +// name: "Sales channel2", +// description: "Sales channel2", +// }, +// ], +// }) +// }) + +// afterEach(async () => { +// const db = useDb() +// return await db.teardown() +// }) + +// it("should assign sales channel to order on cart completion if PK is present in the header", async () => { +// const api = useApi() + +// await api.post( +// `/admin/api-keys/${pubKeyId}/sales-channels/batch`, +// { +// sales_channel_ids: [{ id: "sales-channel" }], +// }, +// adminHeaders +// ) + +// const customerRes = await api.post("/store/customers", customerData, { +// withCredentials: true, +// }) + +// const createCartRes = await api.post( +// "/store/carts", +// { +// region_id: "test-region", +// items: [ +// { +// variant_id: product.variants[0].id, +// quantity: 1, +// }, +// ], +// }, +// { +// headers: { +// "x-medusa-access-token": "test_token", +// "x-publishable-api-key": pubKeyId, +// }, +// } +// ) + +// const cart = createCartRes.data.cart + +// await api.post(`/store/carts/${cart.id}`, { +// customer_id: customerRes.data.customer.id, +// }) + +// await api.post(`/store/carts/${cart.id}/payment-sessions`) + +// const createdOrder = await api.post( +// `/store/carts/${cart.id}/complete-cart` +// ) + +// expect(createdOrder.data.type).toEqual("order") +// expect(createdOrder.status).toEqual(200) +// expect(createdOrder.data.data).toEqual( +// expect.objectContaining({ +// sales_channel_id: "sales-channel", +// }) +// ) +// }) + +// it("SC from params defines where product is assigned (passed SC still has to be in the scope of PK from the header)", async () => { +// const api = useApi() + +// await api.post( +// `/admin/api-keys/${pubKeyId}/sales-channels/batch`, +// { +// sales_channel_ids: [ +// { id: "sales-channel" }, +// { id: "sales-channel2" }, +// ], +// }, +// adminHeaders +// ) + +// const customerRes = await api.post("/store/customers", customerData, { +// withCredentials: true, +// }) + +// const createCartRes = await api.post( +// "/store/carts", +// { +// sales_channel_id: "sales-channel2", +// region_id: "test-region", +// items: [ +// { +// variant_id: product.variants[0].id, +// quantity: 1, +// }, +// ], +// }, +// { +// headers: { +// "x-medusa-access-token": "test_token", +// "x-publishable-api-key": pubKeyId, +// }, +// } +// ) + +// const cart = createCartRes.data.cart + +// await api.post(`/store/carts/${cart.id}`, { +// customer_id: customerRes.data.customer.id, +// }) + +// await api.post(`/store/carts/${cart.id}/payment-sessions`) + +// const createdOrder = await api.post( +// `/store/carts/${cart.id}/complete-cart` +// ) + +// expect(createdOrder.data.type).toEqual("order") +// expect(createdOrder.status).toEqual(200) +// expect(createdOrder.data.data).toEqual( +// expect.objectContaining({ +// sales_channel_id: "sales-channel2", +// }) +// ) +// }) + +// it("should throw because SC id in the body is not in the scope of PK from the header", async () => { +// const api = useApi() + +// await api.post( +// `/admin/api-keys/${pubKeyId}/sales-channels/batch`, +// { +// sales_channel_ids: [{ id: "sales-channel" }], +// }, +// adminHeaders +// ) + +// try { +// await api.post( +// "/store/carts", +// { +// sales_channel_id: "sales-channel2", // SC not in the PK scope +// region_id: "test-region", +// items: [ +// { +// variant_id: product.variants[0].id, +// quantity: 1, +// }, +// ], +// }, +// { +// headers: { +// "x-medusa-access-token": "test_token", +// "x-publishable-api-key": pubKeyId, +// }, +// } +// ) +// } catch (error) { +// expect(error.response.status).toEqual(400) +// expect(error.response.data.errors[0]).toEqual( +// `Provided sales channel id param: sales-channel2 is not associated with the Publishable API Key passed in the header of the request.` +// ) +// } +// }) +// }) +// }) diff --git a/integration-tests/http/__tests__/product/admin/product.spec.ts b/integration-tests/http/__tests__/product/admin/product.spec.ts index 9cd6aba834..9800af534d 100644 --- a/integration-tests/http/__tests__/product/admin/product.spec.ts +++ b/integration-tests/http/__tests__/product/admin/product.spec.ts @@ -3,48 +3,10 @@ import { adminHeaders, createAdminUser, } from "../../../../helpers/create-admin-user" +import { getProductFixture } from "../../../../helpers/fixtures" jest.setTimeout(50000) -const getProductFixture = (overrides) => ({ - title: "Test fixture", - description: "test-product-description", - status: "draft", - // BREAKING: Images input changed from string[] to {url: string}[] - images: [{ url: "test-image.png" }, { url: "test-image-2.png" }], - tags: [{ value: "123" }, { value: "456" }], - // BREAKING: Options input changed from {title: string}[] to {title: string, values: string[]}[] - options: [ - { title: "size", values: ["large", "small"] }, - { title: "color", values: ["green"] }, - ], - variants: [ - { - title: "Test variant", - prices: [ - { - currency_code: "usd", - amount: 100, - }, - { - currency_code: "eur", - amount: 45, - }, - { - currency_code: "dkk", - amount: 30, - }, - ], - // BREAKING: Options input changed from {value: string}[] to {[optionTitle]: optionValue} map - options: { - size: "large", - color: "green", - }, - }, - ], - ...(overrides ?? {}), -}) - medusaIntegrationTestRunner({ testSuite: ({ dbConnection, getContainer, api }) => { let baseProduct @@ -56,7 +18,6 @@ medusaIntegrationTestRunner({ let publishedCollection let baseType - let baseRegion beforeEach(async () => { await createAdminUser(dbConnection, adminHeaders, getContainer()) @@ -85,18 +46,6 @@ medusaIntegrationTestRunner({ ) ).data.product_type - // BREAKING: Creating a region no longer takes tax_rate, payment_providers, fulfillment_providers, countriesr - baseRegion = ( - await api.post( - "/admin/regions", - { - name: "Test region", - currency_code: "USD", - }, - adminHeaders - ) - ).data.region - baseProduct = ( await api.post( "/admin/products", @@ -1947,527 +1896,6 @@ medusaIntegrationTestRunner({ }) }) - describe("GET /admin/products/:id/variants", () => { - it("should return the variants related to the requested product", async () => { - const res = await api - .get(`/admin/products/${baseProduct.id}/variants`, adminHeaders) - .catch((err) => { - console.log(err) - }) - - expect(res.status).toEqual(200) - expect(res.data.variants.length).toBe(1) - expect(res.data.variants).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: baseProduct.variants[0].id, - product_id: baseProduct.id, - }), - ]) - ) - }) - - it("should allow searching of variants", async () => { - const newProduct = ( - await api.post( - "/admin/products", - getProductFixture({ - variants: [ - { title: "First variant", prices: [] }, - { title: "Second variant", prices: [] }, - ], - }), - adminHeaders - ) - ).data.product - - const res = await api - .get( - `/admin/products/${newProduct.id}/variants?q=first`, - adminHeaders - ) - .catch((err) => { - console.log(err) - }) - - expect(res.status).toEqual(200) - expect(res.data.variants).toHaveLength(1) - expect(res.data.variants).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - title: "First variant", - product_id: newProduct.id, - }), - ]) - ) - }) - }) - - describe("updates a variant's default prices (ignores prices associated with a Price List)", () => { - it("successfully updates a variant's default prices by changing an existing price (currency_code)", async () => { - const data = { - prices: [ - { - currency_code: "usd", - amount: 1500, - }, - ], - } - - const response = await api.post( - `/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}`, - data, - adminHeaders - ) - - expect( - baseProduct.variants[0].prices.find( - (p) => p.currency_code === "usd" - ).amount - ).toEqual(100) - expect(response.status).toEqual(200) - expect(response.data).toEqual({ - product: expect.objectContaining({ - id: baseProduct.id, - variants: expect.arrayContaining([ - expect.objectContaining({ - id: baseProduct.variants[0].id, - prices: expect.arrayContaining([ - expect.objectContaining({ - amount: 1500, - currency_code: "usd", - }), - ]), - }), - ]), - }), - }) - }) - - // TODO: Do we want to add support for region prices through the product APIs? - it.skip("successfully updates a variant's price by changing an existing price (given a region_id)", async () => { - const data = { - prices: [ - { - region_id: "test-region", - amount: 1500, - }, - ], - } - - const response = await api - .post( - "/admin/products/test-product1/variants/test-variant_3", - data, - adminHeaders - ) - .catch((err) => { - console.log(err) - }) - - expect(response.status).toEqual(200) - - expect(response.data.product).toEqual( - expect.objectContaining({ - variants: expect.arrayContaining([ - expect.objectContaining({ - id: "test-variant_3", - prices: expect.arrayContaining([ - expect.objectContaining({ - amount: 1500, - currency_code: "usd", - region_id: "test-region", - }), - ]), - }), - ]), - }) - ) - }) - - it("successfully updates a variant's prices by adding a new price", async () => { - const usdPrice = baseProduct.variants[0].prices.find( - (p) => p.currency_code === "usd" - ) - const data = { - title: "Test variant prices", - prices: [ - { - id: usdPrice.id, - currency_code: "usd", - amount: 100, - }, - { - currency_code: "eur", - amount: 4500, - }, - ], - } - - const response = await api.post( - `/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}`, - data, - adminHeaders - ) - - expect(response.status).toEqual(200) - - expect(response.data).toEqual( - expect.objectContaining({ - product: expect.objectContaining({ - id: baseProduct.id, - variants: expect.arrayContaining([ - expect.objectContaining({ - id: baseProduct.variants[0].id, - prices: expect.arrayContaining([ - expect.objectContaining({ - amount: 100, - currency_code: "usd", - id: usdPrice.id, - }), - expect.objectContaining({ - amount: 4500, - currency_code: "eur", - }), - ]), - }), - ]), - }), - }) - ) - }) - - it("successfully updates a variant's prices by deleting a price and adding another price", async () => { - const data = { - prices: [ - { - currency_code: "dkk", - amount: 8000, - }, - { - currency_code: "eur", - amount: 900, - }, - ], - } - - const response = await api - .post( - `/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}`, - data, - adminHeaders - ) - .catch((err) => { - console.log(err) - }) - - expect(response.status).toEqual(200) - - const variant = response.data.product.variants[0] - expect(variant.prices.length).toEqual(2) - - expect(variant.prices).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - amount: 8000, - currency_code: "dkk", - }), - expect.objectContaining({ - amount: 900, - currency_code: "eur", - }), - ]) - ) - }) - - // TODO: Similarly we need to decide how to handle regions - it.skip("successfully updates a variant's prices by updating an existing price (using region_id) and adding another price", async () => { - const data = { - prices: [ - { - region_id: "test-region", - amount: 8000, - }, - { - currency_code: "eur", - amount: 900, - }, - ], - } - - const variantId = "test-variant_3" - const response = await api - .post( - `/admin/products/test-product1/variants/${variantId}`, - data, - adminHeaders - ) - .catch((err) => { - console.log(err) - }) - - expect(response.status).toEqual(200) - - const variant = response.data.product.variants.find( - (v) => v.id === variantId - ) - expect(variant.prices.length).toEqual(data.prices.length) - - expect(variant.prices).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - amount: 8000, - currency_code: "usd", - region_id: "test-region", - }), - expect.objectContaining({ - amount: 900, - currency_code: "eur", - }), - ]) - ) - }) - - // TODO: Similarly we need to decide how to handle regions - it.skip("successfully deletes a region price", async () => { - const createRegionPricePayload = { - prices: [ - { - currency_code: "usd", - amount: 1000, - }, - { - region_id: "test-region", - amount: 8000, - }, - { - currency_code: "eur", - amount: 900, - }, - ], - } - - const variantId = "test-variant_3" - - const createRegionPriceResponse = await api.post( - "/admin/products/test-product1/variants/test-variant_3", - createRegionPricePayload, - adminHeaders - ) - - const initialPriceArray = - createRegionPriceResponse.data.product.variants.find( - (v) => v.id === variantId - ).prices - - expect(createRegionPriceResponse.status).toEqual(200) - expect(initialPriceArray).toHaveLength(3) - expect(initialPriceArray).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - amount: 1000, - currency_code: "usd", - }), - expect.objectContaining({ - amount: 8000, - currency_code: "usd", - region_id: "test-region", - }), - expect.objectContaining({ - amount: 900, - currency_code: "eur", - }), - ]) - ) - - const deleteRegionPricePayload = { - prices: [ - { - currency_code: "usd", - amount: 1000, - }, - { - currency_code: "eur", - amount: 900, - }, - ], - } - - const deleteRegionPriceResponse = await api.post( - "/admin/products/test-product1/variants/test-variant_3", - deleteRegionPricePayload, - adminHeaders - ) - - const finalPriceArray = - deleteRegionPriceResponse.data.product.variants.find( - (v) => v.id === variantId - ).prices - - expect(deleteRegionPriceResponse.status).toEqual(200) - expect(finalPriceArray).toHaveLength(2) - expect(finalPriceArray).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - amount: 1000, - currency_code: "usd", - }), - expect.objectContaining({ - amount: 900, - currency_code: "eur", - }), - ]) - ) - }) - - // TODO: Similarly we need to decide how to handle regions - it.skip("successfully updates a variants prices by deleting both a currency and region price", async () => { - // await Promise.all( - // ["reg_1", "reg_2", "reg_3"].map(async (regionId) => { - // return await simpleRegionFactory(dbConnection, { - // id: regionId, - // currency_code: regionId === "reg_1" ? "eur" : "usd", - // }) - // }) - // ) - - const createPrices = { - prices: [ - { - region_id: "reg_1", - amount: 1, - }, - { - region_id: "reg_2", - amount: 2, - }, - { - currency_code: "usd", - amount: 3, - }, - { - region_id: "reg_3", - amount: 4, - }, - { - currency_code: "eur", - amount: 5, - }, - ], - } - - const variantId = "test-variant_3" - - await api - .post( - `/admin/products/test-product1/variants/${variantId}`, - createPrices, - adminHeaders - ) - .catch((err) => { - console.log(err) - }) - - const updatePrices = { - prices: [ - { - region_id: "reg_1", - amount: 100, - }, - { - region_id: "reg_2", - amount: 200, - }, - { - currency_code: "usd", - amount: 300, - }, - ], - } - - const response = await api.post( - `/admin/products/test-product1/variants/${variantId}`, - updatePrices, - adminHeaders - ) - - const finalPriceArray = response.data.product.variants.find( - (v) => v.id === variantId - ).prices - - expect(response.status).toEqual(200) - expect(finalPriceArray).toHaveLength(3) - expect(finalPriceArray).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - amount: 100, - region_id: "reg_1", - }), - expect.objectContaining({ - amount: 200, - region_id: "reg_2", - }), - expect.objectContaining({ - amount: 300, - currency_code: "usd", - }), - ]) - ) - }) - }) - - describe("variant creation", () => { - it("create a product variant with prices (regional and currency)", async () => { - const payload = { - title: "Created variant", - sku: "new-sku", - ean: "new-ean", - upc: "new-upc", - barcode: "new-barcode", - prices: [ - { - currency_code: "usd", - amount: 100, - }, - { - currency_code: "eur", - amount: 200, - }, - ], - } - - const res = await api - .post( - `/admin/products/${baseProduct.id}/variants`, - payload, - adminHeaders - ) - .catch((err) => console.log(err)) - - const insertedVariant = res.data.product.variants.find( - (v) => v.sku === "new-sku" - ) - - expect(res.status).toEqual(200) - - expect(insertedVariant.prices).toHaveLength(2) - expect(insertedVariant.prices).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - currency_code: "usd", - amount: 100, - variant_id: insertedVariant.id, - }), - expect.objectContaining({ - currency_code: "eur", - amount: 200, - variant_id: insertedVariant.id, - }), - ]) - ) - }) - }) - describe("testing for soft-deletion + uniqueness on handles, collection and variant properties", () => { it("successfully deletes a product", async () => { const response = await api @@ -3029,314 +2457,6 @@ medusaIntegrationTestRunner({ ) }) }) - - describe("POST /admin/products/:id/variants/:variant_id/inventory-items", () => { - it("should throw an error when required attributes are not passed", async () => { - const { response } = await api - .post( - `/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}/inventory-items`, - {}, - adminHeaders - ) - .catch((e) => e) - - expect(response.status).toEqual(400) - expect(response.data).toEqual({ - type: "invalid_data", - message: - "Invalid request: Field 'required_quantity' is required; Field 'inventory_item_id' is required", - }) - }) - - it("successfully adds inventory item to a variant", async () => { - const inventoryItem = ( - await api.post( - `/admin/inventory-items`, - { sku: "12345" }, - adminHeaders - ) - ).data.inventory_item - - const res = await api.post( - `/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}/inventory-items?fields=inventory_items.inventory.*,inventory_items.*`, - { - inventory_item_id: inventoryItem.id, - required_quantity: 5, - }, - adminHeaders - ) - - expect(res.status).toEqual(200) - expect(res.data.variant.inventory_items).toHaveLength(2) - expect(res.data.variant.inventory_items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - required_quantity: 5, - inventory_item_id: inventoryItem.id, - }), - ]) - ) - }) - }) - - describe("POST /admin/products/:id/variants/:variant_id/inventory-items/:inventory_id", () => { - let inventoryItem - - beforeEach(async () => { - inventoryItem = ( - await api.post( - `/admin/inventory-items`, - { sku: "12345" }, - adminHeaders - ) - ).data.inventory_item - - await api.post( - `/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}/inventory-items`, - { - inventory_item_id: inventoryItem.id, - required_quantity: 5, - }, - adminHeaders - ) - }) - - it("should throw an error when required attributes are not passed", async () => { - const { response } = await api - .post( - `/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}/inventory-items/${inventoryItem.id}`, - {}, - adminHeaders - ) - .catch((e) => e) - - expect(response.status).toEqual(400) - expect(response.data).toEqual({ - type: "invalid_data", - message: "Invalid request: Field 'required_quantity' is required", - }) - }) - - it("successfully updates an inventory item link to a variant", async () => { - const res = await api.post( - `/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}/inventory-items/${inventoryItem.id}?fields=inventory_items.inventory.*,inventory_items.*`, - { required_quantity: 10 }, - adminHeaders - ) - - expect(res.status).toEqual(200) - expect(res.data.variant.inventory_items).toHaveLength(2) - expect(res.data.variant.inventory_items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - required_quantity: 10, - inventory_item_id: inventoryItem.id, - }), - ]) - ) - }) - }) - - describe("DELETE /admin/products/:id/variants/:variant_id/inventory-items/:inventory_id", () => { - let inventoryItem - - beforeEach(async () => { - inventoryItem = ( - await api.post( - `/admin/inventory-items`, - { sku: "12345" }, - adminHeaders - ) - ).data.inventory_item - - await api.post( - `/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}/inventory-items`, - { - inventory_item_id: inventoryItem.id, - required_quantity: 5, - }, - adminHeaders - ) - }) - - it("successfully deletes an inventory item link from a variant", async () => { - await api.post( - `/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}/inventory-items`, - { inventory_item_id: inventoryItem.id, required_quantity: 5 }, - adminHeaders - ) - - const res = await api.delete( - `/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}/inventory-items/${inventoryItem.id}?fields=inventory_items.inventory.*,inventory_items.*`, - adminHeaders - ) - - expect(res.status).toEqual(200) - expect(res.data.parent.inventory_items).toHaveLength(1) - expect(res.data.parent.inventory_items[0].id).not.toBe( - inventoryItem.id - ) - }) - }) - - describe("POST /admin/products/:id/variants/:variant_id/inventory-items/batch", () => { - let inventoryItemToUpdate - let inventoryItemToDelete - let inventoryItemToCreate - let inventoryProduct - let inventoryVariant1 - let inventoryVariant2 - let inventoryVariant3 - - beforeEach(async () => { - inventoryProduct = ( - await api.post( - "/admin/products", - { - title: "product 1", - variants: [ - { - title: "variant 1", - prices: [{ currency_code: "usd", amount: 100 }], - }, - { - title: "variant 2", - prices: [{ currency_code: "usd", amount: 100 }], - }, - { - title: "variant 3", - prices: [{ currency_code: "usd", amount: 100 }], - }, - ], - }, - adminHeaders - ) - ).data.product - - inventoryVariant1 = inventoryProduct.variants[0] - inventoryVariant2 = inventoryProduct.variants[1] - inventoryVariant3 = inventoryProduct.variants[2] - - inventoryItemToCreate = ( - await api.post( - `/admin/inventory-items`, - { sku: "to-create" }, - adminHeaders - ) - ).data.inventory_item - - inventoryItemToUpdate = ( - await api.post( - `/admin/inventory-items`, - { sku: "to-update" }, - adminHeaders - ) - ).data.inventory_item - - inventoryItemToDelete = ( - await api.post( - `/admin/inventory-items`, - { sku: "to-delete" }, - adminHeaders - ) - ).data.inventory_item - - await api.post( - `/admin/products/${baseProduct.id}/variants/${inventoryVariant1.id}/inventory-items`, - { - inventory_item_id: inventoryItemToUpdate.id, - required_quantity: 5, - }, - adminHeaders - ) - - await api.post( - `/admin/products/${baseProduct.id}/variants/${inventoryVariant2.id}/inventory-items`, - { - inventory_item_id: inventoryItemToDelete.id, - required_quantity: 10, - }, - adminHeaders - ) - }) - - it("successfully creates, updates and deletes an inventory item link from a variant", async () => { - const res = await api.post( - `/admin/products/${baseProduct.id}/variants/inventory-items/batch`, - { - create: [ - { - required_quantity: 15, - inventory_item_id: inventoryItemToCreate.id, - variant_id: inventoryVariant3.id, - }, - ], - update: [ - { - required_quantity: 25, - inventory_item_id: inventoryItemToUpdate.id, - variant_id: inventoryVariant1.id, - }, - ], - delete: [ - { - inventory_item_id: inventoryItemToDelete.id, - variant_id: inventoryVariant2.id, - }, - ], - }, - adminHeaders - ) - - expect(res.status).toEqual(200) - - const createdLinkVariant = ( - await api.get( - `/admin/products/${baseProduct.id}/variants/${inventoryVariant3.id}?fields=inventory_items.inventory.*,inventory_items.*`, - adminHeaders - ) - ).data.variant - - expect(createdLinkVariant.inventory_items).toHaveLength(2) - expect(createdLinkVariant.inventory_items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - required_quantity: 15, - inventory_item_id: inventoryItemToCreate.id, - }), - ]) - ) - - const updatedLinkVariant = ( - await api.get( - `/admin/products/${baseProduct.id}/variants/${inventoryVariant1.id}?fields=inventory_items.inventory.*,inventory_items.*`, - adminHeaders - ) - ).data.variant - - expect(updatedLinkVariant.inventory_items).toHaveLength(2) - expect(updatedLinkVariant.inventory_items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - required_quantity: 25, - inventory_item_id: inventoryItemToUpdate.id, - }), - ]) - ) - - const deletedLinkVariant = ( - await api.get( - `/admin/products/${baseProduct.id}/variants/${inventoryVariant2.id}?fields=inventory_items.inventory.*,inventory_items.*`, - adminHeaders - ) - ).data.variant - - expect(deletedLinkVariant.inventory_items).toHaveLength(1) - expect(deletedLinkVariant.inventory_items[0].id).not.toEqual( - inventoryItemToDelete.id - ) - }) - }) }) }, }) diff --git a/integration-tests/http/__tests__/product/admin/variant.spec.ts b/integration-tests/http/__tests__/product/admin/variant.spec.ts new file mode 100644 index 0000000000..fc066f3d8a --- /dev/null +++ b/integration-tests/http/__tests__/product/admin/variant.spec.ts @@ -0,0 +1,943 @@ +import { medusaIntegrationTestRunner } from "medusa-test-utils" +import { + adminHeaders, + createAdminUser, +} from "../../../../helpers/create-admin-user" +import { getProductFixture } from "../../../../helpers/fixtures" + +jest.setTimeout(50000) + +medusaIntegrationTestRunner({ + testSuite: ({ dbConnection, getContainer, api }) => { + let baseProduct + let baseRegion + + beforeEach(async () => { + await createAdminUser(dbConnection, adminHeaders, getContainer()) + // BREAKING: Creating a region no longer takes tax_rate, payment_providers, fulfillment_providers, countriesr + baseRegion = ( + await api.post( + "/admin/regions", + { + name: "Test region", + currency_code: "USD", + }, + adminHeaders + ) + ).data.region + + baseProduct = ( + await api.post( + "/admin/products", + getProductFixture({ + title: "Base product", + }), + adminHeaders + ) + ).data.product + }) + + // BREAKING: We no longer have `/admin/variants` endpoint. Instead, variant is scoped by product ID, `/admin/products/:id/variants` + describe("GET /admin/products/:id/variants", () => { + it("should return the variants related to the requested product", async () => { + const res = await api + .get(`/admin/products/${baseProduct.id}/variants`, adminHeaders) + .catch((err) => { + console.log(err) + }) + + expect(res.status).toEqual(200) + expect(res.data.variants.length).toBe(1) + expect(res.data.variants).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: baseProduct.variants[0].id, + product_id: baseProduct.id, + }), + ]) + ) + }) + + it("should allow searching of variants", async () => { + const newProduct = ( + await api.post( + "/admin/products", + getProductFixture({ + variants: [ + { title: "First variant", prices: [] }, + { title: "Second variant", prices: [] }, + ], + }), + adminHeaders + ) + ).data.product + + const res = await api + .get( + `/admin/products/${newProduct.id}/variants?q=first`, + adminHeaders + ) + .catch((err) => { + console.log(err) + }) + + expect(res.status).toEqual(200) + expect(res.data.variants).toHaveLength(1) + expect(res.data.variants).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + title: "First variant", + product_id: newProduct.id, + }), + ]) + ) + }) + }) + + describe("updates a variant's default prices (ignores prices associated with a Price List)", () => { + it("successfully updates a variant's default prices by changing an existing price (currency_code)", async () => { + const data = { + prices: [ + { + currency_code: "usd", + amount: 1500, + }, + ], + } + + const response = await api.post( + `/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}`, + data, + adminHeaders + ) + + expect( + baseProduct.variants[0].prices.find((p) => p.currency_code === "usd") + .amount + ).toEqual(100) + expect(response.status).toEqual(200) + expect(response.data).toEqual({ + product: expect.objectContaining({ + id: baseProduct.id, + variants: expect.arrayContaining([ + expect.objectContaining({ + id: baseProduct.variants[0].id, + prices: expect.arrayContaining([ + expect.objectContaining({ + amount: 1500, + currency_code: "usd", + }), + ]), + }), + ]), + }), + }) + }) + + // TODO: Do we want to add support for region prices through the product APIs? + it.skip("successfully updates a variant's price by changing an existing price (given a region_id)", async () => { + const data = { + prices: [ + { + region_id: "test-region", + amount: 1500, + }, + ], + } + + const response = await api + .post( + "/admin/products/test-product1/variants/test-variant_3", + data, + adminHeaders + ) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + + expect(response.data.product).toEqual( + expect.objectContaining({ + variants: expect.arrayContaining([ + expect.objectContaining({ + id: "test-variant_3", + prices: expect.arrayContaining([ + expect.objectContaining({ + amount: 1500, + currency_code: "usd", + region_id: "test-region", + }), + ]), + }), + ]), + }) + ) + }) + + it("successfully updates a variant's prices by adding a new price", async () => { + const usdPrice = baseProduct.variants[0].prices.find( + (p) => p.currency_code === "usd" + ) + const data = { + title: "Test variant prices", + prices: [ + { + id: usdPrice.id, + currency_code: "usd", + amount: 100, + }, + { + currency_code: "eur", + amount: 4500, + }, + ], + } + + const response = await api.post( + `/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}`, + data, + adminHeaders + ) + + expect(response.status).toEqual(200) + + expect(response.data).toEqual( + expect.objectContaining({ + product: expect.objectContaining({ + id: baseProduct.id, + variants: expect.arrayContaining([ + expect.objectContaining({ + id: baseProduct.variants[0].id, + prices: expect.arrayContaining([ + expect.objectContaining({ + amount: 100, + currency_code: "usd", + id: usdPrice.id, + }), + expect.objectContaining({ + amount: 4500, + currency_code: "eur", + }), + ]), + }), + ]), + }), + }) + ) + }) + + it("successfully updates a variant's prices by deleting a price and adding another price", async () => { + const data = { + prices: [ + { + currency_code: "dkk", + amount: 8000, + }, + { + currency_code: "eur", + amount: 900, + }, + ], + } + + const response = await api + .post( + `/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}`, + data, + adminHeaders + ) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + + const variant = response.data.product.variants[0] + expect(variant.prices.length).toEqual(2) + + expect(variant.prices).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + amount: 8000, + currency_code: "dkk", + }), + expect.objectContaining({ + amount: 900, + currency_code: "eur", + }), + ]) + ) + }) + + // TODO: Similarly we need to decide how to handle regions + it.skip("successfully updates a variant's prices by updating an existing price (using region_id) and adding another price", async () => { + const data = { + prices: [ + { + region_id: "test-region", + amount: 8000, + }, + { + currency_code: "eur", + amount: 900, + }, + ], + } + + const variantId = "test-variant_3" + const response = await api + .post( + `/admin/products/test-product1/variants/${variantId}`, + data, + adminHeaders + ) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + + const variant = response.data.product.variants.find( + (v) => v.id === variantId + ) + expect(variant.prices.length).toEqual(data.prices.length) + + expect(variant.prices).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + amount: 8000, + currency_code: "usd", + region_id: "test-region", + }), + expect.objectContaining({ + amount: 900, + currency_code: "eur", + }), + ]) + ) + }) + + // TODO: Similarly we need to decide how to handle regions + it.skip("successfully deletes a region price", async () => { + const createRegionPricePayload = { + prices: [ + { + currency_code: "usd", + amount: 1000, + }, + { + region_id: "test-region", + amount: 8000, + }, + { + currency_code: "eur", + amount: 900, + }, + ], + } + + const variantId = "test-variant_3" + + const createRegionPriceResponse = await api.post( + "/admin/products/test-product1/variants/test-variant_3", + createRegionPricePayload, + adminHeaders + ) + + const initialPriceArray = + createRegionPriceResponse.data.product.variants.find( + (v) => v.id === variantId + ).prices + + expect(createRegionPriceResponse.status).toEqual(200) + expect(initialPriceArray).toHaveLength(3) + expect(initialPriceArray).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + amount: 1000, + currency_code: "usd", + }), + expect.objectContaining({ + amount: 8000, + currency_code: "usd", + region_id: "test-region", + }), + expect.objectContaining({ + amount: 900, + currency_code: "eur", + }), + ]) + ) + + const deleteRegionPricePayload = { + prices: [ + { + currency_code: "usd", + amount: 1000, + }, + { + currency_code: "eur", + amount: 900, + }, + ], + } + + const deleteRegionPriceResponse = await api.post( + "/admin/products/test-product1/variants/test-variant_3", + deleteRegionPricePayload, + adminHeaders + ) + + const finalPriceArray = + deleteRegionPriceResponse.data.product.variants.find( + (v) => v.id === variantId + ).prices + + expect(deleteRegionPriceResponse.status).toEqual(200) + expect(finalPriceArray).toHaveLength(2) + expect(finalPriceArray).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + amount: 1000, + currency_code: "usd", + }), + expect.objectContaining({ + amount: 900, + currency_code: "eur", + }), + ]) + ) + }) + + // TODO: Similarly we need to decide how to handle regions + it.skip("successfully updates a variants prices by deleting both a currency and region price", async () => { + // await Promise.all( + // ["reg_1", "reg_2", "reg_3"].map(async (regionId) => { + // return await simpleRegionFactory(dbConnection, { + // id: regionId, + // currency_code: regionId === "reg_1" ? "eur" : "usd", + // }) + // }) + // ) + + const createPrices = { + prices: [ + { + region_id: "reg_1", + amount: 1, + }, + { + region_id: "reg_2", + amount: 2, + }, + { + currency_code: "usd", + amount: 3, + }, + { + region_id: "reg_3", + amount: 4, + }, + { + currency_code: "eur", + amount: 5, + }, + ], + } + + const variantId = "test-variant_3" + + await api + .post( + `/admin/products/test-product1/variants/${variantId}`, + createPrices, + adminHeaders + ) + .catch((err) => { + console.log(err) + }) + + const updatePrices = { + prices: [ + { + region_id: "reg_1", + amount: 100, + }, + { + region_id: "reg_2", + amount: 200, + }, + { + currency_code: "usd", + amount: 300, + }, + ], + } + + const response = await api.post( + `/admin/products/test-product1/variants/${variantId}`, + updatePrices, + adminHeaders + ) + + const finalPriceArray = response.data.product.variants.find( + (v) => v.id === variantId + ).prices + + expect(response.status).toEqual(200) + expect(finalPriceArray).toHaveLength(3) + expect(finalPriceArray).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + amount: 100, + region_id: "reg_1", + }), + expect.objectContaining({ + amount: 200, + region_id: "reg_2", + }), + expect.objectContaining({ + amount: 300, + currency_code: "usd", + }), + ]) + ) + }) + }) + + // TODO: Do we want to support price calculation on the admin endpoints? Enable this suite if we do, otherwise remove it + describe.skip("variant pricing calculations", () => { + it("selects prices based on the passed currency code", async () => { + const response = await api.get( + `/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}?fields=calculated_price¤cy_code=usd`, + adminHeaders + ) + + expect(response.data.variant).toEqual({ + id: baseProduct.variants[0].id, + original_price: 100, + calculated_price: 80, + calculated_price_type: "sale", + original_price_incl_tax: null, + calculated_price_incl_tax: null, + original_tax: null, + calculated_tax: null, + options: expect.any(Array), + prices: expect.any(Array), + product: expect.any(Object), + created_at: expect.any(String), + updated_at: expect.any(String), + }) + }) + + it("selects prices based on the passed region id", async () => { + const response = await api.get( + `/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}?fields=calculated_price®ion_id=${baseRegion.id}`, + adminHeaders + ) + + expect(response.data.variant).toEqual({ + id: "test-variant", + original_price: 100, + calculated_price: 80, + calculated_price_type: "sale", + original_price_incl_tax: 100, + calculated_price_incl_tax: 80, + original_tax: 0, + calculated_tax: 0, + options: expect.any(Array), + prices: expect.any(Array), + product: expect.any(Object), + created_at: expect.any(String), + updated_at: expect.any(String), + }) + }) + + it("selects prices based on the passed region id and customer id", async () => { + const response = await api.get( + `/admin/products/${baseProduct.id}/variants/${ + baseProduct.variants[0].id + }?fields=calculated_price®ion_id=${ + baseRegion.id + }&customer_id=${""}`, + adminHeaders + ) + + expect(response.data.variant).toEqual({ + id: "test-variant", + original_price: 100, + calculated_price: 40, + calculated_price_type: "sale", + original_price_incl_tax: 100, + calculated_price_incl_tax: 40, + original_tax: 0, + calculated_tax: 0, + prices: expect.any(Array), + options: expect.any(Array), + product: expect.any(Object), + created_at: expect.any(String), + updated_at: expect.any(String), + }) + }) + }) + + describe("variant creation", () => { + it("create a product variant with prices (regional and currency)", async () => { + const payload = { + title: "Created variant", + sku: "new-sku", + ean: "new-ean", + upc: "new-upc", + barcode: "new-barcode", + prices: [ + { + currency_code: "usd", + amount: 100, + }, + { + currency_code: "eur", + amount: 200, + }, + ], + } + + const res = await api + .post( + `/admin/products/${baseProduct.id}/variants`, + payload, + adminHeaders + ) + .catch((err) => console.log(err)) + + const insertedVariant = res.data.product.variants.find( + (v) => v.sku === "new-sku" + ) + + expect(res.status).toEqual(200) + + expect(insertedVariant.prices).toHaveLength(2) + expect(insertedVariant.prices).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + currency_code: "usd", + amount: 100, + variant_id: insertedVariant.id, + }), + expect.objectContaining({ + currency_code: "eur", + amount: 200, + variant_id: insertedVariant.id, + }), + ]) + ) + }) + }) + + describe("POST /admin/products/:id/variants/:variant_id/inventory-items", () => { + it("should throw an error when required attributes are not passed", async () => { + const { response } = await api + .post( + `/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}/inventory-items`, + {}, + adminHeaders + ) + .catch((e) => e) + + expect(response.status).toEqual(400) + expect(response.data).toEqual({ + type: "invalid_data", + message: + "Invalid request: Field 'required_quantity' is required; Field 'inventory_item_id' is required", + }) + }) + + it("successfully adds inventory item to a variant", async () => { + const inventoryItem = ( + await api.post( + `/admin/inventory-items`, + { sku: "12345" }, + adminHeaders + ) + ).data.inventory_item + + const res = await api.post( + `/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}/inventory-items?fields=inventory_items.inventory.*,inventory_items.*`, + { + inventory_item_id: inventoryItem.id, + required_quantity: 5, + }, + adminHeaders + ) + + expect(res.status).toEqual(200) + expect(res.data.variant.inventory_items).toHaveLength(2) + expect(res.data.variant.inventory_items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + required_quantity: 5, + inventory_item_id: inventoryItem.id, + }), + ]) + ) + }) + }) + + describe("POST /admin/products/:id/variants/:variant_id/inventory-items/:inventory_id", () => { + let inventoryItem + + beforeEach(async () => { + inventoryItem = ( + await api.post( + `/admin/inventory-items`, + { sku: "12345" }, + adminHeaders + ) + ).data.inventory_item + + await api.post( + `/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}/inventory-items`, + { + inventory_item_id: inventoryItem.id, + required_quantity: 5, + }, + adminHeaders + ) + }) + + it("should throw an error when required attributes are not passed", async () => { + const { response } = await api + .post( + `/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}/inventory-items/${inventoryItem.id}`, + {}, + adminHeaders + ) + .catch((e) => e) + + expect(response.status).toEqual(400) + expect(response.data).toEqual({ + type: "invalid_data", + message: "Invalid request: Field 'required_quantity' is required", + }) + }) + + it("successfully updates an inventory item link to a variant", async () => { + const res = await api.post( + `/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}/inventory-items/${inventoryItem.id}?fields=inventory_items.inventory.*,inventory_items.*`, + { required_quantity: 10 }, + adminHeaders + ) + + expect(res.status).toEqual(200) + expect(res.data.variant.inventory_items).toHaveLength(2) + expect(res.data.variant.inventory_items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + required_quantity: 10, + inventory_item_id: inventoryItem.id, + }), + ]) + ) + }) + }) + + describe("DELETE /admin/products/:id/variants/:variant_id/inventory-items/:inventory_id", () => { + let inventoryItem + + beforeEach(async () => { + inventoryItem = ( + await api.post( + `/admin/inventory-items`, + { sku: "12345" }, + adminHeaders + ) + ).data.inventory_item + + await api.post( + `/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}/inventory-items`, + { + inventory_item_id: inventoryItem.id, + required_quantity: 5, + }, + adminHeaders + ) + }) + + it("successfully deletes an inventory item link from a variant", async () => { + await api.post( + `/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}/inventory-items`, + { inventory_item_id: inventoryItem.id, required_quantity: 5 }, + adminHeaders + ) + + const res = await api.delete( + `/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}/inventory-items/${inventoryItem.id}?fields=inventory_items.inventory.*,inventory_items.*`, + adminHeaders + ) + + expect(res.status).toEqual(200) + expect(res.data.parent.inventory_items).toHaveLength(1) + expect(res.data.parent.inventory_items[0].id).not.toBe(inventoryItem.id) + }) + }) + + describe("POST /admin/products/:id/variants/:variant_id/inventory-items/batch", () => { + let inventoryItemToUpdate + let inventoryItemToDelete + let inventoryItemToCreate + let inventoryProduct + let inventoryVariant1 + let inventoryVariant2 + let inventoryVariant3 + + beforeEach(async () => { + inventoryProduct = ( + await api.post( + "/admin/products", + { + title: "product 1", + variants: [ + { + title: "variant 1", + prices: [{ currency_code: "usd", amount: 100 }], + }, + { + title: "variant 2", + prices: [{ currency_code: "usd", amount: 100 }], + }, + { + title: "variant 3", + prices: [{ currency_code: "usd", amount: 100 }], + }, + ], + }, + adminHeaders + ) + ).data.product + + inventoryVariant1 = inventoryProduct.variants[0] + inventoryVariant2 = inventoryProduct.variants[1] + inventoryVariant3 = inventoryProduct.variants[2] + + inventoryItemToCreate = ( + await api.post( + `/admin/inventory-items`, + { sku: "to-create" }, + adminHeaders + ) + ).data.inventory_item + + inventoryItemToUpdate = ( + await api.post( + `/admin/inventory-items`, + { sku: "to-update" }, + adminHeaders + ) + ).data.inventory_item + + inventoryItemToDelete = ( + await api.post( + `/admin/inventory-items`, + { sku: "to-delete" }, + adminHeaders + ) + ).data.inventory_item + + await api.post( + `/admin/products/${baseProduct.id}/variants/${inventoryVariant1.id}/inventory-items`, + { + inventory_item_id: inventoryItemToUpdate.id, + required_quantity: 5, + }, + adminHeaders + ) + + await api.post( + `/admin/products/${baseProduct.id}/variants/${inventoryVariant2.id}/inventory-items`, + { + inventory_item_id: inventoryItemToDelete.id, + required_quantity: 10, + }, + adminHeaders + ) + }) + + it("successfully creates, updates and deletes an inventory item link from a variant", async () => { + const res = await api.post( + `/admin/products/${baseProduct.id}/variants/inventory-items/batch`, + { + create: [ + { + required_quantity: 15, + inventory_item_id: inventoryItemToCreate.id, + variant_id: inventoryVariant3.id, + }, + ], + update: [ + { + required_quantity: 25, + inventory_item_id: inventoryItemToUpdate.id, + variant_id: inventoryVariant1.id, + }, + ], + delete: [ + { + inventory_item_id: inventoryItemToDelete.id, + variant_id: inventoryVariant2.id, + }, + ], + }, + adminHeaders + ) + + expect(res.status).toEqual(200) + + const createdLinkVariant = ( + await api.get( + `/admin/products/${baseProduct.id}/variants/${inventoryVariant3.id}?fields=inventory_items.inventory.*,inventory_items.*`, + adminHeaders + ) + ).data.variant + + expect(createdLinkVariant.inventory_items).toHaveLength(2) + expect(createdLinkVariant.inventory_items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + required_quantity: 15, + inventory_item_id: inventoryItemToCreate.id, + }), + ]) + ) + + const updatedLinkVariant = ( + await api.get( + `/admin/products/${baseProduct.id}/variants/${inventoryVariant1.id}?fields=inventory_items.inventory.*,inventory_items.*`, + adminHeaders + ) + ).data.variant + + expect(updatedLinkVariant.inventory_items).toHaveLength(2) + expect(updatedLinkVariant.inventory_items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + required_quantity: 25, + inventory_item_id: inventoryItemToUpdate.id, + }), + ]) + ) + + const deletedLinkVariant = ( + await api.get( + `/admin/products/${baseProduct.id}/variants/${inventoryVariant2.id}?fields=inventory_items.inventory.*,inventory_items.*`, + adminHeaders + ) + ).data.variant + + expect(deletedLinkVariant.inventory_items).toHaveLength(1) + expect(deletedLinkVariant.inventory_items[0].id).not.toEqual( + inventoryItemToDelete.id + ) + }) + }) + }, +}) diff --git a/integration-tests/http/__tests__/product/store/product.spec.ts b/integration-tests/http/__tests__/product/store/product.spec.ts new file mode 100644 index 0000000000..a51827f59a --- /dev/null +++ b/integration-tests/http/__tests__/product/store/product.spec.ts @@ -0,0 +1,405 @@ +import { medusaIntegrationTestRunner } from "medusa-test-utils" +import { + adminHeaders, + createAdminUser, +} from "../../../../helpers/create-admin-user" +import { getProductFixture } from "../../../../helpers/fixtures" +import { ModuleRegistrationName } from "@medusajs/utils" +import { IStoreModuleService } from "@medusajs/types" + +jest.setTimeout(30000) + +medusaIntegrationTestRunner({ + testSuite: ({ dbConnection, api, getContainer }) => { + let store + let product1 + let product2 + let product3 + + beforeEach(async () => { + const appContainer = getContainer() + await createAdminUser(dbConnection, adminHeaders, appContainer) + + const storeModule: IStoreModuleService = appContainer.resolve( + ModuleRegistrationName.STORE + ) + // A default store is created when the app is started, so we want to delete that one and create one specifically for our tests. + const defaultId = (await api.get("/admin/stores", adminHeaders)).data + .stores?.[0]?.id + if (defaultId) { + storeModule.delete(defaultId) + } + + store = await storeModule.create({ + name: "New store", + supported_currency_codes: ["usd", "dkk"], + default_currency_code: "usd", + }) + + product1 = ( + await api.post( + "/admin/products", + getProductFixture({ title: "test1", status: "published" }), + adminHeaders + ) + ).data.product + product2 = ( + await api.post( + "/admin/products", + getProductFixture({ title: "test2", status: "published" }), + adminHeaders + ) + ).data.product + product3 = ( + await api.post( + "/admin/products", + getProductFixture({ title: "test3", status: "published" }), + adminHeaders + ) + ).data.product + }) + + describe("Get products based on publishable key", () => { + let pubKey1 + let salesChannel1 + let salesChannel2 + + beforeEach(async () => { + pubKey1 = ( + await api.post( + "/admin/api-keys", + { title: "sample key", type: "publishable" }, + adminHeaders + ) + ).data.api_key + + salesChannel1 = ( + await api.post( + "/admin/sales-channels", + { + name: "test name", + description: "test description", + }, + adminHeaders + ) + ).data.sales_channel + + salesChannel2 = ( + await api.post( + "/admin/sales-channels", + { + name: "test name 2", + description: "test description 2", + }, + adminHeaders + ) + ).data.sales_channel + + await api.post( + `/admin/sales-channels/${salesChannel1.id}/products`, + { add: [product1.id] }, + adminHeaders + ) + await api.post( + `/admin/sales-channels/${salesChannel2.id}/products`, + { add: [product2.id] }, + adminHeaders + ) + await api.post( + `/admin/stores/${store.id}`, + { default_sales_channel_id: salesChannel1.id }, + adminHeaders + ) + }) + + it("returns products from a specific channel associated with a publishable key", async () => { + await api.post( + `/admin/api-keys/${pubKey1.id}/sales-channels`, + { + add: [salesChannel1.id], + }, + adminHeaders + ) + + const response = await api.get(`/store/products`, { + headers: { + ...adminHeaders.headers, + "x-publishable-api-key": pubKey1.token, + }, + }) + + expect(response.data.products.length).toBe(1) + expect(response.data.products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: product1.id, + }), + ]) + ) + }) + + it("returns products from multiples sales channels associated with a publishable key", async () => { + await api.post( + `/admin/api-keys/${pubKey1.id}/sales-channels`, + { + add: [salesChannel1.id, salesChannel2.id], + }, + adminHeaders + ) + + const response = await api.get(`/store/products`, { + headers: { + ...adminHeaders.headers, + "x-publishable-api-key": pubKey1.token, + }, + }) + + expect(response.data.products.length).toBe(2) + expect(response.data.products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: product2.id, + }), + expect.objectContaining({ + id: product1.id, + }), + ]) + ) + }) + + it("SC param overrides PK channels (but SK still needs to be in the PK's scope", async () => { + await api.post( + `/admin/api-keys/${pubKey1.id}/sales-channels`, + { + add: [salesChannel1.id, salesChannel2.id], + }, + adminHeaders + ) + + const response = await api.get( + `/store/products?sales_channel_id[0]=${salesChannel2.id}`, + { + headers: { + ...adminHeaders.headers, + "x-publishable-api-key": pubKey1.token, + }, + } + ) + + expect(response.data.products.length).toBe(1) + expect(response.data.products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: product2.id, + }), + ]) + ) + }) + + it("returns default product from default sales channel if PK is not passed", async () => { + await api.post( + `/admin/stores/${store.id}`, + { default_sales_channel_id: salesChannel2.id }, + adminHeaders + ) + + await api.post( + `/admin/api-keys/${pubKey1.id}/sales-channels`, + { + add: [salesChannel1.id, salesChannel2.id], + }, + adminHeaders + ) + + const response = await api.get(`/store/products`, { + adminHeaders, + }) + + expect(response.data.products.length).toBe(1) + expect(response.data.products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: product2.id, + }), + ]) + ) + }) + + // TODO: Decide if this is the behavior we want to keep in v2, as it seems a bit strange + it.skip("returns all products if passed PK doesn't have associated channels", async () => { + const response = await api.get(`/store/products`, { + headers: { + ...adminHeaders.headers, + "x-publishable-api-key": pubKey1.token, + }, + }) + + expect(response.data.products.length).toBe(3) + expect(response.data.products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: product1.id, + }), + expect.objectContaining({ + id: product2.id, + }), + expect.objectContaining({ + id: product3.id, + }), + ]) + ) + }) + + it("throws because sales channel param is not in the scope of passed PK", async () => { + await api.post( + `/admin/api-keys/${pubKey1.id}/sales-channels`, + { + add: [salesChannel1.id], + }, + adminHeaders + ) + + const err = await api + .get(`/store/products?sales_channel_id[]=${salesChannel2.id}`, { + headers: { + ...adminHeaders.headers, + "x-publishable-api-key": pubKey1.token, + }, + }) + .catch((e) => e) + + expect(err.response.status).toEqual(400) + expect(err.response.data.message).toEqual( + `Requested sales channel is not part of the publishable key mappings` + ) + }) + + it("retrieve a product from a specific channel associated with a publishable key", async () => { + await api.post( + `/admin/api-keys/${pubKey1.id}/sales-channels`, + { + add: [salesChannel1.id], + }, + adminHeaders + ) + + const response = await api.get(`/store/products/${product1.id}`, { + headers: { + ...adminHeaders.headers, + "x-publishable-api-key": pubKey1.token, + }, + }) + + expect(response.data.product).toEqual( + expect.objectContaining({ + id: product1.id, + }) + ) + }) + + // BREAKING: If product not in sales channel we used to return 400, we return 404 instead. + it("return 404 because requested product is not in the SC associated with a publishable key", async () => { + await api.post( + `/admin/api-keys/${pubKey1.id}/sales-channels`, + { + add: [salesChannel1.id], + }, + adminHeaders + ) + + const err = await api + .get(`/store/products/${product2.id}`, { + headers: { + ...adminHeaders.headers, + "x-publishable-api-key": pubKey1.token, + }, + }) + .catch((e) => e) + + expect(err.response.status).toEqual(404) + }) + + // TODO: Add variant endpoints to the store API (if that is what we want) + it.skip("should return 404 when the requested variant doesn't exist", async () => { + await api.post( + `/admin/api-keys/${pubKey1.id}/sales-channels`, + { + add: [salesChannel1.id], + }, + adminHeaders + ) + + const response = await api + .get(`/store/variants/does-not-exist`, { + headers: { + ...adminHeaders.headers, + "x-publishable-api-key": pubKey1.token, + }, + }) + .catch((err) => { + return err.response + }) + + expect(response.status).toEqual(404) + expect(response.data.message).toEqual( + "Variant with id: does-not-exist was not found" + ) + }) + + it("should return 404 when the requested product doesn't exist", async () => { + await api.post( + `/admin/api-keys/${pubKey1.id}/sales-channels`, + { + add: [salesChannel1.id], + }, + adminHeaders + ) + + const response = await api + .get(`/store/products/does-not-exist`, { + headers: { + ...adminHeaders.headers, + "x-publishable-api-key": pubKey1.token, + }, + }) + .catch((err) => { + return err.response + }) + + expect(response.status).toEqual(404) + expect(response.data.message).toEqual( + "Product with id: does-not-exist was not found" + ) + }) + + // TODO: Similar to above, decide what the behavior should be in v2 + it.skip("correctly returns a product if passed PK has no associated SCs", async () => { + let response = await api + .get(`/store/products/${product1.id}`, { + headers: { + ...adminHeaders.headers, + "x-publishable-api-key": pubKey1.token, + }, + }) + .catch((err) => { + return err.response + }) + + expect(response.status).toEqual(200) + + response = await api + .get(`/store/products/${product2.id}`, { + headers: { + ...adminHeaders.headers, + "x-publishable-api-key": pubKey1.token, + }, + }) + .catch((err) => { + return err.response + }) + + expect(response.status).toEqual(200) + }) + }) + }, +}) diff --git a/integration-tests/http/__tests__/sales-channel/admin/sales-channel.spec.ts b/integration-tests/http/__tests__/sales-channel/admin/sales-channel.spec.ts index 7d3bf7c3ad..5f70bb95b7 100644 --- a/integration-tests/http/__tests__/sales-channel/admin/sales-channel.spec.ts +++ b/integration-tests/http/__tests__/sales-channel/admin/sales-channel.spec.ts @@ -1,39 +1,47 @@ import { medusaIntegrationTestRunner } from "medusa-test-utils" import { - adminHeaders, - createAdminUser, + adminHeaders, + createAdminUser, } from "../../../../helpers/create-admin-user" jest.setTimeout(60000) medusaIntegrationTestRunner({ testSuite: ({ dbConnection, getContainer, api }) => { - beforeAll(() => {}) + let salesChannel1 + let salesChannel2 beforeEach(async () => { const container = getContainer() await createAdminUser(dbConnection, adminHeaders, container) + + salesChannel1 = ( + await api.post( + "/admin/sales-channels", + { + name: "test name", + description: "test description", + }, + adminHeaders + ) + ).data.sales_channel + + salesChannel2 = ( + await api.post( + "/admin/sales-channels", + { + name: "test name 2", + description: "test description 2", + }, + adminHeaders + ) + ).data.sales_channel }) describe("GET /admin/sales-channels/:id", () => { - let salesChannel - - beforeEach(async () => { - salesChannel = ( - await api.post( - "/admin/sales-channels", - { - name: "test name", - description: "test description", - }, - adminHeaders - ) - ).data.sales_channel - }) - it("should retrieve the requested sales channel", async () => { const response = await api.get( - `/admin/sales-channels/${salesChannel.id}`, + `/admin/sales-channels/${salesChannel1.id}`, adminHeaders ) @@ -42,8 +50,8 @@ medusaIntegrationTestRunner({ expect(response.data.sales_channel).toEqual( expect.objectContaining({ id: expect.any(String), - name: salesChannel.name, - description: salesChannel.description, + name: salesChannel1.name, + description: salesChannel1.description, created_at: expect.any(String), updated_at: expect.any(String), }) @@ -52,33 +60,6 @@ medusaIntegrationTestRunner({ }) describe("GET /admin/sales-channels", () => { - let salesChannel1 - let salesChannel2 - - beforeEach(async () => { - salesChannel1 = ( - await api.post( - "/admin/sales-channels", - { - name: "test name", - description: "test description", - }, - adminHeaders - ) - ).data.sales_channel - - salesChannel2 = ( - await api.post( - "/admin/sales-channels", - { - name: "test name 2", - description: "test description 2", - }, - adminHeaders - ) - ).data.sales_channel - }) - it("should list the sales channel", async () => { const response = await api.get(`/admin/sales-channels`, adminHeaders) @@ -183,21 +164,6 @@ medusaIntegrationTestRunner({ }) describe("POST /admin/sales-channels/:id", () => { - let sc - - beforeEach(async () => { - sc = ( - await api.post( - "/admin/sales-channels", - { - name: "test name", - description: "test description", - }, - adminHeaders - ) - ).data.sales_channel - }) - it("updates sales channel properties", async () => { const payload = { name: "updated name", @@ -206,7 +172,7 @@ medusaIntegrationTestRunner({ } const response = await api.post( - `/admin/sales-channels/${sc.id}`, + `/admin/sales-channels/${salesChannel1.id}`, payload, adminHeaders ) @@ -279,46 +245,19 @@ medusaIntegrationTestRunner({ }) describe("DELETE /admin/sales-channels/:id", () => { - let salesChannel - let salesChannel2 - - beforeEach(async () => { - salesChannel = ( - await api.post( - "/admin/sales-channels", - { - name: "test name", - description: "test description", - }, - adminHeaders - ) - ).data.sales_channel - - salesChannel2 = ( - await api.post( - "/admin/sales-channels", - { - name: "test name 2", - description: "test description 2", - }, - adminHeaders - ) - ).data.sales_channel - }) - it("should delete the requested sales channel", async () => { const toDelete = ( await api.get( - `/admin/sales-channels/${salesChannel.id}`, + `/admin/sales-channels/${salesChannel1.id}`, adminHeaders ) ).data.sales_channel - expect(toDelete.id).toEqual(salesChannel.id) + expect(toDelete.id).toEqual(salesChannel1.id) expect(toDelete.deleted_at).toEqual(null) const response = await api.delete( - `/admin/sales-channels/${salesChannel.id}`, + `/admin/sales-channels/${salesChannel1.id}`, adminHeaders ) @@ -331,13 +270,13 @@ medusaIntegrationTestRunner({ await api .get( - `/admin/sales-channels/${salesChannel.id}?fields=id,deleted_at`, + `/admin/sales-channels/${salesChannel1.id}?fields=id,deleted_at`, adminHeaders ) .catch((err) => { expect(err.response.data.type).toEqual("not_found") expect(err.response.data.message).toEqual( - `Sales channel with id: ${salesChannel.id} not found` + `Sales channel with id: ${salesChannel1.id} not found` ) }) }) @@ -356,13 +295,13 @@ medusaIntegrationTestRunner({ await api.post( `/admin/stock-locations/${location.id}/sales-channels`, { - add: [salesChannel.id, salesChannel2.id], + add: [salesChannel1.id, salesChannel2.id], }, adminHeaders ) await api.delete( - `/admin/sales-channels/${salesChannel.id}`, + `/admin/sales-channels/${salesChannel1.id}`, adminHeaders ) @@ -381,21 +320,8 @@ medusaIntegrationTestRunner({ // BREAKING CHANGE: Endpoint has changed // from: /admin/sales-channels/:id/products/batch // to: /admin/sales-channels/:id/products - - let salesChannel let product beforeEach(async () => { - salesChannel = ( - await api.post( - "/admin/sales-channels", - { - name: "test name", - description: "test description", - }, - adminHeaders - ) - ).data.sales_channel - product = ( await api.post( "/admin/products", @@ -409,7 +335,7 @@ medusaIntegrationTestRunner({ it("should add products to a sales channel", async () => { const response = await api.post( - `/admin/sales-channels/${salesChannel.id}/products`, + `/admin/sales-channels/${salesChannel1.id}/products`, { add: [product.id] }, adminHeaders ) @@ -449,7 +375,7 @@ medusaIntegrationTestRunner({ it("should remove products from a sales channel", async () => { await api.post( - `/admin/sales-channels/${salesChannel.id}/products`, + `/admin/sales-channels/${salesChannel1.id}/products`, { add: [product.id] }, adminHeaders ) @@ -474,7 +400,7 @@ medusaIntegrationTestRunner({ ) const response = await api.post( - `/admin/sales-channels/${salesChannel.id}/products`, + `/admin/sales-channels/${salesChannel1.id}/products`, { remove: [product.id] }, adminHeaders ) @@ -499,7 +425,50 @@ medusaIntegrationTestRunner({ expect(product.sales_channels.length).toBe(0) }) }) - // DELETED TESTS: + + describe("Sales channels with publishable key", () => { + let pubKey1 + beforeEach(async () => { + pubKey1 = ( + await api.post( + "/admin/api-keys", + { title: "sample key", type: "publishable" }, + adminHeaders + ) + ).data.api_key + + await api.post( + `/admin/api-keys/${pubKey1.id}/sales-channels`, + { + add: [salesChannel1.id, salesChannel2.id], + }, + adminHeaders + ) + }) + + it("list sales channels from the publishable api key with free text search filter", async () => { + const response = await api.get( + `/admin/sales-channels?q=2&publishable_api_key=${pubKey1.id}`, + adminHeaders + ) + + expect(response.status).toBe(200) + expect(response.data.sales_channels.length).toEqual(1) + expect(response.data.sales_channels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: salesChannel2.id, + deleted_at: null, + name: "test name 2", + description: "test description 2", + is_disabled: false, + }), + ]) + ) + }) + }) + + // BREAKING: DELETED TESTS: // - POST /admin/products/:id // - Mutation sales channels on products // - POST /admin/products diff --git a/integration-tests/http/__tests__/store/admin/store.spec.ts b/integration-tests/http/__tests__/store/admin/store.spec.ts new file mode 100644 index 0000000000..b1e4529e7f --- /dev/null +++ b/integration-tests/http/__tests__/store/admin/store.spec.ts @@ -0,0 +1,194 @@ +import { medusaIntegrationTestRunner } from "medusa-test-utils" +import { + adminHeaders, + createAdminUser, +} from "../../../../helpers/create-admin-user" +import { ModuleRegistrationName } from "@medusajs/utils" +import { IStoreModuleService } from "@medusajs/types" + +jest.setTimeout(90000) + +medusaIntegrationTestRunner({ + testSuite: ({ dbConnection, getContainer, api }) => { + describe("/admin/stores", () => { + let store + let container + + beforeEach(async () => { + container = getContainer() + const storeModule: IStoreModuleService = container.resolve( + ModuleRegistrationName.STORE + ) + await createAdminUser(dbConnection, adminHeaders, container) + + // A default store is created when the app is started, so we want to delete that one and create one specifically for our tests. + const defaultId = (await api.get("/admin/stores", adminHeaders)).data + .stores?.[0]?.id + if (defaultId) { + storeModule.delete(defaultId) + } + + store = await storeModule.create({ + name: "New store", + supported_currency_codes: ["usd", "dkk"], + default_currency_code: "usd", + default_sales_channel_id: "sc_12345", + }) + }) + + // BREAKING: The URL changed from `GET /admin/store` to `GET /admin/stores` + describe("Store creation", () => { + it("has created store with default currency", async () => { + const resStore = ( + await api.get("/admin/stores", adminHeaders) + ).data.stores.find((s) => s.id === store.id) + + // BREAKING: The store response contained currencies, modules, and feature flags, which are not present anymore + expect(resStore).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: "New store", + default_currency_code: "usd", + default_sales_channel_id: expect.any(String), + supported_currency_codes: ["usd", "dkk"], + created_at: expect.any(String), + updated_at: expect.any(String), + }) + ) + }) + }) + + describe("POST /admin/stores", () => { + it("fails to update default currency if not in store currencies", async () => { + const err = await api + .post( + `/admin/stores/${store.id}`, + { + default_currency_code: "eur", + }, + adminHeaders + ) + .catch((e) => e) + + expect(err.response.status).toBe(400) + expect(err.response.data).toEqual( + expect.objectContaining({ + type: "invalid_data", + message: "Store does not have currency: eur", + }) + ) + }) + + // BREAKING: `currencies` was renamed to `supported_currency_codes` + it("fails to remove default currency from currencies without replacing it", async () => { + const err = await api + .post( + `/admin/stores/${store.id}`, + { supported_currency_codes: ["dkk"] }, + adminHeaders + ) + .catch((e) => e) + + expect(err.response.status).toBe(400) + expect(err.response.data).toEqual( + expect.objectContaining({ + type: "invalid_data", + message: + "You are not allowed to remove default currency from store currencies without replacing it as well", + }) + ) + }) + + it("successfully updates default currency code", async () => { + const response = await api + .post( + `/admin/stores/${store.id}`, + { + default_currency_code: "dkk", + }, + adminHeaders + ) + .catch((err) => console.log(err)) + + expect(response.status).toEqual(200) + expect(response.data.store).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: "New store", + default_currency_code: "dkk", + created_at: expect.any(String), + updated_at: expect.any(String), + }) + ) + }) + + it("successfully updates default currency and store currencies", async () => { + const response = await api.post( + `/admin/stores/${store.id}`, + { + default_currency_code: "jpy", + supported_currency_codes: ["jpy", "usd"], + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.store).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: "New store", + default_sales_channel_id: expect.any(String), + supported_currency_codes: ["jpy", "usd"], + default_currency_code: "jpy", + created_at: expect.any(String), + updated_at: expect.any(String), + }) + ) + }) + + it("successfully updates and store currencies", async () => { + const response = await api.post( + `/admin/stores/${store.id}`, + { + supported_currency_codes: ["jpy", "usd"], + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.store).toEqual( + expect.objectContaining({ + id: expect.any(String), + default_sales_channel_id: expect.any(String), + name: "New store", + supported_currency_codes: ["jpy", "usd"], + default_currency_code: "usd", + created_at: expect.any(String), + updated_at: expect.any(String), + }) + ) + }) + }) + + describe("GET /admin/stores", () => { + it("supports searching of stores", async () => { + const response = await api.get( + "/admin/stores?q=nonexistent", + adminHeaders + ) + expect(response.status).toEqual(200) + expect(response.data.stores).toHaveLength(0) + + const response2 = await api.get("/admin/stores?q=store", adminHeaders) + expect(response.status).toEqual(200) + expect(response2.data.stores).toEqual([ + expect.objectContaining({ + id: store.id, + name: "New store", + }), + ]) + }) + }) + }) + }, +}) diff --git a/integration-tests/modules/__tests__/product/store/index.spec.ts b/integration-tests/modules/__tests__/product/store/index.spec.ts index b63fa1dbe3..bde5955269 100644 --- a/integration-tests/modules/__tests__/product/store/index.spec.ts +++ b/integration-tests/modules/__tests__/product/store/index.spec.ts @@ -317,7 +317,7 @@ medusaIntegrationTestRunner({ expect(error.response.status).toEqual(400) expect(error.response.data).toEqual({ - message: `Invalid sales channel filters provided - does-not-exist`, + message: `Requested sales channel is not part of the publishable key mappings`, type: "invalid_data", }) }) @@ -331,7 +331,7 @@ medusaIntegrationTestRunner({ expect(error.response.status).toEqual(400) expect(error.response.data).toEqual({ - message: `Invalid sales channel filters provided - ${salesChannel2.id}`, + message: `Requested sales channel is not part of the publishable key mappings`, type: "invalid_data", }) }) diff --git a/packages/core/medusa-test-utils/src/medusa-test-runner.ts b/packages/core/medusa-test-utils/src/medusa-test-runner.ts index 6dca6e5e96..e239be8deb 100644 --- a/packages/core/medusa-test-utils/src/medusa-test-runner.ts +++ b/packages/core/medusa-test-utils/src/medusa-test-runner.ts @@ -153,6 +153,7 @@ export function medusaIntegrationTestRunner({ let isFirstTime = true const beforeAll_ = async () => { + console.log(`Creating database ${dbName}`) await dbUtils.create(dbName) try { diff --git a/packages/medusa/src/api/admin/api-keys/[id]/route.ts b/packages/medusa/src/api/admin/api-keys/[id]/route.ts index d4a786fa67..abd375f249 100644 --- a/packages/medusa/src/api/admin/api-keys/[id]/route.ts +++ b/packages/medusa/src/api/admin/api-keys/[id]/route.ts @@ -9,6 +9,7 @@ import { import { refetchApiKey } from "../helpers" import { AdminUpdateApiKeyType } from "../validators" +import { MedusaError } from "@medusajs/utils" export const GET = async ( req: AuthenticatedMedusaRequest, @@ -20,6 +21,13 @@ export const GET = async ( req.remoteQueryConfig.fields ) + if (!apiKey) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `API Key with id: ${req.params.id} was not found` + ) + } + res.status(200).json({ api_key: apiKey }) } diff --git a/packages/medusa/src/api/admin/api-keys/query-config.ts b/packages/medusa/src/api/admin/api-keys/query-config.ts index 41a60ebcad..d1ce9944d1 100644 --- a/packages/medusa/src/api/admin/api-keys/query-config.ts +++ b/packages/medusa/src/api/admin/api-keys/query-config.ts @@ -5,6 +5,7 @@ export const defaultAdminApiKeyFields = [ "redacted", "type", "last_used_at", + "updated_at", "created_at", "created_by", "revoked_at", diff --git a/packages/medusa/src/api/admin/api-keys/validators.ts b/packages/medusa/src/api/admin/api-keys/validators.ts index d01c07d851..943fa8a022 100644 --- a/packages/medusa/src/api/admin/api-keys/validators.ts +++ b/packages/medusa/src/api/admin/api-keys/validators.ts @@ -11,7 +11,7 @@ export const AdminGetApiKeyParams = createSelectParams() export type AdminGetApiKeysParamsType = z.infer export const AdminGetApiKeysParams = createFindParams({ offset: 0, - limit: 50, + limit: 20, }).merge( z.object({ q: z.string().optional(), diff --git a/packages/medusa/src/api/admin/products/[id]/route.ts b/packages/medusa/src/api/admin/products/[id]/route.ts index 96221e7409..54b8f0d83d 100644 --- a/packages/medusa/src/api/admin/products/[id]/route.ts +++ b/packages/medusa/src/api/admin/products/[id]/route.ts @@ -18,6 +18,7 @@ import { remapProductResponse, } from "../helpers" import { AdminUpdateProductType } from "../validators" +import { MedusaError } from "@medusajs/utils" export const GET = async ( req: AuthenticatedMedusaRequest, @@ -35,6 +36,9 @@ export const GET = async ( }) const [product] = await remoteQuery(queryObject) + if (!product) { + throw new MedusaError(MedusaError.Types.NOT_FOUND, "Product not found") + } res.status(200).json({ product: remapProductResponse(product) }) } diff --git a/packages/medusa/src/api/store/products/[id]/route.ts b/packages/medusa/src/api/store/products/[id]/route.ts index e7e4fae94c..57dd6bdf25 100644 --- a/packages/medusa/src/api/store/products/[id]/route.ts +++ b/packages/medusa/src/api/store/products/[id]/route.ts @@ -2,6 +2,7 @@ import { isPresent } from "@medusajs/utils" import { MedusaRequest, MedusaResponse } from "../../../../types/routing" import { refetchProduct, wrapVariantsWithInventoryQuantity } from "../helpers" import { StoreGetProductsParamsType } from "../validators" +import { MedusaError } from "@medusajs/utils" export const GET = async ( req: MedusaRequest, @@ -34,6 +35,13 @@ export const GET = async ( req.remoteQueryConfig.fields ) + if (!product) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Product with id: ${req.params.id} was not found` + ) + } + if (withInventoryQuantity) { await wrapVariantsWithInventoryQuantity(req, product.variants || []) } diff --git a/packages/medusa/src/api/store/products/middlewares.ts b/packages/medusa/src/api/store/products/middlewares.ts index 2d1265b15b..bc4a46f4fe 100644 --- a/packages/medusa/src/api/store/products/middlewares.ts +++ b/packages/medusa/src/api/store/products/middlewares.ts @@ -14,6 +14,7 @@ import { StoreGetProductsParams, StoreGetProductsParamsType, } from "./validators" +import { applyParamsAsFilters } from "../../utils/middlewares/common/apply-params-as-filters" export const storeProductRoutesMiddlewares: MiddlewareRoute[] = [ { @@ -57,6 +58,7 @@ export const storeProductRoutesMiddlewares: MiddlewareRoute[] = [ StoreGetProductsParams, QueryConfig.retrieveProductQueryConfig ), + applyParamsAsFilters({ id: "id" }), filterByValidSalesChannels(), setContext({ stock_location_id: maybeApplyStockLocationId, diff --git a/packages/medusa/src/api/utils/middlewares/common/apply-params-as-filters.ts b/packages/medusa/src/api/utils/middlewares/common/apply-params-as-filters.ts new file mode 100644 index 0000000000..e22005561e --- /dev/null +++ b/packages/medusa/src/api/utils/middlewares/common/apply-params-as-filters.ts @@ -0,0 +1,16 @@ +import { NextFunction } from "express" +import { MedusaRequest } from "../../../../types/routing" + +export function applyParamsAsFilters(mappings: { + [param: string]: string +}) { + return async (req: MedusaRequest, _, next: NextFunction) => { + for (const [param, paramValue] of Object.entries(req.params)) { + if (mappings[param]) { + req.filterableFields[mappings[param]] = paramValue + } + } + + return next() + } +} diff --git a/packages/medusa/src/api/utils/middlewares/products/filter-by-valid-sales-channels.ts b/packages/medusa/src/api/utils/middlewares/products/filter-by-valid-sales-channels.ts index b4faa4b92c..4227855d2b 100644 --- a/packages/medusa/src/api/utils/middlewares/products/filter-by-valid-sales-channels.ts +++ b/packages/medusa/src/api/utils/middlewares/products/filter-by-valid-sales-channels.ts @@ -1,8 +1,13 @@ -import { isPresent, MedusaError } from "@medusajs/utils" +import { MedusaError } from "@medusajs/utils" import { NextFunction } from "express" import { AuthenticatedMedusaRequest } from "../../../../types/routing" import { refetchEntity } from "../../refetch-entity" +import { arrayDifference } from "@medusajs/utils" +// Selection of sales channels happens in the following priority: +// - If a publishable API key is passed, we take the sales channels attached to it and filter them down based on the query params +// - If a sales channel id is passed through query params, we use that +// - If not, we use the default sales channel for the store export function filterByValidSalesChannels() { return async (req: AuthenticatedMedusaRequest, _, next: NextFunction) => { const publishableApiKey = req.get("x-publishable-api-key") @@ -10,68 +15,61 @@ export function filterByValidSalesChannels() { | string[] | undefined + if (publishableApiKey) { + const apiKey = await refetchEntity( + "api_key", + { token: publishableApiKey }, + req.scope, + ["id", "sales_channels_link.sales_channel_id"] + ) + + if (!apiKey) { + return next( + new MedusaError( + MedusaError.Types.INVALID_DATA, + `Publishable API key not found` + ) + ) + } + let result = apiKey.sales_channels_link.map( + (link) => link.sales_channel_id + ) + + if (salesChannelIds?.length) { + // If all sales channel ids are not in the publishable key, we throw an error + const uniqueInParams = arrayDifference(salesChannelIds, result) + if (uniqueInParams.length) { + return next( + new MedusaError( + MedusaError.Types.INVALID_DATA, + `Requested sales channel is not part of the publishable key mappings` + ) + ) + } + result = salesChannelIds + } + + req.filterableFields.sales_channel_id = result + return next() + } + + if (salesChannelIds?.length) { + req.filterableFields.sales_channel_id = salesChannelIds + return next() + } + const store = await refetchEntity("stores", {}, req.scope, [ "default_sales_channel_id", ]) if (!store) { - try { - throw new MedusaError(MedusaError.Types.INVALID_DATA, `Store not found`) - } catch (e) { - return next(e) - } - } - // Always set sales channels in the following priority - // - any existing sales chennel ids passed through query params - // - if none, we set the default sales channel - req.filterableFields.sales_channel_id = salesChannelIds ?? [ - store.default_sales_channel_id, - ] - - // Return early if no publishable keys are found - if (!isPresent(publishableApiKey)) { - return next() + return next( + new MedusaError(MedusaError.Types.INVALID_DATA, `Store not found`) + ) } - // When publishable keys are present, we fetch for all sales chennels attached - // to the publishable key and validate the sales channel filter against it - const apiKey = await refetchEntity( - "api_key", - { token: publishableApiKey }, - req.scope, - ["id", "sales_channels_link.sales_channel_id"] - ) - - if (!apiKey) { - try { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Publishable API key not found` - ) - } catch (e) { - return next(e) - } - } - - const validSalesChannelIds = apiKey.sales_channels_link.map( - (link) => link.sales_channel_id - ) - - const invalidSalesChannelIds = (salesChannelIds || []).filter( - (id) => !validSalesChannelIds.includes(id) - ) - - if (invalidSalesChannelIds.length) { - try { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Invalid sales channel filters provided - ${invalidSalesChannelIds.join( - ", " - )}` - ) - } catch (e) { - return next(e) - } + if (store.default_sales_channel_id) { + req.filterableFields.sales_channel_id = [store.default_sales_channel_id] } return next() diff --git a/packages/modules/api-key/src/migrations/.snapshot-medusa-api-key.json b/packages/modules/api-key/src/migrations/.snapshot-medusa-api-key.json index 5f7a4ea5a3..285509c8b7 100644 --- a/packages/modules/api-key/src/migrations/.snapshot-medusa-api-key.json +++ b/packages/modules/api-key/src/migrations/.snapshot-medusa-api-key.json @@ -90,6 +90,17 @@ "default": "now()", "mappedType": "datetime" }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, "revoked_by": { "name": "revoked_by", "type": "text", diff --git a/packages/modules/api-key/src/migrations/Migration20240604080145.ts b/packages/modules/api-key/src/migrations/Migration20240604080145.ts new file mode 100644 index 0000000000..f4270b1384 --- /dev/null +++ b/packages/modules/api-key/src/migrations/Migration20240604080145.ts @@ -0,0 +1,13 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20240604080145 extends Migration { + + async up(): Promise { + this.addSql('alter table if exists "api_key" add column if not exists "updated_at" timestamptz not null default now();'); + } + + async down(): Promise { + this.addSql('alter table if exists "api_key" drop column if exists "updated_at";'); + } + +} diff --git a/packages/modules/api-key/src/models/api-key.ts b/packages/modules/api-key/src/models/api-key.ts index c62b847a5a..9c3b3e3aba 100644 --- a/packages/modules/api-key/src/models/api-key.ts +++ b/packages/modules/api-key/src/models/api-key.ts @@ -65,6 +65,14 @@ export default class ApiKey { }) created_at: Date + @Property({ + onCreate: () => new Date(), + onUpdate: () => new Date(), + columnType: "timestamptz", + defaultRaw: "now()", + }) + updated_at?: Date + @Property({ columnType: "text", nullable: true }) revoked_by: string | null = null