feat(core-flows,modules-sdk,types,medusa,link-modules): adds variant <> inventory item link endpoints (#7576)

what:

- adds variant inventory link management endpoints:
```
Link inventory item to variant
POST /products/:id/variants/:vid/inventory-items

Update variant's inventory item link
POST /products/:id/variants/:vid/inventory-items/:iid

Unlink variant's inventory item
DELETE /products/:id/variants/:vid/inventory-items/:iid
```

- a batch endpoint that does the above 3 across variants
```
POST /products/:id/variants/inventory-items
```
This commit is contained in:
Riqwan Thamir
2024-06-03 18:23:29 +00:00
committed by GitHub
parent 122186a78d
commit ecfbfcc707
23 changed files with 1279 additions and 126 deletions
@@ -1,8 +1,8 @@
import {
createAdminUser,
adminHeaders,
} from "../../../../helpers/create-admin-user"
import { medusaIntegrationTestRunner } from "medusa-test-utils"
import {
adminHeaders,
createAdminUser,
} from "../../../../helpers/create-admin-user"
jest.setTimeout(50000)
@@ -2870,6 +2870,298 @@ 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[0]).toEqual(
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)
expect(res.data.variant.inventory_items).toEqual([
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)
expect(res.data.parent.inventory_items).toEqual([])
})
})
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[0]).toEqual(
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[0]).toEqual(
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(0)
})
})
})
},
})
@@ -0,0 +1,313 @@
import {
createLinksWorkflow,
createLinksWorkflowId,
dismissLinksWorkflow,
dismissLinksWorkflowId,
updateLinksWorkflow,
updateLinksWorkflowId,
} from "@medusajs/core-flows"
import { Modules } from "@medusajs/utils"
import { medusaIntegrationTestRunner } from "medusa-test-utils/dist"
import {
adminHeaders,
createAdminUser,
} from "../../../helpers/create-admin-user"
jest.setTimeout(50000)
medusaIntegrationTestRunner({
env: {},
testSuite: ({ getContainer, api, dbConnection }) => {
describe("Workflows: Common", () => {
let appContainer
let product
let variant
beforeAll(async () => {
appContainer = getContainer()
})
beforeEach(async () => {
await createAdminUser(dbConnection, adminHeaders, getContainer())
product = (
await api.post(
"/admin/products",
{
title: "product 1",
variants: [
{
title: "variant 1",
prices: [{ currency_code: "usd", amount: 100 }],
},
],
},
adminHeaders
)
).data.product
variant = product.variants[0]
})
describe("createLinksWorkflow", () => {
describe("compensation", () => {
it("should dismiss links when step throws an error", async () => {
const workflow = createLinksWorkflow(appContainer)
const workflowId = createLinksWorkflowId
const inventoryItem = (
await api.post(
`/admin/inventory-items`,
{ sku: "12345" },
adminHeaders
)
).data.inventory_item
workflow.appendAction("throw", workflowId, {
invoke: async function failStep() {
throw new Error(`Fail`)
},
})
const { errors } = await workflow.run({
input: [
{
[Modules.PRODUCT]: { variant_id: variant.id },
[Modules.INVENTORY]: { inventory_item_id: inventoryItem.id },
data: { required_quantity: 10 },
},
],
throwOnError: false,
})
expect(errors).toEqual([
{
action: "throw",
handlerType: "invoke",
error: expect.objectContaining({
message: `Fail`,
}),
},
])
const updatedVariant = (
await api.get(
`/admin/products/${product.id}/variants/${variant.id}?fields=inventory_items.inventory.*,inventory_items.*`,
adminHeaders
)
).data.variant
expect(updatedVariant.inventory_items).toHaveLength(0)
})
})
})
describe("updateLinksWorkflow", () => {
describe("compensation", () => {
it("should revert link data when step throws an error", async () => {
const workflow = updateLinksWorkflow(appContainer)
const workflowId = updateLinksWorkflowId
const originalQuantity = 5
const newQuantity = 10
const inventoryItem = (
await api.post(
`/admin/inventory-items`,
{ sku: "12345" },
adminHeaders
)
).data.inventory_item
await api.post(
`/admin/products/${product.id}/variants/${variant.id}/inventory-items`,
{
inventory_item_id: inventoryItem.id,
required_quantity: originalQuantity,
},
adminHeaders
)
workflow.appendAction("throw", workflowId, {
invoke: async function failStep() {
throw new Error(`Fail`)
},
})
const { errors } = await workflow.run({
input: [
{
[Modules.PRODUCT]: { variant_id: variant.id },
[Modules.INVENTORY]: { inventory_item_id: inventoryItem.id },
data: { required_quantity: newQuantity },
},
],
throwOnError: false,
})
expect(errors).toEqual([
{
action: "throw",
handlerType: "invoke",
error: expect.objectContaining({
message: `Fail`,
}),
},
])
const updatedVariant = (
await api.get(
`/admin/products/${product.id}/variants/${variant.id}?fields=inventory_items.inventory.*,inventory_items.*`,
adminHeaders
)
).data.variant
expect(updatedVariant.inventory_items).toEqual([
expect.objectContaining({
required_quantity: originalQuantity,
}),
])
})
it("should throw an error when a link is not found", async () => {
const workflow = updateLinksWorkflow(appContainer)
const workflowId = updateLinksWorkflowId
workflow.appendAction("throw", workflowId, {
invoke: async function failStep() {
throw new Error(`Fail`)
},
})
const { errors } = await workflow.run({
input: [
{
[Modules.PRODUCT]: { variant_id: variant.id },
[Modules.INVENTORY]: {
inventory_item_id: "does-not-exist-id",
},
data: { required_quantity: 10 },
},
],
throwOnError: false,
})
expect(errors).toEqual([
{
action: "update-remote-links-step",
handlerType: "invoke",
error: expect.objectContaining({
message: `Could not find all existing links from data`,
}),
},
])
})
})
})
describe("dismissLinksWorkflow", () => {
describe("compensation", () => {
it("should recreate dismissed links when step throws an error", async () => {
const originalQuantity = 10
const workflow = dismissLinksWorkflow(appContainer)
const workflowId = dismissLinksWorkflowId
const inventoryItem = (
await api.post(
`/admin/inventory-items`,
{ sku: "12345" },
adminHeaders
)
).data.inventory_item
await api.post(
`/admin/products/${product.id}/variants/${variant.id}/inventory-items`,
{
inventory_item_id: inventoryItem.id,
required_quantity: originalQuantity,
},
adminHeaders
)
workflow.appendAction("throw", workflowId, {
invoke: async function failStep() {
throw new Error(`Fail`)
},
})
const { errors } = await workflow.run({
input: [
{
[Modules.PRODUCT]: { variant_id: variant.id },
[Modules.INVENTORY]: { inventory_item_id: inventoryItem.id },
},
],
throwOnError: false,
})
expect(errors).toEqual([
{
action: "throw",
handlerType: "invoke",
error: expect.objectContaining({
message: `Fail`,
}),
},
])
const updatedVariant = (
await api.get(
`/admin/products/${product.id}/variants/${variant.id}?fields=inventory_items.inventory.*,inventory_items.*`,
adminHeaders
)
).data.variant
expect(updatedVariant.inventory_items).toEqual([
expect.objectContaining({
required_quantity: originalQuantity,
}),
])
})
it("should pass dismiss step if link not found if next step throws error", async () => {
const workflow = dismissLinksWorkflow(appContainer)
const workflowId = dismissLinksWorkflowId
workflow.appendAction("throw", workflowId, {
invoke: async function failStep() {
throw new Error(`Fail`)
},
})
const { errors } = await workflow.run({
input: [
{
[Modules.PRODUCT]: { variant_id: variant.id },
[Modules.INVENTORY]: {
inventory_item_id: "does-not-exist-id",
},
},
],
throwOnError: false,
})
expect(errors).toEqual([
{
action: "throw",
handlerType: "invoke",
error: expect.objectContaining({
message: `Fail`,
}),
},
])
const updatedVariant = (
await api.get(
`/admin/products/${product.id}/variants/${variant.id}?fields=inventory_items.inventory.*,inventory_items.*`,
adminHeaders
)
).data.variant
expect(updatedVariant.inventory_items).toEqual([])
})
})
})
})
},
})