** What
- Allow auto-loaded Medusa files to export a config object.
- Currently supports isDisabled to control loading.
- new instance `FeatureFlag` exported by `@medusajs/framework/utils`
- `feature-flags` is now a supported folder for medusa projects, modules, providers and plugins. They will be loaded and added to `FeatureFlag`
** Why
- Enables conditional loading of routes, migrations, jobs, subscribers, workflows, and other files based on feature flags.
```ts
// /src/feature-flags
import { FlagSettings } from "@medusajs/framework/feature-flags"
const CustomFeatureFlag: FlagSettings = {
key: "custom_feature",
default_val: false,
env_key: "FF_MY_CUSTOM_FEATURE",
description: "Enable xyz",
}
export default CustomFeatureFlag
```
```ts
// /src/modules/my-custom-module/migration/Migration20250822135845.ts
import { FeatureFlag } from "@medusajs/framework/utils"
export class Migration20250822135845 extends Migration {
override async up(){ }
override async down(){ }
}
defineFileConfig({
isDisabled: () => !FeatureFlag.isFeatureEnabled("custom_feature")
})
```
371 lines
12 KiB
TypeScript
371 lines
12 KiB
TypeScript
import {
|
|
createFulfillmentWorkflow,
|
|
createFulfillmentWorkflowId,
|
|
createShipmentWorkflow,
|
|
createShipmentWorkflowId,
|
|
updateFulfillmentWorkflow,
|
|
updateFulfillmentWorkflowId,
|
|
} from "@medusajs/core-flows"
|
|
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
|
|
import {
|
|
IFulfillmentModuleService,
|
|
MedusaContainer,
|
|
StockLocationDTO,
|
|
} from "@medusajs/types"
|
|
import { ContainerRegistrationKeys, Modules } from "@medusajs/utils"
|
|
import {
|
|
generateCreateFulfillmentData,
|
|
generateCreateShippingOptionsData,
|
|
} from "../fixtures"
|
|
|
|
jest.setTimeout(50000)
|
|
|
|
const providerId = "manual_test-provider"
|
|
|
|
medusaIntegrationTestRunner({
|
|
env: {},
|
|
testSuite: ({ getContainer }) => {
|
|
describe("Workflows: Fulfillment", () => {
|
|
let location: StockLocationDTO
|
|
let appContainer: MedusaContainer
|
|
let service: IFulfillmentModuleService
|
|
|
|
beforeAll(async () => {
|
|
appContainer = getContainer()
|
|
service = appContainer.resolve(Modules.FULFILLMENT)
|
|
})
|
|
|
|
beforeEach(async () => {
|
|
const stockLocationService = appContainer.resolve(
|
|
Modules.STOCK_LOCATION
|
|
)
|
|
|
|
location = await stockLocationService.createStockLocations({
|
|
name: "Test Location",
|
|
address: {
|
|
address_1: "Test Address",
|
|
address_2: "tttest",
|
|
city: "Test City",
|
|
country_code: "us",
|
|
postal_code: "12345",
|
|
metadata: { email: "test@mail.com" },
|
|
},
|
|
metadata: { custom_location: "yes" },
|
|
})
|
|
})
|
|
|
|
describe("createFulfillmentWorkflow", () => {
|
|
describe("invoke", () => {
|
|
it("should get stock location", async () => {
|
|
const workflow = createFulfillmentWorkflow(appContainer)
|
|
|
|
const link = appContainer.resolve(
|
|
ContainerRegistrationKeys.REMOTE_LINK
|
|
)
|
|
|
|
const shippingProfile = await service.createShippingProfiles({
|
|
name: "test",
|
|
type: "default",
|
|
})
|
|
|
|
const fulfillmentSet = await service.createFulfillmentSets({
|
|
name: "test",
|
|
type: "test-type",
|
|
})
|
|
|
|
await link.create({
|
|
[Modules.STOCK_LOCATION]: {
|
|
stock_location_id: location.id,
|
|
},
|
|
[Modules.FULFILLMENT]: {
|
|
fulfillment_set_id: fulfillmentSet.id,
|
|
},
|
|
})
|
|
|
|
const serviceZone = await service.createServiceZones({
|
|
name: "test",
|
|
fulfillment_set_id: fulfillmentSet.id,
|
|
})
|
|
|
|
const shippingOption = await service.createShippingOptions(
|
|
generateCreateShippingOptionsData({
|
|
provider_id: providerId,
|
|
service_zone_id: serviceZone.id,
|
|
shipping_profile_id: shippingProfile.id,
|
|
})
|
|
)
|
|
|
|
const data = generateCreateFulfillmentData({
|
|
provider_id: providerId,
|
|
shipping_option_id: shippingOption.id,
|
|
order_id: "fake-order",
|
|
location_id: location.id,
|
|
})
|
|
|
|
const { transaction } = await workflow.run({
|
|
input: data,
|
|
throwOnError: true,
|
|
})
|
|
|
|
expect(
|
|
transaction.context.invoke["get-location"].output.output
|
|
).toEqual({
|
|
id: expect.any(String),
|
|
created_at: expect.any(Date),
|
|
updated_at: expect.any(Date),
|
|
name: "Test Location",
|
|
address: {
|
|
id: expect.any(String),
|
|
address_1: "Test Address",
|
|
address_2: "tttest",
|
|
city: "Test City",
|
|
country_code: "us",
|
|
postal_code: "12345",
|
|
metadata: { email: "test@mail.com" },
|
|
phone: null,
|
|
province: null,
|
|
},
|
|
metadata: { custom_location: "yes" },
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("compensation", () => {
|
|
it("should cancel created fulfillment if step following step throws error", async () => {
|
|
const workflow = createFulfillmentWorkflow(appContainer)
|
|
|
|
workflow.appendAction("throw", createFulfillmentWorkflowId, {
|
|
invoke: async function failStep() {
|
|
throw new Error(
|
|
`Failed to do something after creating fulfillment`
|
|
)
|
|
},
|
|
})
|
|
|
|
const shippingProfile = await service.createShippingProfiles({
|
|
name: "test",
|
|
type: "default",
|
|
})
|
|
|
|
const fulfillmentSet = await service.createFulfillmentSets({
|
|
name: "test",
|
|
type: "test-type",
|
|
})
|
|
|
|
const serviceZone = await service.createServiceZones({
|
|
name: "test",
|
|
fulfillment_set_id: fulfillmentSet.id,
|
|
})
|
|
|
|
const shippingOption = await service.createShippingOptions(
|
|
generateCreateShippingOptionsData({
|
|
provider_id: providerId,
|
|
service_zone_id: serviceZone.id,
|
|
shipping_profile_id: shippingProfile.id,
|
|
})
|
|
)
|
|
|
|
const data = generateCreateFulfillmentData({
|
|
provider_id: providerId,
|
|
shipping_option_id: shippingOption.id,
|
|
order_id: "fake-order",
|
|
location_id: location.id,
|
|
})
|
|
const { errors } = await workflow.run({
|
|
input: data,
|
|
throwOnError: false,
|
|
})
|
|
|
|
expect(errors).toEqual([
|
|
{
|
|
action: "throw",
|
|
handlerType: "invoke",
|
|
error: expect.objectContaining({
|
|
message: `Failed to do something after creating fulfillment`,
|
|
}),
|
|
},
|
|
])
|
|
|
|
const fulfillments = await service.listFulfillments()
|
|
|
|
expect(fulfillments.filter((f) => !!f.canceled_at)).toHaveLength(1)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("updateFulfillmentWorkflow", () => {
|
|
describe("compensation", () => {
|
|
it("should rollback updated fulfillment if step following step throws error", async () => {
|
|
const workflow = updateFulfillmentWorkflow(appContainer)
|
|
|
|
workflow.appendAction("throw", updateFulfillmentWorkflowId, {
|
|
invoke: async function failStep() {
|
|
throw new Error(
|
|
`Failed to do something after updating fulfillment`
|
|
)
|
|
},
|
|
})
|
|
|
|
const shippingProfile = await service.createShippingProfiles({
|
|
name: "test",
|
|
type: "default",
|
|
})
|
|
|
|
const fulfillmentSet = await service.createFulfillmentSets({
|
|
name: "test",
|
|
type: "test-type",
|
|
})
|
|
|
|
const serviceZone = await service.createServiceZones({
|
|
name: "test",
|
|
fulfillment_set_id: fulfillmentSet.id,
|
|
})
|
|
|
|
const shippingOption = await service.createShippingOptions(
|
|
generateCreateShippingOptionsData({
|
|
provider_id: providerId,
|
|
service_zone_id: serviceZone.id,
|
|
shipping_profile_id: shippingProfile.id,
|
|
})
|
|
)
|
|
|
|
const data = generateCreateFulfillmentData({
|
|
order_id: "fake-order",
|
|
provider_id: providerId,
|
|
shipping_option_id: shippingOption.id,
|
|
location_id: location.id,
|
|
})
|
|
|
|
const fulfillment = await service.createFulfillment({
|
|
...data,
|
|
location,
|
|
})
|
|
|
|
const date = new Date()
|
|
const { errors } = await workflow.run({
|
|
input: {
|
|
id: fulfillment.id,
|
|
shipped_at: date,
|
|
packed_at: date,
|
|
location_id: location.id,
|
|
},
|
|
throwOnError: false,
|
|
})
|
|
|
|
expect(errors).toEqual([
|
|
{
|
|
action: "throw",
|
|
handlerType: "invoke",
|
|
error: expect.objectContaining({
|
|
message: `Failed to do something after updating fulfillment`,
|
|
}),
|
|
},
|
|
])
|
|
|
|
const fulfillmentAfterRollback = await service.retrieveFulfillment(
|
|
fulfillment.id
|
|
)
|
|
|
|
expect(fulfillmentAfterRollback).toEqual(
|
|
expect.objectContaining({
|
|
location_id: data.location_id,
|
|
shipped_at: data.shipped_at,
|
|
packed_at: data.packed_at,
|
|
})
|
|
)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("createShipmentWorkflow", () => {
|
|
describe("compensation", () => {
|
|
it("should rollback shipment workflow if following step throws error", async () => {
|
|
const workflow = createShipmentWorkflow(appContainer)
|
|
|
|
workflow.appendAction("throw", createShipmentWorkflowId, {
|
|
invoke: async function failStep() {
|
|
throw new Error(
|
|
`Failed to do something after creating shipment`
|
|
)
|
|
},
|
|
})
|
|
|
|
const shippingProfile = await service.createShippingProfiles({
|
|
name: "test",
|
|
type: "default",
|
|
})
|
|
|
|
const fulfillmentSet = await service.createFulfillmentSets({
|
|
name: "test",
|
|
type: "test-type",
|
|
})
|
|
|
|
const serviceZone = await service.createServiceZones({
|
|
name: "test",
|
|
fulfillment_set_id: fulfillmentSet.id,
|
|
})
|
|
|
|
const shippingOption = await service.createShippingOptions(
|
|
generateCreateShippingOptionsData({
|
|
provider_id: providerId,
|
|
service_zone_id: serviceZone.id,
|
|
shipping_profile_id: shippingProfile.id,
|
|
})
|
|
)
|
|
|
|
const data = generateCreateFulfillmentData({
|
|
order_id: "fake-order",
|
|
provider_id: providerId,
|
|
shipping_option_id: shippingOption.id,
|
|
location_id: location.id,
|
|
})
|
|
|
|
const fulfillment = await service.createFulfillment({
|
|
...data,
|
|
location,
|
|
labels: [],
|
|
})
|
|
|
|
const { errors } = await workflow.run({
|
|
input: {
|
|
id: fulfillment.id,
|
|
labels: [
|
|
{
|
|
tracking_number: "test-tracking-number",
|
|
tracking_url: "test-tracking-url",
|
|
label_url: "test-label-url",
|
|
},
|
|
],
|
|
},
|
|
throwOnError: false,
|
|
})
|
|
|
|
expect(errors).toEqual([
|
|
{
|
|
action: "throw",
|
|
handlerType: "invoke",
|
|
error: expect.objectContaining({
|
|
message: `Failed to do something after creating shipment`,
|
|
}),
|
|
},
|
|
])
|
|
|
|
const fulfillmentAfterRollback = await service.retrieveFulfillment(
|
|
fulfillment.id,
|
|
{ select: ["shipped_at"], relations: ["labels"] }
|
|
)
|
|
|
|
expect(fulfillmentAfterRollback).toEqual(
|
|
expect.objectContaining({
|
|
shipped_at: null,
|
|
// TODO: the revert isn't handling deleting the labels. This needs to be handled uniformly across.
|
|
// labels: [],
|
|
})
|
|
)
|
|
})
|
|
})
|
|
})
|
|
})
|
|
},
|
|
})
|