├── logs
└── .gitkeep
├── .scrutinizer.yml
├── .gitignore
├── watch.sh
├── run_tests.sh
├── run_mutation.sh
├── docker-compose.yml
├── docker
├── watch.sh
└── run_tests.sh
├── src
├── Exceptions
│ ├── TaleException.php
│ ├── FailureToBuildStep.php
│ └── FailedApplyingAllCompensations.php
├── State
│ ├── CloneableStateHelper.php
│ └── CloneableState.php
├── Steps
│ ├── NamedStep.php
│ ├── FinalisingStep.php
│ ├── Step.php
│ └── LambdaStep.php
├── Execution
│ ├── TransactionResult.php
│ ├── CompletedStep.php
│ ├── Failure.php
│ └── Success.php
├── StepBuilder.php
└── Transaction.php
├── infection.json.dist
├── tests
├── State
│ └── FakeState.php
├── Steps
│ ├── Mocks
│ │ ├── FailingStep.php
│ │ ├── MockStep.php
│ │ ├── StepWithFailingCompensate.php
│ │ └── MockFinalisingStep.php
│ └── LambdaStepTest.php
├── LoggingTest.php
├── FinalisationTest.php
├── StateTest.php
├── StepBuilderTest.php
└── TransactionTest.php
├── .travis.yml
├── phpunit.xml
├── Dockerfile
├── composer.json
├── CHANGELOG.md
├── LICENSE
├── psalm.xml
└── README.md
/logs/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.scrutinizer.yml:
--------------------------------------------------------------------------------
1 | checks:
2 | php: true
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | vendor/
2 | .idea/
3 | composer.lock
4 | logs/*
--------------------------------------------------------------------------------
/watch.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -ex
3 |
4 | docker-compose run tale docker/watch.sh
--------------------------------------------------------------------------------
/run_tests.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -ex
3 |
4 | docker-compose run tale docker/run-tests.sh
--------------------------------------------------------------------------------
/run_mutation.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -ex
3 |
4 | docker-compose run tale /tools/infection.phar
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 |
3 | services:
4 | tale:
5 | build:
6 | context: ./
7 | volumes:
8 | - ./:/app/
9 |
--------------------------------------------------------------------------------
/docker/watch.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -ex
3 |
4 | # Rerun the tests when any php file changes
5 | find . -name '*.php' | entr ./docker/run_tests.sh
6 |
--------------------------------------------------------------------------------
/src/Exceptions/TaleException.php:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | tests
8 |
9 |
10 |
11 |
12 |
13 | src/
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/src/Execution/TransactionResult.php:
--------------------------------------------------------------------------------
1 | add($mockStep);
17 | $transaction->run("starting_state");
18 |
19 | $this->assertTrue($mockLogger->log->countRecordsWithLevel("debug") > 0);
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM composer:1.7.2
2 |
3 | RUN apk add --no-cache $PHPIZE_DEPS \
4 | && pecl install xdebug-2.6.1 \
5 | && docker-php-ext-enable xdebug
6 |
7 | RUN apk add entr # Used by the test watcher
8 |
9 | WORKDIR /tools
10 |
11 | RUN wget https://github.com/infection/infection/releases/download/0.10.3/infection.phar
12 | RUN wget https://github.com/infection/infection/releases/download/0.10.3/infection.phar.asc
13 | RUN chmod +x infection.phar
14 |
15 | WORKDIR /app
16 |
17 | # Grab the composer.* files first so we can cache this layer when
18 | # the dependencies haven't changed
19 | COPY composer.json /app/composer.json
20 | RUN composer install
21 |
22 | COPY . /app/
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mead-steve/tale",
3 | "license": "MIT",
4 | "authors": [
5 | {
6 | "name": "Steve Brazier",
7 | "email": "meadsteve@gmail.com"
8 | }
9 | ],
10 | "require": {
11 | "php": ">=7.1, <8.0",
12 | "psr/log": "^1.0"
13 | },
14 |
15 | "require-dev": {
16 | "phpunit/phpunit": "^7.2",
17 | "phpstan/phpstan": "^0.11.5",
18 | "squizlabs/php_codesniffer": "^3.3",
19 | "monolog/monolog": "^1.23",
20 | "vimeo/psalm": "^3.2",
21 | "gamez/psr-testlogger": "^3.0"
22 | },
23 |
24 | "autoload": {
25 | "psr-4": {
26 | "MeadSteve\\Tale\\": "src/",
27 | "MeadSteve\\Tale\\Tests\\": "tests/"
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/tests/Steps/Mocks/MockStep.php:
--------------------------------------------------------------------------------
1 | executedState = $state;
15 | return $state;
16 | }
17 |
18 | public function compensate($state): void
19 | {
20 | $this->revertedState = $state;
21 | }
22 |
23 | /**
24 | * @return string the public name of this step
25 | */
26 | public function stepName(): string
27 | {
28 | return "mock step";
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/Steps/Step.php:
--------------------------------------------------------------------------------
1 | step = $step;
32 | $this->state = $state;
33 | $this->stepId = $stepId;
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/Execution/Failure.php:
--------------------------------------------------------------------------------
1 | exception = $exception;
16 | }
17 |
18 | /**
19 | * @throws \Throwable
20 | */
21 | public function throwFailures()
22 | {
23 | throw $this->exception;
24 | }
25 |
26 | /**
27 | * Whatever the final state was after finishing the transaction
28 | * @return mixed
29 | */
30 | public function finalState()
31 | {
32 | return $this->exception;
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/Exceptions/FailedApplyingAllCompensations.php:
--------------------------------------------------------------------------------
1 | caughtExceptions = $caughtExceptions;
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/Execution/Success.php:
--------------------------------------------------------------------------------
1 | finalState = $finalState;
20 | }
21 |
22 | public function throwFailures()
23 | {
24 | // Nothing to be thrown as this is a success
25 | return $this;
26 | }
27 |
28 | public function finalState()
29 | {
30 | if ($this->finalState instanceof CloneableState) {
31 | return $this->finalState->cloneState();
32 | }
33 | return $this->finalState;
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/tests/Steps/Mocks/MockFinalisingStep.php:
--------------------------------------------------------------------------------
1 | executedState = $state;
17 | return $state;
18 | }
19 |
20 | public function compensate($state): void
21 | {
22 | $this->revertedState = $state;
23 | }
24 |
25 | /**
26 | * @return string the public name of this step
27 | */
28 | public function stepName(): string
29 | {
30 | return "mock finalising step";
31 | }
32 |
33 | public function finalise($state): void
34 | {
35 | $this->finalisedState = $state;
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/StepBuilder.php:
--------------------------------------------------------------------------------
1 | executeHandler = $execute;
25 | $this->compensateHandler = $compensate ?? function () {
26 | };
27 |
28 | $this->name = $name ?? "anonymous lambda";
29 | }
30 |
31 | public function execute($state)
32 | {
33 | $function = $this->executeHandler;
34 | return $function($state);
35 | }
36 |
37 | public function compensate($state): void
38 | {
39 | $function = $this->compensateHandler;
40 | $function($state);
41 | }
42 |
43 | /**
44 | * @return string the public name of this step
45 | */
46 | public function stepName(): string
47 | {
48 | return $this->name;
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/tests/FinalisationTest.php:
--------------------------------------------------------------------------------
1 | add($mockStep);
18 |
19 | $transaction
20 | ->run("expected_result")
21 | ->finalState();
22 |
23 | $this->assertEquals("expected_result", $mockStep->finalisedState);
24 | }
25 |
26 | public function testFinaliseMethodsArentCalledIfTheTransactionFails()
27 | {
28 |
29 | $mockStep = new MockFinalisingStep();
30 | $transaction = (new Transaction())
31 | ->add($mockStep)
32 | ->add(new FailingStep());
33 |
34 | $transaction
35 | ->run("expected_result")
36 | ->finalState();
37 |
38 | $this->assertNull($mockStep->finalisedState);
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/tests/Steps/LambdaStepTest.php:
--------------------------------------------------------------------------------
1 | state = "not-run";
20 | $this->testedStep = new LambdaStep(function ($state) {
21 | $this->state = "run with " . $state;
22 | return "second-state";
23 | }, function ($state) {
24 | $this->state = "compensated with " . $state;
25 | }, $name);
26 | }
27 |
28 | public function testExecuteReturnsTheResultOfTheWrappedLambda()
29 | {
30 | $this->setupLamda();
31 | $this->assertEquals(
32 | "second-state",
33 | $this->testedStep->execute("whatever")
34 | );
35 | }
36 |
37 | public function testExecutePassesTheProvidedStateToTheLambda()
38 | {
39 | $this->setupLamda();
40 | $this->testedStep->execute("provided-state");
41 | $this->assertEquals("run with provided-state", $this->state);
42 | }
43 |
44 | public function testCompensatePassesTheProvidedStateToTheLambda()
45 | {
46 | $this->setupLamda();
47 | $this->testedStep->compensate("state-to-revert");
48 | $this->assertEquals("compensated with state-to-revert", $this->state);
49 | }
50 | public function testLambdaStepsCanBeNamed()
51 | {
52 | $this->setupLamda("my name");
53 | $this->assertEquals("my name", $this->testedStep->stepName());
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/tests/StateTest.php:
--------------------------------------------------------------------------------
1 | helloFrom = "stateOne";
27 | $stateOne = $state;
28 | return $state;
29 | }
30 | );
31 | $stepTwo = new LambdaStep(
32 | function ($state) use (&$stateTwo) {
33 | $state->helloFrom = "stateTwo";
34 | $stateTwo = $state;
35 | return $state;
36 | }
37 | );
38 | $transaction = (new Transaction())
39 | ->add($stepOne)
40 | ->add($stepTwo);
41 |
42 | $startingState = new FakeState();
43 | $finalState = $transaction->run($startingState)->finalState();
44 | $finalState->helloFrom = "afterwards";
45 | $startingState->helloFrom = "beforeButAfter";
46 |
47 |
48 | $this->assertNotEquals($startingState, $stateOne);
49 | $this->assertNotEquals($stateOne, $stateTwo);
50 | $this->assertNotEquals($stateTwo, $finalState);
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/tests/StepBuilderTest.php:
--------------------------------------------------------------------------------
1 | assertSame($outputStep, $mockStep);
19 | }
20 |
21 | public function testTurnsTwoCallablesIntoALambdaStep()
22 | {
23 | $functionOne = function () {
24 | return "function_one";
25 | };
26 | $functionTwo = function () {
27 | return "function_two";
28 | };
29 |
30 | $outputStep = StepBuilder::build($functionOne, $functionTwo);
31 |
32 | $this->assertInstanceOf(LambdaStep::class, $outputStep);
33 | }
34 |
35 | public function testTurnsAStringAndTwoCallablesIntoANamedLambdaStep()
36 | {
37 | $functionOne = function () {
38 | return "function_one";
39 | };
40 | $functionTwo = function () {
41 | return "function_two";
42 | };
43 |
44 | /** @var NamedStep $outputStep */
45 | $outputStep = StepBuilder::build($functionOne, $functionTwo, "my_function");
46 |
47 | $this->assertInstanceOf(LambdaStep::class, $outputStep);
48 | $this->assertEquals("my_function", $outputStep->stepName());
49 | }
50 |
51 | public function testAnExceptionIsThrownIfItsNotPossibleToBuildAStep()
52 | {
53 | $this->expectExceptionMessage('Not sure how to build a step from provided data');
54 | $this->expectException(FailureToBuildStep::class);
55 |
56 | StepBuilder::build("Hugin", "Munin", []);
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/psalm.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Tale
2 | ====
3 | [](https://travis-ci.org/meadsteve/Tale)
4 | [](https://scrutinizer-ci.com/g/meadsteve/Tale/?branch=master)
5 |
6 | ## What?
7 | Tale is a small library to help write a "distributed transaction like"
8 | object across a number of services. It's loosely based on the saga pattern.
9 | A good intro is available on the couchbase blog:
10 | https://blog.couchbase.com/saga-pattern-implement-business-transactions-using-microservices-part/
11 |
12 | ## Installation
13 | ```bash
14 | composer require mead-steve/tale
15 | ```
16 |
17 | ## Example Usage
18 | An example use case of this would be some holiday booking software broken
19 | down into a few services.
20 |
21 | Assuming we have the following services: Flight booking API, Hotel booking API,
22 | and a Customer API.
23 |
24 | We'd write the following steps:
25 |
26 | ```php
27 | class DebitCustomerBalanceStep implements Step
28 | {
29 | //.. Some constructor logic for initialising the api etc...
30 |
31 | public function execute(CustomerPurchase $state)
32 | {
33 | $paymentId = $this->customerApi->debit($state->Amount);
34 | return $state->markAsPaid($paymentId);
35 | }
36 |
37 | public function compensate($state): void
38 | {
39 | $this->customerApi->refundAccountForPayment($state->paymentId)
40 | }
41 | ```
42 |
43 | ```php
44 | class BookFlightStep implements Step
45 | {
46 | //.. Some constructor logic for initialising the api etc...
47 |
48 | public function execute(FlightPurchase $state)
49 | {
50 | $flightsBookingRef = $this->flightApi->buildBooking(
51 | $state->Destination,
52 | $state->Origin,
53 | self::RETURN,
54 | $this->airline
55 | );
56 | if ($flightsBookingRef=== null) {
57 | raise \Exception("Unable to book flights");
58 | }
59 | return $state->flightsBooked($flightsBookingRef);
60 | }
61 |
62 | public function compensate($state): void
63 | {
64 | $this->customerApi->cancelFlights($state->flightsBookingRef)
65 | }
66 | ```
67 |
68 | and so on for any of the steps needed. Then in whatever is handling the user's
69 | request a distributed transaction can be built:
70 |
71 | ```php
72 | $transaction = (new Transaction())
73 | ->add(new DebitCustomerBalance($user))
74 | ->add(new BookFlightStep($airlineOfChoice))
75 | ->add(new BookHotelStep())
76 | ->add(new EmailCustomerDetailsOfBookingStep())
77 |
78 | $result = $transaction
79 | ->run($startingData)
80 | ->throwFailures()
81 | ->finalState();
82 | ```
83 |
84 | If any step along the way fails then the compensate method on each step
85 | is called in reverse order until everything is undone.
86 |
87 | ## State immutability
88 | The current state is passed from one step to the next. This same state is also
89 | used to compensate for the transactions in the event of a failure further on
90 | in the transaction. Since this is the case it is important that implementations
91 | consider making the state immutable.
92 |
93 | Tale provides a `CloneableState` interface to help with this. Any state implementing
94 | this interface will have its `cloneState` method called before being passed to a step
95 | ensuring that steps won't share references to the same state.
96 | ```php
97 | class FakeState implements CloneableState
98 | {
99 | public function cloneState()
100 | {
101 | return clone $this;
102 | }
103 | }
104 |
105 | $stepOne = new LambdaStep(
106 | function (MyStateExample $state) {
107 | $state->mutateTheState = "step one"
108 | return $state;
109 | }
110 | );
111 | $stepTwo = new LambdaStep(
112 | function (MyStateExample $state) {
113 | $state->mutateTheState = "step two"
114 | return $state;
115 | }
116 | );
117 | $transaction = (new Transaction())
118 | ->add($stepOne)
119 | ->add($stepTwo);
120 |
121 | $startingState = new MyStateExample();
122 | $finalState = $transaction->run($startingState)->finalState();
123 | ```
124 | In the example above `$startingState`, `$finalState` and `$state` given to both function
125 | calls are all clones of each other so changing one won't affect any earlier states.
126 |
127 | ## Testing / Development
128 | Contributions are very welcome. Please open an issue first if the change is large or will
129 | break backwards compatibility.
130 |
131 | All builds must pass the travis tests before merge.
132 | Running `./run_tests.sh` will run the same tests as travis.yml but locally.
133 |
134 | The dockerfile provides an environment that can execute all the tests & static analysis.
--------------------------------------------------------------------------------
/src/Transaction.php:
--------------------------------------------------------------------------------
1 | logger = $logger ?? new NullLogger();
32 | }
33 |
34 |
35 | /**
36 | * Adds a step to the transaction. With the following args:
37 | *
38 | * (Step) -> Adds the step to transaction
39 | * (Closure, Closure) -> Creates a step with the first lambda
40 | * as the execute and the second as compensate
41 | * (Closure, Closure, str) -> Same as above but named
42 | *
43 | * @param mixed ...$args
44 | * @return Transaction
45 | */
46 | public function add(...$args): Transaction
47 | {
48 | $step = StepBuilder::build(...$args);
49 | return $this->addStep($step);
50 | }
51 |
52 | /**
53 | * Runs each step in the transaction
54 | *
55 | * @param mixed $startingState the state to pass in to the first step
56 | * @return TransactionResult
57 | */
58 | public function run($startingState = []): TransactionResult
59 | {
60 | $this->logger->debug("Running transaction");
61 | $state = $startingState;
62 | $completedSteps = [];
63 | foreach ($this->steps as $key => $step) {
64 | try {
65 | $state = $this->executeStep($key, $step, $state);
66 | $completedSteps[] = new CompletedStep($step, $state, $key);
67 | } catch (\Throwable $failure) {
68 | $this->logger->debug("Failed executing {$this->stepName($step)} step [$key]");
69 | $this->revertCompletedSteps($completedSteps);
70 | $this->logger->debug("Finished compensating all previous steps");
71 | return new Failure($failure);
72 | }
73 | }
74 | $this->finaliseSteps($completedSteps);
75 | return new Success($state);
76 | }
77 |
78 | private function addStep(Step $step): Transaction
79 | {
80 | $this->logger->debug("Adding {$this->stepName($step)} to transaction definition");
81 | $this->steps[] = $step;
82 | return $this;
83 | }
84 |
85 | /**
86 | * @param CompletedStep[] $completedSteps
87 | */
88 | private function revertCompletedSteps(array $completedSteps): void
89 | {
90 | $errors = [];
91 | foreach (array_reverse($completedSteps) as $completedStep) {
92 | $step = $completedStep->step;
93 | $stepId = $completedStep->stepId;
94 | try {
95 | $this->logger->debug("Compensating for step {$this->stepName($step)} [{$stepId}]");
96 | $step->compensate($completedStep->state);
97 | $this->logger->debug("Compensation complete for step {$this->stepName($step)} [{$stepId}]");
98 | } catch (\Throwable $error) {
99 | $this->logger->debug("Compensation failed for step {$this->stepName($step)} [{$stepId}]");
100 | $errors[] = $error;
101 | }
102 | }
103 | if (sizeof($errors) !== 0) {
104 | throw new FailedApplyingAllCompensations($errors);
105 | }
106 | }
107 |
108 | /**
109 | * @param CompletedStep[] $completedSteps
110 | */
111 | private function finaliseSteps($completedSteps): void
112 | {
113 | foreach ($completedSteps as $completedStep) {
114 | $step = $completedStep->step;
115 | if ($step instanceof FinalisingStep) {
116 | $stepId = $completedStep->stepId;
117 | $this->logger->debug("Finalising step {$this->stepName($step)} [{$stepId}]");
118 | $step->finalise($completedStep->state);
119 | $this->logger->debug("Finalising step {$this->stepName($step)} [{$stepId}]");
120 | }
121 | }
122 | }
123 |
124 | private function stepName(Step $step): string
125 | {
126 | if ($step instanceof NamedStep) {
127 | return "`{$step->stepName()}`";
128 | }
129 | return "anonymous step";
130 | }
131 |
132 | private function executeStep($number, Step $step, $state)
133 | {
134 | if ($state instanceof CloneableState) {
135 | $state = $state->cloneState();
136 | }
137 | $this->logger->debug("Executing {$this->stepName($step)} step [$number]");
138 | $state = $step->execute($state);
139 | $this->logger->debug("Execution of {$this->stepName($step)} step [$number] complete");
140 | return $state;
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/tests/TransactionTest.php:
--------------------------------------------------------------------------------
1 | add($mockStep);
20 | $transaction->run("starting_state");
21 |
22 | $this->assertEquals("starting_state", $mockStep->executedState);
23 | }
24 |
25 | public function testExecutesEachStepInTurn()
26 | {
27 | $stepOne = new LambdaStep(
28 | function ($state) {
29 | return $state . "|one";
30 | }
31 | );
32 | $stepTwo = new LambdaStep(
33 | function ($state) {
34 | return $state . "|two";
35 | }
36 | );
37 | $transaction = (new Transaction())
38 | ->add($stepOne)
39 | ->add($stepTwo);
40 |
41 | $this->assertEquals("zero|one|two", $transaction->run("zero")->finalState());
42 | }
43 |
44 | public function testAfterAFailedStepEachPreviousStepIsReverted()
45 | {
46 | $events = [];
47 | $stepOne = new LambdaStep(
48 | function ($state) use (&$events) {
49 | $events[] = "Ran step 1 with: " . $state;
50 | return "$state|one";
51 | },
52 | function ($stateToRevert) use (&$events) {
53 | $events[] = "Reverted step 1 from: " . $stateToRevert;
54 | }
55 | );
56 |
57 | $stepTwo = new LambdaStep(
58 | function ($state) use (&$events) {
59 | $events[] = "Ran step 2 with: " . $state;
60 | return "$state|two";
61 | },
62 | function ($stateToRevert) use (&$events) {
63 | $events[] = "Reverted step 2 from: " . $stateToRevert;
64 | }
65 | );
66 |
67 | $transaction = (new Transaction())
68 | ->add($stepOne)
69 | ->add($stepTwo)
70 | ->add(new FailingStep());
71 |
72 | $transaction->run("zero");
73 |
74 | $expectedEvents = [
75 | 'Ran step 1 with: zero',
76 | 'Ran step 2 with: zero|one',
77 | 'Reverted step 2 from: zero|one|two',
78 | 'Reverted step 1 from: zero|one'
79 | ];
80 | $this->assertEquals($events, $expectedEvents);
81 | }
82 |
83 | public function testErrorsAreCaughtAsWellAsExceptions()
84 | {
85 | $failureStep = new LambdaStep(
86 | function ($state) {
87 | throw new \Error("I'm a little error. Short and bad.");
88 | },
89 | function ($stateToRevert) {
90 | }
91 | );
92 |
93 | $transaction = (new Transaction())
94 | ->add($failureStep);
95 |
96 | $result = $transaction->run();
97 |
98 | $this->assertInstanceOf(\Error::class, $result->finalState());
99 | }
100 |
101 | public function testAFailObjectWithTheFailingExceptionIsReturned()
102 | {
103 |
104 | $transaction = (new Transaction())
105 | ->add(new FailingStep());
106 |
107 | $result = $transaction->run("zero");
108 |
109 | $this->assertInstanceOf(Failure::class, $result);
110 | $this->assertInstanceOf(\Exception::class, $result->exception);
111 | }
112 |
113 | public function testTransactionFailuresCanBeRethrown()
114 | {
115 |
116 | $transaction = (new Transaction())
117 | ->add(new FailingStep());
118 |
119 | $this->expectException(\Exception::class);
120 | $this->expectExceptionMessage("I always fail");
121 |
122 | $transaction
123 | ->run("zero")
124 | ->throwFailures();
125 | }
126 |
127 | public function testThrowingASuccessDoesNothingButPassTheResultThrough()
128 | {
129 |
130 | $transaction = (new Transaction())
131 | ->add(new MockStep());
132 |
133 | $result = $transaction
134 | ->run("expected_result")
135 | ->throwFailures()
136 | ->finalState();
137 |
138 | $this->assertEquals("expected_result", $result);
139 | }
140 |
141 | public function testFailuresInCompensationAreCaught()
142 | {
143 |
144 | $firstStep = new MockStep();
145 | $transaction = (new Transaction())
146 | ->add($firstStep)
147 | ->add(new StepWithFailingCompensate())
148 | ->add(new FailingStep());
149 |
150 | $this->expectException(FailedApplyingAllCompensations::class);
151 | $this->expectExceptionMessage("Failed applying all compensation steps");
152 |
153 | try {
154 | $transaction->run("some payload");
155 | } finally {
156 | # The reverted state should not be null as it should have
157 | # been compensated
158 | $this->assertNotNull($firstStep->revertedState);
159 | }
160 | }
161 | }
162 |
--------------------------------------------------------------------------------