fix: Several fixes to store product endpoints, moved several test suites to HTTP (#7601)

* chore: Move publishable api key tests to HTTP

* chore: Move store tests to HTTP folder

* fix: Add tests for store products, fix several bugs around publishable keys
This commit is contained in:
Stevche Radevski
2024-06-04 21:00:07 +02:00
committed by GitHub
parent d104d1a256
commit e44fe78b96
24 changed files with 2301 additions and 2763 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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",
}),
])
}
)
})
})
})
},
})

View File

@@ -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&currency_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&region_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&region_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)
}
})
})
})

View File

@@ -0,0 +1,42 @@
import { HttpTypes } from "@medusajs/types"
export const getProductFixture = (
overrides: Partial<HttpTypes.AdminProduct>
) => ({
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 ?? {}),
})

View File

@@ -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",
}),
])
)
})
})
})
},
})

View File

@@ -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.`
// )
// }
// })
// })
// })

View File

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

View File

@@ -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&currency_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&region_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&region_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
)
})
})
},
})

View File

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

View File

@@ -8,54 +8,13 @@ jest.setTimeout(60000)
medusaIntegrationTestRunner({
testSuite: ({ dbConnection, getContainer, api }) => {
beforeAll(() => {})
beforeEach(async () => {
const container = getContainer()
await createAdminUser(dbConnection, adminHeaders, container)
})
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}`,
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.sales_channel).toBeTruthy()
expect(response.data.sales_channel).toEqual(
expect.objectContaining({
id: expect.any(String),
name: salesChannel.name,
description: salesChannel.description,
created_at: expect.any(String),
updated_at: expect.any(String),
})
)
})
})
describe("GET /admin/sales-channels", () => {
let salesChannel1
let salesChannel2
beforeEach(async () => {
const container = getContainer()
await createAdminUser(dbConnection, adminHeaders, container)
salesChannel1 = (
await api.post(
"/admin/sales-channels",
@@ -79,6 +38,28 @@ medusaIntegrationTestRunner({
).data.sales_channel
})
describe("GET /admin/sales-channels/:id", () => {
it("should retrieve the requested sales channel", async () => {
const response = await api.get(
`/admin/sales-channels/${salesChannel1.id}`,
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.sales_channel).toBeTruthy()
expect(response.data.sales_channel).toEqual(
expect.objectContaining({
id: expect.any(String),
name: salesChannel1.name,
description: salesChannel1.description,
created_at: expect.any(String),
updated_at: expect.any(String),
})
)
})
})
describe("GET /admin/sales-channels", () => {
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

View File

@@ -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",
}),
])
})
})
})
},
})

View File

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

View File

@@ -153,6 +153,7 @@ export function medusaIntegrationTestRunner({
let isFirstTime = true
const beforeAll_ = async () => {
console.log(`Creating database ${dbName}`)
await dbUtils.create(dbName)
try {

View File

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

View File

@@ -5,6 +5,7 @@ export const defaultAdminApiKeyFields = [
"redacted",
"type",
"last_used_at",
"updated_at",
"created_at",
"created_by",
"revoked_at",

View File

@@ -11,7 +11,7 @@ export const AdminGetApiKeyParams = createSelectParams()
export type AdminGetApiKeysParamsType = z.infer<typeof AdminGetApiKeysParams>
export const AdminGetApiKeysParams = createFindParams({
offset: 0,
limit: 50,
limit: 20,
}).merge(
z.object({
q: z.string().optional(),

View File

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

View File

@@ -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<StoreGetProductsParamsType>,
@@ -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 || [])
}

View File

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

View File

@@ -0,0 +1,16 @@
import { NextFunction } from "express"
import { MedusaRequest } from "../../../../types/routing"
export function applyParamsAsFilters<TFilter extends object>(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()
}
}

View File

@@ -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,31 +15,7 @@ export function filterByValidSalesChannels() {
| string[]
| undefined
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()
}
// When publishable keys are present, we fetch for all sales chennels attached
// to the publishable key and validate the sales channel filter against it
if (publishableApiKey) {
const apiKey = await refetchEntity(
"api_key",
{ token: publishableApiKey },
@@ -43,35 +24,52 @@ export function filterByValidSalesChannels() {
)
if (!apiKey) {
try {
throw new MedusaError(
return next(
new MedusaError(
MedusaError.Types.INVALID_DATA,
`Publishable API key not found`
)
} catch (e) {
return next(e)
)
}
}
const validSalesChannelIds = apiKey.sales_channels_link.map(
let result = 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(
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,
`Invalid sales channel filters provided - ${invalidSalesChannelIds.join(
", "
)}`
`Requested sales channel is not part of the publishable key mappings`
)
)
} catch (e) {
return next(e)
}
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) {
return next(
new MedusaError(MedusaError.Types.INVALID_DATA, `Store not found`)
)
}
if (store.default_sales_channel_id) {
req.filterableFields.sales_channel_id = [store.default_sales_channel_id]
}
return next()

View File

@@ -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",

View File

@@ -0,0 +1,13 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20240604080145 extends Migration {
async up(): Promise<void> {
this.addSql('alter table if exists "api_key" add column if not exists "updated_at" timestamptz not null default now();');
}
async down(): Promise<void> {
this.addSql('alter table if exists "api_key" drop column if exists "updated_at";');
}
}

View File

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