committed by
GitHub
parent
ae33f4825f
commit
f12299deb1
@@ -0,0 +1,435 @@
|
||||
import { OrchestratorBuilder } from "../../transaction/orchestrator-builder"
|
||||
|
||||
describe("OrchestratorBuilder", () => {
|
||||
let builder: OrchestratorBuilder
|
||||
|
||||
beforeEach(() => {
|
||||
builder = new OrchestratorBuilder()
|
||||
})
|
||||
|
||||
it("should load a TransactionStepsDefinition", () => {
|
||||
builder.load({ action: "foo" })
|
||||
expect(builder.build()).toEqual({
|
||||
action: "foo",
|
||||
})
|
||||
})
|
||||
|
||||
it("should add a new action after the last action set", () => {
|
||||
builder.addAction("foo")
|
||||
|
||||
expect(builder.build()).toEqual({
|
||||
action: "foo",
|
||||
})
|
||||
|
||||
builder.addAction("bar")
|
||||
|
||||
expect(builder.build()).toEqual({
|
||||
action: "foo",
|
||||
next: {
|
||||
action: "bar",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should replace an action by another keeping its next steps", () => {
|
||||
builder.addAction("foo").addAction("axe").replaceAction("foo", "bar")
|
||||
expect(builder.build()).toEqual({
|
||||
action: "bar",
|
||||
next: {
|
||||
action: "axe",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should insert a new action before an existing action", () => {
|
||||
builder.addAction("foo").addAction("bar").insertActionBefore("bar", "axe")
|
||||
|
||||
expect(builder.build()).toEqual({
|
||||
action: "foo",
|
||||
next: {
|
||||
action: "axe",
|
||||
next: {
|
||||
action: "bar",
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should insert a new action after an existing action", () => {
|
||||
builder.addAction("foo").addAction("axe").insertActionAfter("foo", "bar")
|
||||
|
||||
expect(builder.build()).toEqual({
|
||||
action: "foo",
|
||||
next: {
|
||||
action: "bar",
|
||||
next: {
|
||||
action: "axe",
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should move an existing action and its next steps to another place. the destination will become next steps of the final branch", () => {
|
||||
builder
|
||||
.addAction("foo")
|
||||
.addAction("bar")
|
||||
.addAction("axe")
|
||||
.addAction("zzz")
|
||||
.moveAction("axe", "foo")
|
||||
|
||||
expect(builder.build()).toEqual({
|
||||
action: "axe",
|
||||
next: {
|
||||
action: "zzz",
|
||||
next: {
|
||||
action: "foo",
|
||||
next: {
|
||||
action: "bar",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should merge two action to run in parallel", () => {
|
||||
builder
|
||||
.addAction("foo")
|
||||
.addAction("bar")
|
||||
.addAction("axe")
|
||||
.mergeActions("foo", "axe")
|
||||
|
||||
expect(builder.build()).toEqual({
|
||||
next: [
|
||||
{
|
||||
action: "foo",
|
||||
next: { action: "bar" },
|
||||
},
|
||||
{ action: "axe" },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("should merge multiple actions to run in parallel", () => {
|
||||
builder
|
||||
.addAction("foo")
|
||||
.addAction("bar")
|
||||
.addAction("axe")
|
||||
.addAction("step")
|
||||
.mergeActions("bar", "axe", "step")
|
||||
|
||||
expect(builder.build()).toEqual({
|
||||
action: "foo",
|
||||
next: [
|
||||
{
|
||||
action: "bar",
|
||||
},
|
||||
{
|
||||
action: "axe",
|
||||
},
|
||||
{
|
||||
action: "step",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("should delete an action", () => {
|
||||
builder.addAction("foo").deleteAction("foo")
|
||||
|
||||
expect(builder.build()).toEqual({})
|
||||
})
|
||||
|
||||
it("should delete an action and keep all the next steps of that branch", () => {
|
||||
builder
|
||||
.addAction("foo")
|
||||
.addAction("bar")
|
||||
.addAction("axe")
|
||||
.deleteAction("bar")
|
||||
|
||||
expect(builder.build()).toEqual({
|
||||
action: "foo",
|
||||
next: {
|
||||
action: "axe",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should delete an action and remove all the next steps of that branch", () => {
|
||||
builder
|
||||
.addAction("foo")
|
||||
.addAction("bar")
|
||||
.addAction("axe")
|
||||
.addAction("step")
|
||||
.pruneAction("bar")
|
||||
expect(builder.build()).toEqual({
|
||||
action: "foo",
|
||||
})
|
||||
})
|
||||
|
||||
it("should append a new action to the end of a given action's branch", () => {
|
||||
builder
|
||||
.load({
|
||||
action: "foo",
|
||||
next: [
|
||||
{
|
||||
action: "bar",
|
||||
next: {
|
||||
action: "zzz",
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "axe",
|
||||
},
|
||||
],
|
||||
})
|
||||
.appendAction("step", "bar", { saveResponse: true })
|
||||
|
||||
expect(builder.build()).toEqual({
|
||||
action: "foo",
|
||||
next: [
|
||||
{
|
||||
action: "bar",
|
||||
next: {
|
||||
action: "zzz",
|
||||
next: {
|
||||
action: "step",
|
||||
saveResponse: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "axe",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
describe("Composing Complex Transactions", () => {
|
||||
const loadedFlow = {
|
||||
next: {
|
||||
action: "createProduct",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "attachToSalesChannel",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "createPrices",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "createInventoryItems",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "attachInventoryItems",
|
||||
noCompensation: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
it("should load a transaction and add two steps", () => {
|
||||
const builder = new OrchestratorBuilder(loadedFlow)
|
||||
builder
|
||||
.addAction("step_1", { saveResponse: true })
|
||||
.addAction("step_2", { saveResponse: true })
|
||||
|
||||
expect(builder.build()).toEqual({
|
||||
action: "createProduct",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "attachToSalesChannel",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "createPrices",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "createInventoryItems",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "attachInventoryItems",
|
||||
noCompensation: true,
|
||||
next: {
|
||||
action: "step_1",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "step_2",
|
||||
saveResponse: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should load a transaction, add 2 steps and merge step_1 to run in parallel with createProduct", () => {
|
||||
const builder = new OrchestratorBuilder(loadedFlow)
|
||||
builder
|
||||
.addAction("step_1", { saveResponse: true })
|
||||
.addAction("step_2", { saveResponse: true })
|
||||
.mergeActions("createProduct", "step_1")
|
||||
|
||||
expect(builder.build()).toEqual({
|
||||
next: [
|
||||
{
|
||||
action: "createProduct",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "attachToSalesChannel",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "createPrices",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "createInventoryItems",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "attachInventoryItems",
|
||||
noCompensation: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "step_1",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "step_2",
|
||||
saveResponse: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("should load a transaction, add 2 steps and move 'step_1' and all its next steps to run before 'createPrices'", () => {
|
||||
const builder = new OrchestratorBuilder(loadedFlow)
|
||||
builder
|
||||
.addAction("step_1", { saveResponse: true })
|
||||
.addAction("step_2", { saveResponse: true })
|
||||
.moveAction("step_1", "createPrices")
|
||||
|
||||
expect(builder.build()).toEqual({
|
||||
action: "createProduct",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "attachToSalesChannel",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "step_1",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "step_2",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "createPrices",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "createInventoryItems",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "attachInventoryItems",
|
||||
noCompensation: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should load a transaction, add 2 steps and move 'step_1' to run before 'createPrices' and merge next steps", () => {
|
||||
const builder = new OrchestratorBuilder(loadedFlow)
|
||||
builder
|
||||
.addAction("step_1", { saveResponse: true })
|
||||
.addAction("step_2", { saveResponse: true })
|
||||
.moveAndMergeNextAction("step_1", "createPrices")
|
||||
|
||||
expect(builder.build()).toEqual({
|
||||
action: "createProduct",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "attachToSalesChannel",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "step_1",
|
||||
saveResponse: true,
|
||||
next: [
|
||||
{
|
||||
action: "step_2",
|
||||
saveResponse: true,
|
||||
},
|
||||
{
|
||||
action: "createPrices",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "createInventoryItems",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "attachInventoryItems",
|
||||
noCompensation: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("Fully compose a complex transaction", () => {
|
||||
const builder = new OrchestratorBuilder()
|
||||
builder
|
||||
.addAction("step_1", { saveResponse: true })
|
||||
.addAction("step_2", { saveResponse: true })
|
||||
.addAction("step_3", { saveResponse: true })
|
||||
|
||||
builder.insertActionBefore("step_3", "step_2.5", {
|
||||
saveResponse: false,
|
||||
noCompensation: true,
|
||||
})
|
||||
|
||||
builder.insertActionAfter("step_1", "step_1.1", { saveResponse: true })
|
||||
|
||||
builder.insertActionAfter("step_3", "step_4", { async: false })
|
||||
|
||||
builder
|
||||
.mergeActions("step_2", "step_2.5", "step_3")
|
||||
.addAction("step_5", { noCompensation: true })
|
||||
|
||||
builder.deleteAction("step_3")
|
||||
|
||||
expect(builder.build()).toEqual({
|
||||
action: "step_1",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "step_1.1",
|
||||
saveResponse: true,
|
||||
next: [
|
||||
{
|
||||
action: "step_2",
|
||||
saveResponse: true,
|
||||
},
|
||||
{
|
||||
action: "step_2.5",
|
||||
saveResponse: false,
|
||||
noCompensation: true,
|
||||
},
|
||||
{
|
||||
action: "step_4",
|
||||
async: false,
|
||||
next: {
|
||||
action: "step_5",
|
||||
noCompensation: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,861 @@
|
||||
import {
|
||||
TransactionHandlerType,
|
||||
TransactionOrchestrator,
|
||||
TransactionPayload,
|
||||
TransactionState,
|
||||
TransactionStepsDefinition,
|
||||
} from "../../transaction"
|
||||
|
||||
describe("Transaction Orchestrator", () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("Should follow the flow by calling steps in order with the correct payload", async () => {
|
||||
const mocks = {
|
||||
one: jest.fn().mockImplementation((payload) => {
|
||||
return payload
|
||||
}),
|
||||
two: jest.fn().mockImplementation((payload) => {
|
||||
return payload
|
||||
}),
|
||||
}
|
||||
|
||||
async function handler(
|
||||
actionId: string,
|
||||
functionHandlerType: TransactionHandlerType,
|
||||
payload: TransactionPayload
|
||||
) {
|
||||
const command = {
|
||||
firstMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
mocks.one(payload)
|
||||
},
|
||||
},
|
||||
secondMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
mocks.two(payload)
|
||||
},
|
||||
},
|
||||
}
|
||||
return command[actionId][functionHandlerType](payload)
|
||||
}
|
||||
|
||||
const flow: TransactionStepsDefinition = {
|
||||
next: {
|
||||
action: "firstMethod",
|
||||
next: {
|
||||
action: "secondMethod",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const strategy = new TransactionOrchestrator("transaction-name", flow)
|
||||
|
||||
const transaction = await strategy.beginTransaction(
|
||||
"transaction_id_123",
|
||||
handler,
|
||||
{
|
||||
prop: 123,
|
||||
}
|
||||
)
|
||||
|
||||
await strategy.resume(transaction)
|
||||
|
||||
expect(transaction.transactionId).toBe("transaction_id_123")
|
||||
expect(transaction.getState()).toBe(TransactionState.DONE)
|
||||
|
||||
expect(mocks.one).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: {
|
||||
model_id: "transaction-name",
|
||||
reply_to_topic: "trans:transaction-name",
|
||||
idempotency_key: "transaction_id_123:firstMethod:invoke",
|
||||
action: "firstMethod",
|
||||
action_type: "invoke",
|
||||
attempt: 1,
|
||||
timestamp: expect.any(Number),
|
||||
},
|
||||
data: { prop: 123 },
|
||||
})
|
||||
)
|
||||
|
||||
expect(mocks.two).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: {
|
||||
model_id: "transaction-name",
|
||||
reply_to_topic: "trans:transaction-name",
|
||||
idempotency_key: "transaction_id_123:secondMethod:invoke",
|
||||
action: "secondMethod",
|
||||
action_type: "invoke",
|
||||
attempt: 1,
|
||||
timestamp: expect.any(Number),
|
||||
},
|
||||
data: { prop: 123 },
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("Should resume steps in parallel if 'next' is an array", async () => {
|
||||
const actionOrder: string[] = []
|
||||
async function handler(
|
||||
actionId: string,
|
||||
functionHandlerType: TransactionHandlerType,
|
||||
payload: TransactionPayload
|
||||
) {
|
||||
return actionOrder.push(actionId)
|
||||
}
|
||||
|
||||
const flow: TransactionStepsDefinition = {
|
||||
next: [
|
||||
{
|
||||
action: "one",
|
||||
},
|
||||
{
|
||||
action: "two",
|
||||
next: {
|
||||
action: "four",
|
||||
next: {
|
||||
action: "six",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "three",
|
||||
next: {
|
||||
action: "five",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const strategy = new TransactionOrchestrator("transaction-name", flow)
|
||||
|
||||
const transaction = await strategy.beginTransaction(
|
||||
"transaction_id_123",
|
||||
handler
|
||||
)
|
||||
|
||||
await strategy.resume(transaction)
|
||||
expect(actionOrder).toEqual(["one", "two", "three", "four", "five", "six"])
|
||||
})
|
||||
|
||||
it("Should not execute next steps when a step fails", async () => {
|
||||
const actionOrder: string[] = []
|
||||
async function handler(
|
||||
actionId: string,
|
||||
functionHandlerType: TransactionHandlerType,
|
||||
payload: TransactionPayload
|
||||
) {
|
||||
if (functionHandlerType === TransactionHandlerType.INVOKE) {
|
||||
actionOrder.push(actionId)
|
||||
}
|
||||
|
||||
if (TransactionHandlerType.INVOKE && actionId === "three") {
|
||||
throw new Error()
|
||||
}
|
||||
}
|
||||
|
||||
const flow: TransactionStepsDefinition = {
|
||||
next: [
|
||||
{
|
||||
action: "one",
|
||||
},
|
||||
{
|
||||
action: "two",
|
||||
next: {
|
||||
action: "four",
|
||||
next: {
|
||||
action: "six",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "three",
|
||||
maxRetries: 0,
|
||||
next: {
|
||||
action: "five",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const strategy = new TransactionOrchestrator("transaction-name", flow)
|
||||
|
||||
const transaction = await strategy.beginTransaction(
|
||||
"transaction_id_123",
|
||||
handler
|
||||
)
|
||||
|
||||
await strategy.resume(transaction)
|
||||
expect(actionOrder).toEqual(["one", "two", "three"])
|
||||
})
|
||||
|
||||
it("Should store invoke's step response by default or if flag 'saveResponse' is set to true and ignore it if set to false", async () => {
|
||||
const mocks = {
|
||||
one: jest.fn().mockImplementation((data) => {
|
||||
return { abc: 1234 }
|
||||
}),
|
||||
two: jest.fn().mockImplementation((data) => {
|
||||
return { def: "567" }
|
||||
}),
|
||||
three: jest.fn().mockImplementation((data, context) => {
|
||||
return { end: true, onePropAbc: context.invoke.firstMethod.abc }
|
||||
}),
|
||||
four: jest.fn().mockImplementation((data) => {
|
||||
return null
|
||||
}),
|
||||
}
|
||||
|
||||
async function handler(
|
||||
actionId: string,
|
||||
functionHandlerType: TransactionHandlerType,
|
||||
payload: TransactionPayload
|
||||
) {
|
||||
const command = {
|
||||
firstMethod: {
|
||||
[TransactionHandlerType.INVOKE]: (data) => {
|
||||
return mocks.one(data)
|
||||
},
|
||||
},
|
||||
secondMethod: {
|
||||
[TransactionHandlerType.INVOKE]: (data) => {
|
||||
return mocks.two(data)
|
||||
},
|
||||
},
|
||||
thirdMethod: {
|
||||
[TransactionHandlerType.INVOKE]: (data, context) => {
|
||||
return mocks.three(data, context)
|
||||
},
|
||||
},
|
||||
fourthMethod: {
|
||||
[TransactionHandlerType.INVOKE]: (data) => {
|
||||
return mocks.four(data)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return command[actionId][functionHandlerType](
|
||||
payload.data,
|
||||
payload.context
|
||||
)
|
||||
}
|
||||
|
||||
const flow: TransactionStepsDefinition = {
|
||||
next: {
|
||||
action: "firstMethod",
|
||||
next: {
|
||||
action: "secondMethod",
|
||||
next: {
|
||||
action: "thirdMethod",
|
||||
next: {
|
||||
action: "fourthMethod",
|
||||
saveResponse: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const strategy = new TransactionOrchestrator("transaction-name", flow)
|
||||
|
||||
const transaction = await strategy.beginTransaction(
|
||||
"transaction_id_123",
|
||||
handler,
|
||||
{ prop: 123 }
|
||||
)
|
||||
|
||||
await strategy.resume(transaction)
|
||||
|
||||
expect(mocks.one).toBeCalledWith({ prop: 123 })
|
||||
expect(mocks.two).toBeCalledWith({ prop: 123 })
|
||||
|
||||
expect(mocks.three).toBeCalledWith(
|
||||
{ prop: 123 },
|
||||
{
|
||||
payload: {
|
||||
prop: 123,
|
||||
},
|
||||
invoke: {
|
||||
firstMethod: { abc: 1234 },
|
||||
secondMethod: { def: "567" },
|
||||
thirdMethod: { end: true, onePropAbc: 1234 },
|
||||
},
|
||||
compensate: {},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("Should store compensate's step responses if flag 'saveResponse' is set to true", async () => {
|
||||
const mocks = {
|
||||
one: jest.fn().mockImplementation(() => {
|
||||
return 1
|
||||
}),
|
||||
two: jest.fn().mockImplementation(() => {
|
||||
return 2
|
||||
}),
|
||||
compensateOne: jest.fn().mockImplementation((compensateContext) => {
|
||||
return "compensate 1 - 2 = " + compensateContext.secondMethod.two
|
||||
}),
|
||||
compensateTwo: jest.fn().mockImplementation((compensateContext) => {
|
||||
return { two: "isCompensated" }
|
||||
}),
|
||||
}
|
||||
|
||||
async function handler(
|
||||
actionId: string,
|
||||
functionHandlerType: TransactionHandlerType,
|
||||
payload: TransactionPayload
|
||||
) {
|
||||
const command = {
|
||||
firstMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
return mocks.one()
|
||||
},
|
||||
[TransactionHandlerType.COMPENSATE]: ({ compensate }) => {
|
||||
return mocks.compensateOne({ ...compensate })
|
||||
},
|
||||
},
|
||||
secondMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
return mocks.two()
|
||||
},
|
||||
[TransactionHandlerType.COMPENSATE]: ({ compensate }) => {
|
||||
return mocks.compensateTwo({ ...compensate })
|
||||
},
|
||||
},
|
||||
thirdMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
throw new Error("failed")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return command[actionId][functionHandlerType](payload.context)
|
||||
}
|
||||
|
||||
const flow: TransactionStepsDefinition = {
|
||||
next: {
|
||||
action: "firstMethod",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "secondMethod",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "thirdMethod",
|
||||
noCompensation: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const strategy = new TransactionOrchestrator("transaction-name", flow)
|
||||
|
||||
const transaction = await strategy.beginTransaction(
|
||||
"transaction_id_123",
|
||||
handler
|
||||
)
|
||||
|
||||
await strategy.resume(transaction)
|
||||
const resposes = transaction.getContext()
|
||||
|
||||
expect(mocks.compensateTwo).toBeCalledWith({})
|
||||
|
||||
expect(mocks.compensateOne).toBeCalledWith({
|
||||
secondMethod: {
|
||||
two: "isCompensated",
|
||||
},
|
||||
})
|
||||
|
||||
expect(resposes.compensate.firstMethod).toEqual(
|
||||
"compensate 1 - 2 = isCompensated"
|
||||
)
|
||||
})
|
||||
|
||||
it("Should continue the exection of next steps without waiting for the execution of all its parents when flag 'noWait' is set to true", async () => {
|
||||
const actionOrder: string[] = []
|
||||
async function handler(
|
||||
actionId: string,
|
||||
functionHandlerType: TransactionHandlerType,
|
||||
payload: TransactionPayload
|
||||
) {
|
||||
if (functionHandlerType === TransactionHandlerType.INVOKE) {
|
||||
actionOrder.push(actionId)
|
||||
}
|
||||
|
||||
if (
|
||||
functionHandlerType === TransactionHandlerType.INVOKE &&
|
||||
actionId === "three"
|
||||
) {
|
||||
throw new Error()
|
||||
}
|
||||
}
|
||||
|
||||
const flow: TransactionStepsDefinition = {
|
||||
next: [
|
||||
{
|
||||
action: "one",
|
||||
next: {
|
||||
action: "five",
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "two",
|
||||
noWait: true,
|
||||
next: {
|
||||
action: "four",
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "three",
|
||||
maxRetries: 0,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const strategy = new TransactionOrchestrator("transaction-name", flow)
|
||||
|
||||
const transaction = await strategy.beginTransaction(
|
||||
"transaction_id_123",
|
||||
handler
|
||||
)
|
||||
|
||||
strategy.resume(transaction)
|
||||
|
||||
await new Promise((ok) => {
|
||||
strategy.on("finish", ok)
|
||||
})
|
||||
|
||||
expect(actionOrder).toEqual(["one", "two", "three", "four"])
|
||||
})
|
||||
|
||||
it("Should retry steps X times when a step fails and compensate steps afterward", async () => {
|
||||
const mocks = {
|
||||
one: jest.fn().mockImplementation((payload) => {
|
||||
return payload
|
||||
}),
|
||||
compensateOne: jest.fn().mockImplementation((payload) => {
|
||||
return payload
|
||||
}),
|
||||
two: jest.fn().mockImplementation((payload) => {
|
||||
throw new Error()
|
||||
}),
|
||||
compensateTwo: jest.fn().mockImplementation((payload) => {
|
||||
return payload
|
||||
}),
|
||||
}
|
||||
|
||||
async function handler(
|
||||
actionId: string,
|
||||
functionHandlerType: TransactionHandlerType,
|
||||
payload: TransactionPayload
|
||||
) {
|
||||
const command = {
|
||||
firstMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
mocks.one(payload)
|
||||
},
|
||||
[TransactionHandlerType.COMPENSATE]: () => {
|
||||
mocks.compensateOne(payload)
|
||||
},
|
||||
},
|
||||
secondMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
mocks.two(payload)
|
||||
},
|
||||
[TransactionHandlerType.COMPENSATE]: () => {
|
||||
mocks.compensateTwo(payload)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return command[actionId][functionHandlerType](payload)
|
||||
}
|
||||
|
||||
const flow: TransactionStepsDefinition = {
|
||||
next: {
|
||||
action: "firstMethod",
|
||||
maxRetries: 3,
|
||||
next: {
|
||||
action: "secondMethod",
|
||||
maxRetries: 3,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const strategy = new TransactionOrchestrator("transaction-name", flow)
|
||||
|
||||
const transaction = await strategy.beginTransaction(
|
||||
"transaction_id_123",
|
||||
handler
|
||||
)
|
||||
|
||||
await strategy.resume(transaction)
|
||||
|
||||
expect(transaction.transactionId).toBe("transaction_id_123")
|
||||
expect(mocks.one).toBeCalledTimes(1)
|
||||
expect(mocks.two).toBeCalledTimes(4)
|
||||
expect(transaction.getState()).toBe(TransactionState.REVERTED)
|
||||
expect(mocks.compensateOne).toBeCalledTimes(1)
|
||||
|
||||
expect(mocks.two).nthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
attempt: 1,
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
expect(mocks.two).nthCalledWith(
|
||||
4,
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
attempt: 4,
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("Should fail a transaction if any step fails after retrying X time to compensate it", async () => {
|
||||
const mocks = {
|
||||
one: jest.fn().mockImplementation((payload) => {
|
||||
throw new Error()
|
||||
}),
|
||||
}
|
||||
|
||||
async function handler(
|
||||
actionId: string,
|
||||
functionHandlerType: TransactionHandlerType,
|
||||
payload: TransactionPayload
|
||||
) {
|
||||
const command = {
|
||||
firstMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
mocks.one(payload)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return command[actionId][functionHandlerType](payload)
|
||||
}
|
||||
|
||||
const flow: TransactionStepsDefinition = {
|
||||
next: {
|
||||
action: "firstMethod",
|
||||
maxRetries: 1,
|
||||
},
|
||||
}
|
||||
|
||||
const strategy = new TransactionOrchestrator("transaction-name", flow)
|
||||
|
||||
const transaction = await strategy.beginTransaction(
|
||||
"transaction_id_123",
|
||||
handler
|
||||
)
|
||||
|
||||
await strategy.resume(transaction)
|
||||
|
||||
expect(mocks.one).toBeCalledTimes(2)
|
||||
expect(transaction.getState()).toBe(TransactionState.FAILED)
|
||||
})
|
||||
|
||||
it("Should complete a transaction if a failing step has the flag 'continueOnPermanentFailure' set to true", async () => {
|
||||
const mocks = {
|
||||
one: jest.fn().mockImplementation((payload) => {
|
||||
return
|
||||
}),
|
||||
two: jest.fn().mockImplementation((payload) => {
|
||||
throw new Error()
|
||||
}),
|
||||
}
|
||||
|
||||
async function handler(
|
||||
actionId: string,
|
||||
functionHandlerType: TransactionHandlerType,
|
||||
payload: TransactionPayload
|
||||
) {
|
||||
const command = {
|
||||
firstMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
mocks.one(payload)
|
||||
},
|
||||
},
|
||||
secondMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
mocks.two(payload)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return command[actionId][functionHandlerType](payload)
|
||||
}
|
||||
|
||||
const flow: TransactionStepsDefinition = {
|
||||
next: {
|
||||
action: "firstMethod",
|
||||
next: {
|
||||
action: "secondMethod",
|
||||
maxRetries: 1,
|
||||
continueOnPermanentFailure: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const strategy = new TransactionOrchestrator("transaction-name", flow)
|
||||
|
||||
const transaction = await strategy.beginTransaction(
|
||||
"transaction_id_123",
|
||||
handler
|
||||
)
|
||||
|
||||
await strategy.resume(transaction)
|
||||
|
||||
expect(transaction.transactionId).toBe("transaction_id_123")
|
||||
expect(mocks.one).toBeCalledTimes(1)
|
||||
expect(mocks.two).toBeCalledTimes(2)
|
||||
expect(transaction.getState()).toBe(TransactionState.DONE)
|
||||
expect(transaction.isPartiallyCompleted).toBe(true)
|
||||
})
|
||||
|
||||
it("Should hold the status INVOKING while the transaction hasn't finished", async () => {
|
||||
const mocks = {
|
||||
one: jest.fn().mockImplementation((payload) => {
|
||||
return
|
||||
}),
|
||||
two: jest.fn().mockImplementation((payload) => {
|
||||
return
|
||||
}),
|
||||
}
|
||||
|
||||
async function handler(
|
||||
actionId: string,
|
||||
functionHandlerType: TransactionHandlerType,
|
||||
payload: TransactionPayload
|
||||
) {
|
||||
const command = {
|
||||
firstMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
mocks.one(payload)
|
||||
},
|
||||
},
|
||||
secondMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
mocks.two(payload)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return command[actionId][functionHandlerType](payload)
|
||||
}
|
||||
|
||||
const flow: TransactionStepsDefinition = {
|
||||
next: {
|
||||
action: "firstMethod",
|
||||
async: true,
|
||||
next: {
|
||||
action: "secondMethod",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const strategy = new TransactionOrchestrator("transaction-name", flow)
|
||||
|
||||
const transaction = await strategy.beginTransaction(
|
||||
"transaction_id_123",
|
||||
handler,
|
||||
{
|
||||
myPayloadProp: "test",
|
||||
}
|
||||
)
|
||||
|
||||
await strategy.resume(transaction)
|
||||
|
||||
expect(mocks.one).toBeCalledTimes(1)
|
||||
expect(mocks.two).toBeCalledTimes(0)
|
||||
expect(transaction.getState()).toBe(TransactionState.INVOKING)
|
||||
|
||||
const mocktransactionId = TransactionOrchestrator.getKeyName(
|
||||
transaction.transactionId,
|
||||
"firstMethod",
|
||||
TransactionHandlerType.INVOKE
|
||||
)
|
||||
await strategy.registerStepSuccess(
|
||||
mocktransactionId,
|
||||
undefined,
|
||||
transaction
|
||||
)
|
||||
|
||||
expect(transaction.getState()).toBe(TransactionState.DONE)
|
||||
})
|
||||
|
||||
it("Should hold the status COMPENSATING while the transaction hasn't finished compensating", async () => {
|
||||
const mocks = {
|
||||
one: jest.fn().mockImplementation((payload) => {
|
||||
return
|
||||
}),
|
||||
compensateOne: jest.fn().mockImplementation((payload) => {
|
||||
return
|
||||
}),
|
||||
two: jest.fn().mockImplementation((payload) => {
|
||||
return
|
||||
}),
|
||||
}
|
||||
|
||||
async function handler(
|
||||
actionId: string,
|
||||
functionHandlerType: TransactionHandlerType,
|
||||
payload: TransactionPayload
|
||||
) {
|
||||
const command = {
|
||||
firstMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
mocks.one(payload)
|
||||
},
|
||||
[TransactionHandlerType.COMPENSATE]: () => {
|
||||
mocks.compensateOne(payload)
|
||||
},
|
||||
},
|
||||
secondMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
mocks.two(payload)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return command[actionId][functionHandlerType](payload)
|
||||
}
|
||||
|
||||
const flow: TransactionStepsDefinition = {
|
||||
next: {
|
||||
action: "firstMethod",
|
||||
async: true,
|
||||
next: {
|
||||
action: "secondMethod",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const strategy = new TransactionOrchestrator("transaction-name", flow)
|
||||
|
||||
const transaction = await strategy.beginTransaction(
|
||||
"transaction_id_123",
|
||||
handler
|
||||
)
|
||||
|
||||
const mocktransactionId = TransactionOrchestrator.getKeyName(
|
||||
transaction.transactionId,
|
||||
"firstMethod",
|
||||
TransactionHandlerType.INVOKE
|
||||
)
|
||||
|
||||
const registerBeforeAllowed = await strategy
|
||||
.registerStepFailure(mocktransactionId, null, handler)
|
||||
.catch((e) => e.message)
|
||||
|
||||
await strategy.resume(transaction)
|
||||
|
||||
expect(mocks.one).toBeCalledTimes(1)
|
||||
expect(mocks.compensateOne).toBeCalledTimes(0)
|
||||
expect(mocks.two).toBeCalledTimes(0)
|
||||
expect(registerBeforeAllowed).toEqual(
|
||||
"Cannot set step failure when status is idle"
|
||||
)
|
||||
expect(transaction.getState()).toBe(TransactionState.INVOKING)
|
||||
|
||||
const resumedTransaction = await strategy.registerStepFailure(
|
||||
mocktransactionId,
|
||||
null,
|
||||
handler
|
||||
)
|
||||
|
||||
expect(resumedTransaction.getState()).toBe(TransactionState.COMPENSATING)
|
||||
expect(mocks.compensateOne).toBeCalledTimes(1)
|
||||
|
||||
const mocktransactionIdCompensate = TransactionOrchestrator.getKeyName(
|
||||
transaction.transactionId,
|
||||
"firstMethod",
|
||||
TransactionHandlerType.COMPENSATE
|
||||
)
|
||||
await strategy.registerStepSuccess(
|
||||
mocktransactionIdCompensate,
|
||||
undefined,
|
||||
resumedTransaction
|
||||
)
|
||||
|
||||
expect(resumedTransaction.getState()).toBe(TransactionState.REVERTED)
|
||||
})
|
||||
|
||||
it("Should revert a transaction when .cancelTransaction() is called", async () => {
|
||||
const mocks = {
|
||||
one: jest.fn().mockImplementation((payload) => {
|
||||
return payload
|
||||
}),
|
||||
oneCompensate: jest.fn().mockImplementation((payload) => {
|
||||
return payload
|
||||
}),
|
||||
two: jest.fn().mockImplementation((payload) => {
|
||||
return payload
|
||||
}),
|
||||
twoCompensate: jest.fn().mockImplementation((payload) => {
|
||||
return payload
|
||||
}),
|
||||
}
|
||||
|
||||
async function handler(
|
||||
actionId: string,
|
||||
functionHandlerType: TransactionHandlerType,
|
||||
payload: TransactionPayload
|
||||
) {
|
||||
const command = {
|
||||
firstMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
mocks.one(payload)
|
||||
},
|
||||
[TransactionHandlerType.COMPENSATE]: () => {
|
||||
mocks.oneCompensate(payload)
|
||||
},
|
||||
},
|
||||
secondMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
mocks.two(payload)
|
||||
},
|
||||
[TransactionHandlerType.COMPENSATE]: () => {
|
||||
mocks.twoCompensate(payload)
|
||||
},
|
||||
},
|
||||
}
|
||||
return command[actionId][functionHandlerType](payload)
|
||||
}
|
||||
|
||||
const flow: TransactionStepsDefinition = {
|
||||
next: {
|
||||
action: "firstMethod",
|
||||
next: {
|
||||
action: "secondMethod",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const strategy = new TransactionOrchestrator("transaction-name", flow)
|
||||
|
||||
const transaction = await strategy.beginTransaction(
|
||||
"transaction_id_123",
|
||||
handler
|
||||
)
|
||||
|
||||
await strategy.resume(transaction)
|
||||
|
||||
expect(transaction.getState()).toBe(TransactionState.DONE)
|
||||
expect(mocks.one).toBeCalledTimes(1)
|
||||
expect(mocks.two).toBeCalledTimes(1)
|
||||
|
||||
await strategy.cancelTransaction(transaction)
|
||||
|
||||
expect(transaction.getState()).toBe(TransactionState.REVERTED)
|
||||
expect(mocks.one).toBeCalledTimes(1)
|
||||
expect(mocks.two).toBeCalledTimes(1)
|
||||
expect(mocks.oneCompensate).toBeCalledTimes(1)
|
||||
expect(mocks.twoCompensate).toBeCalledTimes(1)
|
||||
})
|
||||
})
|
||||
176
packages/orchestration/src/__tests__/workflow/global-workflow.ts
Normal file
176
packages/orchestration/src/__tests__/workflow/global-workflow.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { GlobalWorkflow } from "../../workflow/global-workflow"
|
||||
import { TransactionState } from "../../transaction/types"
|
||||
import { WorkflowManager } from "../../workflow/workflow-manager"
|
||||
|
||||
describe("WorkflowManager", () => {
|
||||
const container: any = {}
|
||||
|
||||
let handlers
|
||||
let flow: GlobalWorkflow
|
||||
let asyncStepIdempotencyKey: string
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks()
|
||||
WorkflowManager.unregisterAll()
|
||||
|
||||
handlers = new Map()
|
||||
handlers.set("foo", {
|
||||
invoke: jest.fn().mockResolvedValue({ done: true }),
|
||||
compensate: jest.fn(() => {}),
|
||||
})
|
||||
|
||||
handlers.set("bar", {
|
||||
invoke: jest.fn().mockResolvedValue({ done: true }),
|
||||
compensate: jest.fn().mockResolvedValue({}),
|
||||
})
|
||||
|
||||
handlers.set("broken", {
|
||||
invoke: jest.fn(() => {
|
||||
throw new Error("Step Failed")
|
||||
}),
|
||||
compensate: jest.fn().mockResolvedValue({ bar: 123, reverted: true }),
|
||||
})
|
||||
|
||||
handlers.set("callExternal", {
|
||||
invoke: jest.fn(({ metadata }) => {
|
||||
asyncStepIdempotencyKey = metadata.idempotency_key
|
||||
}),
|
||||
})
|
||||
|
||||
WorkflowManager.register(
|
||||
"create-product",
|
||||
{
|
||||
action: "foo",
|
||||
next: {
|
||||
action: "bar",
|
||||
},
|
||||
},
|
||||
handlers
|
||||
)
|
||||
|
||||
WorkflowManager.register(
|
||||
"broken-delivery",
|
||||
{
|
||||
action: "foo",
|
||||
next: {
|
||||
action: "broken",
|
||||
},
|
||||
},
|
||||
handlers
|
||||
)
|
||||
|
||||
WorkflowManager.register(
|
||||
"deliver-product",
|
||||
{
|
||||
action: "foo",
|
||||
next: {
|
||||
action: "callExternal",
|
||||
async: true,
|
||||
noCompensation: true,
|
||||
next: {
|
||||
action: "bar",
|
||||
},
|
||||
},
|
||||
},
|
||||
handlers
|
||||
)
|
||||
|
||||
flow = new GlobalWorkflow(container)
|
||||
})
|
||||
|
||||
it("should return all registered workflows", () => {
|
||||
const wf = Object.keys(Object.fromEntries(WorkflowManager.getWorkflows()))
|
||||
expect(wf).toEqual(["create-product", "broken-delivery", "deliver-product"])
|
||||
})
|
||||
|
||||
it("should begin a transaction and returns its final state", async () => {
|
||||
const transaction = await flow.run("create-product", "t-id", {
|
||||
input: 123,
|
||||
})
|
||||
|
||||
expect(handlers.get("foo").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(handlers.get("foo").compensate).toHaveBeenCalledTimes(0)
|
||||
expect(handlers.get("foo").compensate).toHaveBeenCalledTimes(0)
|
||||
|
||||
expect(transaction.getState()).toBe(TransactionState.DONE)
|
||||
})
|
||||
|
||||
it("should begin a transaction and revert it when fail", async () => {
|
||||
const transaction = await flow.run("broken-delivery", "t-id")
|
||||
|
||||
expect(handlers.get("foo").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("broken").invoke).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(handlers.get("foo").compensate).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("broken").compensate).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(transaction.getState()).toBe(TransactionState.REVERTED)
|
||||
})
|
||||
|
||||
it("should continue an asyncronous transaction after reporting a successful step", async () => {
|
||||
const transaction = await flow.run("deliver-product", "t-id")
|
||||
|
||||
expect(handlers.get("foo").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("callExternal").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(0)
|
||||
|
||||
expect(transaction.getState()).toBe(TransactionState.INVOKING)
|
||||
|
||||
const continuation = await flow.registerStepSuccess(
|
||||
"deliver-product",
|
||||
asyncStepIdempotencyKey,
|
||||
{ ok: true }
|
||||
)
|
||||
|
||||
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(continuation.getState()).toBe(TransactionState.DONE)
|
||||
})
|
||||
|
||||
it("should revert an asyncronous transaction after reporting a failure step", async () => {
|
||||
const transaction = await flow.run("deliver-product", "t-id")
|
||||
|
||||
expect(handlers.get("foo").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("callExternal").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(0)
|
||||
|
||||
expect(transaction.getState()).toBe(TransactionState.INVOKING)
|
||||
|
||||
const continuation = await flow.registerStepFailure(
|
||||
"deliver-product",
|
||||
asyncStepIdempotencyKey,
|
||||
{ ok: true }
|
||||
)
|
||||
|
||||
expect(handlers.get("foo").compensate).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(0)
|
||||
expect(handlers.get("bar").compensate).toHaveBeenCalledTimes(0)
|
||||
|
||||
// Failed because the async is flagged as noCompensation
|
||||
expect(continuation.getState()).toBe(TransactionState.FAILED)
|
||||
})
|
||||
|
||||
it("should update an existing global flow with a new step and a new handler", async () => {
|
||||
const definition =
|
||||
WorkflowManager.getTransactionDefinition("create-product")
|
||||
|
||||
definition.insertActionBefore("bar", "xor", { maxRetries: 3 })
|
||||
|
||||
const additionalHandlers = new Map()
|
||||
additionalHandlers.set("xor", {
|
||||
invoke: jest.fn().mockResolvedValue({ done: true }),
|
||||
compensate: jest.fn().mockResolvedValue({}),
|
||||
})
|
||||
|
||||
WorkflowManager.update("create-product", definition, additionalHandlers)
|
||||
|
||||
const transaction = await flow.run("create-product", "t-id")
|
||||
|
||||
expect(handlers.get("foo").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(additionalHandlers.get("xor").invoke).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(transaction.getState()).toBe(TransactionState.DONE)
|
||||
})
|
||||
})
|
||||
175
packages/orchestration/src/__tests__/workflow/local-workflow.ts
Normal file
175
packages/orchestration/src/__tests__/workflow/local-workflow.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { LocalWorkflow } from "../../workflow/local-workflow"
|
||||
import { TransactionState } from "../../transaction/types"
|
||||
import { WorkflowManager } from "../../workflow/workflow-manager"
|
||||
|
||||
describe("WorkflowManager", () => {
|
||||
const container: any = {}
|
||||
|
||||
let handlers
|
||||
let asyncStepIdempotencyKey: string
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks()
|
||||
WorkflowManager.unregisterAll()
|
||||
|
||||
handlers = new Map()
|
||||
handlers.set("foo", {
|
||||
invoke: jest.fn().mockResolvedValue({ done: true }),
|
||||
compensate: jest.fn(() => {}),
|
||||
})
|
||||
|
||||
handlers.set("bar", {
|
||||
invoke: jest.fn().mockResolvedValue({ done: true }),
|
||||
compensate: jest.fn().mockResolvedValue({}),
|
||||
})
|
||||
|
||||
handlers.set("broken", {
|
||||
invoke: jest.fn(() => {
|
||||
throw new Error("Step Failed")
|
||||
}),
|
||||
compensate: jest.fn().mockResolvedValue({ bar: 123, reverted: true }),
|
||||
})
|
||||
|
||||
handlers.set("callExternal", {
|
||||
invoke: jest.fn(({ metadata }) => {
|
||||
asyncStepIdempotencyKey = metadata.idempotency_key
|
||||
}),
|
||||
})
|
||||
|
||||
WorkflowManager.register(
|
||||
"create-product",
|
||||
{
|
||||
action: "foo",
|
||||
next: {
|
||||
action: "bar",
|
||||
},
|
||||
},
|
||||
handlers
|
||||
)
|
||||
|
||||
WorkflowManager.register(
|
||||
"broken-delivery",
|
||||
{
|
||||
action: "foo",
|
||||
next: {
|
||||
action: "broken",
|
||||
},
|
||||
},
|
||||
handlers
|
||||
)
|
||||
|
||||
WorkflowManager.register(
|
||||
"deliver-product",
|
||||
{
|
||||
action: "foo",
|
||||
next: {
|
||||
action: "callExternal",
|
||||
async: true,
|
||||
noCompensation: true,
|
||||
next: {
|
||||
action: "bar",
|
||||
},
|
||||
},
|
||||
},
|
||||
handlers
|
||||
)
|
||||
})
|
||||
|
||||
it("should return all registered workflows", () => {
|
||||
const wf = Object.keys(Object.fromEntries(WorkflowManager.getWorkflows()))
|
||||
expect(wf).toEqual(["create-product", "broken-delivery", "deliver-product"])
|
||||
})
|
||||
|
||||
it("should begin a transaction and returns its final state", async () => {
|
||||
const flow = new LocalWorkflow("create-product", container)
|
||||
const transaction = await flow.run("t-id", {
|
||||
input: 123,
|
||||
})
|
||||
|
||||
expect(handlers.get("foo").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(handlers.get("foo").compensate).toHaveBeenCalledTimes(0)
|
||||
expect(handlers.get("foo").compensate).toHaveBeenCalledTimes(0)
|
||||
|
||||
expect(transaction.getState()).toBe(TransactionState.DONE)
|
||||
})
|
||||
|
||||
it("should begin a transaction and revert it when fail", async () => {
|
||||
const flow = new LocalWorkflow("broken-delivery", container)
|
||||
const transaction = await flow.run("t-id")
|
||||
|
||||
expect(handlers.get("foo").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("broken").invoke).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(handlers.get("foo").compensate).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("broken").compensate).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(transaction.getState()).toBe(TransactionState.REVERTED)
|
||||
})
|
||||
|
||||
it("should continue an asyncronous transaction after reporting a successful step", async () => {
|
||||
const flow = new LocalWorkflow("deliver-product", container)
|
||||
const transaction = await flow.run("t-id")
|
||||
|
||||
expect(handlers.get("foo").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("callExternal").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(0)
|
||||
|
||||
expect(transaction.getState()).toBe(TransactionState.INVOKING)
|
||||
|
||||
const continuation = await flow.registerStepSuccess(
|
||||
asyncStepIdempotencyKey,
|
||||
{ ok: true }
|
||||
)
|
||||
|
||||
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(continuation.getState()).toBe(TransactionState.DONE)
|
||||
})
|
||||
|
||||
it("should revert an asyncronous transaction after reporting a failure step", async () => {
|
||||
const flow = new LocalWorkflow("deliver-product", container)
|
||||
const transaction = await flow.run("t-id")
|
||||
|
||||
expect(handlers.get("foo").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("callExternal").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(0)
|
||||
|
||||
expect(transaction.getState()).toBe(TransactionState.INVOKING)
|
||||
|
||||
const continuation = await flow.registerStepFailure(
|
||||
asyncStepIdempotencyKey,
|
||||
{ ok: true }
|
||||
)
|
||||
|
||||
expect(handlers.get("foo").compensate).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(0)
|
||||
expect(handlers.get("bar").compensate).toHaveBeenCalledTimes(0)
|
||||
|
||||
// Failed because the async is flagged as noCompensation
|
||||
expect(continuation.getState()).toBe(TransactionState.FAILED)
|
||||
})
|
||||
|
||||
it("should update a flow with a new step and a new handler", async () => {
|
||||
const flow = new LocalWorkflow("create-product", container)
|
||||
|
||||
const additionalHandler = {
|
||||
invoke: jest.fn().mockResolvedValue({ done: true }),
|
||||
compensate: jest.fn().mockResolvedValue({}),
|
||||
}
|
||||
|
||||
flow.insertActionBefore("bar", "xor", additionalHandler, { maxRetries: 3 })
|
||||
|
||||
const transaction = await flow.run("t-id")
|
||||
|
||||
expect(handlers.get("foo").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(additionalHandler.invoke).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(transaction.getState()).toBe(TransactionState.DONE)
|
||||
|
||||
expect(
|
||||
WorkflowManager.getWorkflow("create-product")?.handlers_.has("xor")
|
||||
).toEqual(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user