From 623bb43a683f2b4735f28c863acb0b8dfe1d5b70 Mon Sep 17 00:00:00 2001 From: tienvx Date: Fri, 3 May 2024 13:25:25 +0700 Subject: [PATCH] feat: Allow combining matchers --- .../consumer/tests/Service/MatchersTest.php | 19 +++++++++ .../matchersConsumer-matchersProvider.json | 40 ++++++++++++++++++ example/matchers/provider/public/index.php | 4 ++ .../Formatters/CombinedMatchersFormatter.php | 24 +++++++++++ .../Matcher/Formatters/PluginFormatter.php | 33 ++++++++++++++- src/PhpPact/Consumer/Matcher/Matcher.php | 23 +++++++++- .../Matcher/Matchers/CombinedMatchers.php | 42 +++++++++++++++++++ .../Consumer/Matcher/Matchers/MatchAll.php | 11 +++++ .../Model/CombinedMatchersInterface.php | 11 +++++ .../Consumer/Matcher/Trait/MatchersTrait.php | 26 ++++++++++++ .../CombinedMatchersFormatterTest.php | 30 +++++++++++++ .../Formatters/PluginFormatterTest.php | 15 ++++++- .../PhpPact/Consumer/Matcher/MatcherTest.php | 16 +++++++ .../Matcher/Matchers/MatchAllTest.php | 28 +++++++++++++ .../Matcher/Matchers/MatchingFieldTest.php | 18 +++++--- 15 files changed, 330 insertions(+), 10 deletions(-) create mode 100644 src/PhpPact/Consumer/Matcher/Formatters/CombinedMatchersFormatter.php create mode 100644 src/PhpPact/Consumer/Matcher/Matchers/CombinedMatchers.php create mode 100644 src/PhpPact/Consumer/Matcher/Matchers/MatchAll.php create mode 100644 src/PhpPact/Consumer/Matcher/Model/CombinedMatchersInterface.php create mode 100644 src/PhpPact/Consumer/Matcher/Trait/MatchersTrait.php create mode 100644 tests/PhpPact/Consumer/Matcher/Formatters/CombinedMatchersFormatterTest.php create mode 100644 tests/PhpPact/Consumer/Matcher/Matchers/MatchAllTest.php diff --git a/example/matchers/consumer/tests/Service/MatchersTest.php b/example/matchers/consumer/tests/Service/MatchersTest.php index df68f9d3..9cbec9bf 100644 --- a/example/matchers/consumer/tests/Service/MatchersTest.php +++ b/example/matchers/consumer/tests/Service/MatchersTest.php @@ -113,6 +113,24 @@ public function testGetMatchers(): void [$this->matcher->regex(null, 'car|bike|motorbike')] ), 'url' => $this->matcher->url('http://localhost:8080/users/1234/posts/latest', '.*(\\/users\\/\\d+\\/posts\\/latest)$', false), + 'matchAll' => $this->matcher->matchAll( + ['desktop' => '2000 usd'], + [ + $this->matcher->eachKey( + ['laptop' => '1500 usd'], + [$this->matcher->regex(null, 'laptop|desktop|mobile|tablet')] + ), + $this->matcher->eachValue( + ['mobile' => '500 usd'], + [ + $this->matcher->includes('usd'), + $this->matcher->regex(null, '\d+ \w{3}') + ], + ), + $this->matcher->atLeast(2), + $this->matcher->atMost(3), + ], + ), // Don't mind this. This is for demonstrating what query values provider will received. 'query' => [ @@ -199,6 +217,7 @@ public function testGetMatchers(): void 'vehicle 1' => 'car', ], 'url' => 'http://localhost:8080/users/1234/posts/latest', + 'matchAll' => ['desktop' => '2000 usd'], // Don't mind this. This is for demonstrating what query values provider will received. 'query' => [ diff --git a/example/matchers/pacts/matchersConsumer-matchersProvider.json b/example/matchers/pacts/matchersConsumer-matchersProvider.json index 831f2380..d3a17bce 100644 --- a/example/matchers/pacts/matchersConsumer-matchersProvider.json +++ b/example/matchers/pacts/matchersConsumer-matchersProvider.json @@ -186,6 +186,9 @@ "likeInt": 13, "likeNull": null, "likeString": "some string", + "matchAll": { + "desktop": "2000 usd" + }, "notEmpty": [ "1", "2", @@ -529,6 +532,43 @@ } ] }, + "$.matchAll": { + "combine": "AND", + "matchers": [ + { + "match": "eachKey", + "rules": [ + { + "match": "regex", + "regex": "laptop|desktop|mobile|tablet" + } + ], + "value": "{\"laptop\":\"1500 usd\"}" + }, + { + "match": "eachValue", + "rules": [ + { + "match": "include", + "value": "usd" + }, + { + "match": "regex", + "regex": "\\d+ \\w{3}" + } + ], + "value": "{\"mobile\":\"500 usd\"}" + }, + { + "match": "type", + "min": 2 + }, + { + "match": "type", + "max": 3 + } + ] + }, "$.notEmpty": { "combine": "AND", "matchers": [ diff --git a/example/matchers/provider/public/index.php b/example/matchers/provider/public/index.php index b33364bf..613b1a29 100644 --- a/example/matchers/provider/public/index.php +++ b/example/matchers/provider/public/index.php @@ -74,6 +74,10 @@ 'item 2' => 'motorbike', ], 'url' => 'https://www.example.com/users/1234/posts/latest', + 'matchAll' => [ + 'tablet' => '300 usd', + 'laptop' => '1200 usd', + ], 'query' => $request->getQueryParams(), ])); diff --git a/src/PhpPact/Consumer/Matcher/Formatters/CombinedMatchersFormatter.php b/src/PhpPact/Consumer/Matcher/Formatters/CombinedMatchersFormatter.php new file mode 100644 index 00000000..d2632d2c --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Formatters/CombinedMatchersFormatter.php @@ -0,0 +1,24 @@ + + */ + public function format(MatcherInterface $matcher): array + { + if ($matcher instanceof CombinedMatchersInterface) { + return [ + 'pact:matcher:type' => $matcher->getMatchers(), + 'value' => $matcher->getValue(), + ]; + } + + return parent::format($matcher); + } +} diff --git a/src/PhpPact/Consumer/Matcher/Formatters/PluginFormatter.php b/src/PhpPact/Consumer/Matcher/Formatters/PluginFormatter.php index 4552bacf..566b97ca 100644 --- a/src/PhpPact/Consumer/Matcher/Formatters/PluginFormatter.php +++ b/src/PhpPact/Consumer/Matcher/Formatters/PluginFormatter.php @@ -3,13 +3,17 @@ namespace PhpPact\Consumer\Matcher\Formatters; use PhpPact\Consumer\Matcher\Exception\GeneratorNotRequiredException; +use PhpPact\Consumer\Matcher\Exception\InvalidValueException; use PhpPact\Consumer\Matcher\Exception\MatcherNotSupportedException; use PhpPact\Consumer\Matcher\Exception\MatchingExpressionException; use PhpPact\Consumer\Matcher\Matchers\AbstractDateTime; use PhpPact\Consumer\Matcher\Matchers\ContentType; use PhpPact\Consumer\Matcher\Matchers\EachKey; use PhpPact\Consumer\Matcher\Matchers\EachValue; +use PhpPact\Consumer\Matcher\Matchers\MatchAll; use PhpPact\Consumer\Matcher\Matchers\MatchingField; +use PhpPact\Consumer\Matcher\Matchers\MaxType; +use PhpPact\Consumer\Matcher\Matchers\MinType; use PhpPact\Consumer\Matcher\Matchers\NotEmpty; use PhpPact\Consumer\Matcher\Matchers\NullValue; use PhpPact\Consumer\Matcher\Matchers\Regex; @@ -40,6 +44,12 @@ public function format(MatcherInterface $matcher): string if ($matcher instanceof NullValue) { return $this->formatMatchersWithoutConfig(new Type(null)); } + if ($matcher instanceof MinType) { + return $this->formatMinTypeMatcher($matcher); + } + if ($matcher instanceof MaxType) { + return $this->formatMaxTypeMatcher($matcher); + } if (in_array($matcher->getType(), self::MATCHERS_WITHOUT_CONFIG)) { return $this->formatMatchersWithoutConfig($matcher); @@ -47,6 +57,9 @@ public function format(MatcherInterface $matcher): string if ($matcher instanceof AbstractDateTime || $matcher instanceof Regex || $matcher instanceof ContentType) { return $this->formatMatchersWithConfig($matcher); } + if ($matcher instanceof MatchAll) { + return $this->formatMatchAllMatchers($matcher); + } throw new MatcherNotSupportedException(sprintf("Matcher '%s' is not supported by plugin", $matcher->getType())); } @@ -90,10 +103,28 @@ private function formatEachKeyAndEachValueMatchers(EachKey|EachValue $matcher): return sprintf('%s(%s)', $matcher->getType(), $this->format($rule)); } + private function formatMatchAllMatchers(MatchAll $matcher): string + { + return implode(', ', array_map(fn (MatcherInterface $rule) => $this->format($rule), $matcher->getMatchers())); + } + + private function formatMinTypeMatcher(MinType $matcher): string + { + return sprintf('atLeast(%s)', $this->normalize($matcher->getAttributes()->get('min'))); + } + + private function formatMaxTypeMatcher(MaxType $matcher): string + { + return sprintf('atMost(%s)', $this->normalize($matcher->getAttributes()->get('max'))); + } + private function normalize(mixed $value): string { + if (is_string($value) && str_contains($value, "'")) { + throw new InvalidValueException(sprintf('String value "%s" should not contains single quote', $value)); + } return match (gettype($value)) { - 'string' => sprintf("'%s'", str_replace("'", "\\'", $value)), + 'string' => sprintf("'%s'", $value), 'boolean' => $value ? 'true' : 'false', 'integer' => (string) $value, 'double' => (string) $value, diff --git a/src/PhpPact/Consumer/Matcher/Matcher.php b/src/PhpPact/Consumer/Matcher/Matcher.php index 0ddcf475..0cca8bb8 100644 --- a/src/PhpPact/Consumer/Matcher/Matcher.php +++ b/src/PhpPact/Consumer/Matcher/Matcher.php @@ -18,6 +18,7 @@ use PhpPact\Consumer\Matcher\Matchers\Equality; use PhpPact\Consumer\Matcher\Matchers\Includes; use PhpPact\Consumer\Matcher\Matchers\Integer; +use PhpPact\Consumer\Matcher\Matchers\MatchAll; use PhpPact\Consumer\Matcher\Matchers\MatchingField; use PhpPact\Consumer\Matcher\Matchers\MaxType; use PhpPact\Consumer\Matcher\Matchers\MinMaxType; @@ -426,7 +427,7 @@ public function contentType(string $contentType): MatcherInterface * Allows defining matching rules to apply to the keys in a map * * @param array $values - * @param array $rules + * @param MatcherInterface[] $rules */ public function eachKey(array $values, array $rules): MatcherInterface { @@ -437,7 +438,7 @@ public function eachKey(array $values, array $rules): MatcherInterface * Allows defining matching rules to apply to the values in a collection. For maps, delgates to the Values matcher. * * @param array $values - * @param array $rules + * @param MatcherInterface[] $rules */ public function eachValue(array $values, array $rules): MatcherInterface { @@ -466,6 +467,24 @@ public function matchingField(string $fieldName): MatcherInterface return $this->withFormatter(new MatchingField($fieldName)); } + /** + * @param MatcherInterface[] $matchers + */ + public function matchAll(mixed $value, array $matchers): MatcherInterface + { + return $this->withFormatter(new MatchAll($value, $matchers)); + } + + public function atLeast(int $min): MatcherInterface + { + return $this->atLeastLike(null, $min); + } + + public function atMost(int $max): MatcherInterface + { + return $this->atMostLike(null, $max); + } + private function withFormatter(MatcherInterface&FormatterAwareInterface $matcher): MatcherInterface { if ($this->formatter) { diff --git a/src/PhpPact/Consumer/Matcher/Matchers/CombinedMatchers.php b/src/PhpPact/Consumer/Matcher/Matchers/CombinedMatchers.php new file mode 100644 index 00000000..8fbd8704 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/CombinedMatchers.php @@ -0,0 +1,42 @@ +|object $value + * @param MatcherInterface[] $matchers + */ + public function __construct(private object|array $value, array $matchers) + { + foreach ($matchers as $matcher) { + if ($matcher instanceof CombinedMatchersInterface) { + throw new MatcherNotSupportedException('Nested combined matchers are not supported'); + } + $this->addMatcher($matcher); + } + $this->setFormatter(new CombinedMatchersFormatter()); + } + + protected function getAttributesData(): array + { + return []; + } + + /** + * @return array|object + */ + public function getValue(): object|array + { + return $this->value; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Matchers/MatchAll.php b/src/PhpPact/Consumer/Matcher/Matchers/MatchAll.php new file mode 100644 index 00000000..0da295fc --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/MatchAll.php @@ -0,0 +1,11 @@ +matchers[] = $matcher; + } + + /** + * @return MatcherInterface[] + */ + public function getMatchers(): array + { + return $this->matchers; + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Formatters/CombinedMatchersFormatterTest.php b/tests/PhpPact/Consumer/Matcher/Formatters/CombinedMatchersFormatterTest.php new file mode 100644 index 00000000..f3d1c710 --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Formatters/CombinedMatchersFormatterTest.php @@ -0,0 +1,30 @@ + 123], [ + new MinType([], 1), + new MaxType([], 2), + new EachKey([], [new Includes('test')]), + new EachValue([], [new Integer(123)]), + ]); + $formatter = new CombinedMatchersFormatter(); + $jsonEncoded = json_encode($formatter->format($matcher)); + $this->assertIsString($jsonEncoded); + $this->assertJsonStringEqualsJsonString('{"pact:matcher:type":[{"pact:matcher:type":"type","min":1,"value":[]},{"pact:matcher:type":"type","max":2,"value":[]},{"pact:matcher:type":"eachKey","rules":[{"pact:matcher:type":"include","value":"test"}],"value":[]},{"pact:matcher:type":"eachValue","rules":[{"pact:matcher:type":"integer","value":123}],"value":[]}],"value":{"test 123":123}}', $jsonEncoded); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Formatters/PluginFormatterTest.php b/tests/PhpPact/Consumer/Matcher/Formatters/PluginFormatterTest.php index bd4080b4..aef59c39 100644 --- a/tests/PhpPact/Consumer/Matcher/Formatters/PluginFormatterTest.php +++ b/tests/PhpPact/Consumer/Matcher/Formatters/PluginFormatterTest.php @@ -3,6 +3,7 @@ namespace PhpPactTest\Consumer\Matcher\Formatters; use PhpPact\Consumer\Matcher\Exception\GeneratorNotRequiredException; +use PhpPact\Consumer\Matcher\Exception\InvalidValueException; use PhpPact\Consumer\Matcher\Exception\MatcherNotSupportedException; use PhpPact\Consumer\Matcher\Exception\MatchingExpressionException; use PhpPact\Consumer\Matcher\Formatters\PluginFormatter; @@ -18,6 +19,7 @@ use PhpPact\Consumer\Matcher\Matchers\Equality; use PhpPact\Consumer\Matcher\Matchers\Includes; use PhpPact\Consumer\Matcher\Matchers\Integer; +use PhpPact\Consumer\Matcher\Matchers\MatchAll; use PhpPact\Consumer\Matcher\Matchers\MatchingField; use PhpPact\Consumer\Matcher\Matchers\MaxType; use PhpPact\Consumer\Matcher\Matchers\MinMaxType; @@ -68,8 +70,6 @@ public function testInvalidRules(EachKey|EachValue $matcher): void #[TestWith([new Type(new \stdClass()), 'object'])] #[TestWith([new Type(['key' => 'value']), 'array'])] - #[TestWith([new MinType(['Example value'], 1), 'array'])] - #[TestWith([new MaxType(['Example value'], 2), 'array'])] #[TestWith([new MinMaxType(['Example value'], 1, 2), 'array'])] public function testInvalidValue(MatcherInterface $matcher, string $type): void { @@ -78,6 +78,14 @@ public function testInvalidValue(MatcherInterface $matcher, string $type): void $this->formatter->format($matcher); } + public function testInvalidString(): void + { + $value = "string contains single quote (')"; + $this->expectException(InvalidValueException::class); + $this->expectExceptionMessage(sprintf('String value "%s" should not contains single quote', $value)); + $this->formatter->format(new Type($value)); + } + #[TestWith([new Values([1, 2, 3])])] #[TestWith([new ArrayContains([new Equality(1)])])] #[TestWith([new StatusCode('clientError', 405)])] @@ -106,6 +114,9 @@ public function testNotSupportedMatcher(MatcherInterface $matcher): void #[TestWith([new Regex('\\w{3}\\d+', 'abc123'), '"matching(regex, \'\\\\w{3}\\\\d+\', \'abc123\')"'])] #[TestWith([new ContentType('application/xml'), '"matching(contentType, \'application\/xml\', \'application\/xml\')"'])] #[TestWith([new NullValue(), '"matching(type, null)"'])] + #[TestWith([new MinType(['Example value'], 1), '"atLeast(1)"'])] + #[TestWith([new MaxType(['Example value'], 2), '"atMost(2)"'])] + #[TestWith([new MatchAll(['abc' => 1, 'def' => 234], [new MinType([null], 1), new MaxType([null], 2), new EachKey(["doesn't matter"], [new Regex('\w+', 'abc')]), new EachValue(["doesn't matter"], [new Type(100)])]), '"atLeast(1), atMost(2), eachKey(matching(regex, \'\\\\w+\', \'abc\')), eachValue(matching(type, 100))"'])] public function testFormat(MatcherInterface $matcher, string $json): void { $this->assertSame($json, json_encode($this->formatter->format($matcher))); diff --git a/tests/PhpPact/Consumer/Matcher/MatcherTest.php b/tests/PhpPact/Consumer/Matcher/MatcherTest.php index aed021ab..e6b21375 100644 --- a/tests/PhpPact/Consumer/Matcher/MatcherTest.php +++ b/tests/PhpPact/Consumer/Matcher/MatcherTest.php @@ -22,6 +22,7 @@ use PhpPact\Consumer\Matcher\Matchers\Equality; use PhpPact\Consumer\Matcher\Matchers\Includes; use PhpPact\Consumer\Matcher\Matchers\Integer; +use PhpPact\Consumer\Matcher\Matchers\MatchAll; use PhpPact\Consumer\Matcher\Matchers\MatchingField; use PhpPact\Consumer\Matcher\Matchers\MaxType; use PhpPact\Consumer\Matcher\Matchers\MinMaxType; @@ -354,6 +355,21 @@ public function testMatchingField(): void $this->assertInstanceOf(MatchingField::class, $this->matcher->matchingField('address')); } + public function testMatchAll(): void + { + $this->assertInstanceOf(MatchAll::class, $this->matcher->matchAll(['key' => 'value'], [])); + } + + public function testAtLeast(): void + { + $this->assertInstanceOf(MinType::class, $this->matcher->atLeast(123)); + } + + public function testAtMost(): void + { + $this->assertInstanceOf(MaxType::class, $this->matcher->atMost(123)); + } + public function testWithFormatter(): void { $uuid = $this->matcher->uuid(); diff --git a/tests/PhpPact/Consumer/Matcher/Matchers/MatchAllTest.php b/tests/PhpPact/Consumer/Matcher/Matchers/MatchAllTest.php new file mode 100644 index 00000000..9840d876 --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Matchers/MatchAllTest.php @@ -0,0 +1,28 @@ +expectException(MatcherNotSupportedException::class); + $this->expectExceptionMessage('Nested combined matchers are not supported'); + new MatchAll([], [new MatchAll([], [])]); + } + + public function testSerialize(): void + { + $matcher = new MatchAll(['key' => 123], [new MinType([], 1), new MaxType([], 2), new EachKey([], [new Type('test')]), new EachValue([], [new Type(123)])]); + $this->assertSame('{"pact:matcher:type":[{"pact:matcher:type":"type","min":1,"value":[]},{"pact:matcher:type":"type","max":2,"value":[]},{"pact:matcher:type":"eachKey","rules":[{"pact:matcher:type":"type","value":"test"}],"value":[]},{"pact:matcher:type":"eachValue","rules":[{"pact:matcher:type":"type","value":123}],"value":[]}],"value":{"key":123}}', json_encode($matcher)); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Matchers/MatchingFieldTest.php b/tests/PhpPact/Consumer/Matcher/Matchers/MatchingFieldTest.php index 4ed395f1..5034462b 100644 --- a/tests/PhpPact/Consumer/Matcher/Matchers/MatchingFieldTest.php +++ b/tests/PhpPact/Consumer/Matcher/Matchers/MatchingFieldTest.php @@ -2,6 +2,7 @@ namespace PhpPactTest\Consumer\Matcher\Matchers; +use PhpPact\Consumer\Matcher\Exception\InvalidValueException; use PhpPact\Consumer\Matcher\Exception\MatcherNotSupportedException; use PhpPact\Consumer\Matcher\Formatters\MinimalFormatter; use PhpPact\Consumer\Matcher\Formatters\PluginFormatter; @@ -16,14 +17,21 @@ class MatchingFieldTest extends TestCase { - #[TestWith(['person', "\"matching($'person')\""])] - #[TestWith(["probably doesn't work", "\"matching($'probably doesn\\\\'t work')\""])] - public function testSerialize(string $fieldName, string $json): void + public function testInvalidField(): void { - $matcher = new MatchingField($fieldName); + $this->expectException(InvalidValueException::class); + $this->expectExceptionMessage('String value "probably doesn\'t work" should not contains single quote'); + $matcher = new MatchingField("probably doesn't work"); + $matcher->setFormatter(new PluginFormatter()); + json_encode($matcher); + } + + public function testSerialize(): void + { + $matcher = new MatchingField('person'); $matcher->setFormatter(new PluginFormatter()); $this->assertSame( - $json, + "\"matching($'person')\"", json_encode($matcher) ); }