diff --git a/src/Illuminate/Testing/Fluent/AssertableJson.php b/src/Illuminate/Testing/Fluent/AssertableJson.php index 07104e114990..3d2496fac71b 100644 --- a/src/Illuminate/Testing/Fluent/AssertableJson.php +++ b/src/Illuminate/Testing/Fluent/AssertableJson.php @@ -52,13 +52,13 @@ protected function __construct(array $props, string $path = null) * @param string $key * @return string */ - protected function dotPath(string $key): string + protected function dotPath(string $key = ''): string { if (is_null($this->path)) { return $key; } - return implode('.', [$this->path, $key]); + return rtrim(implode('.', [$this->path, $key]), '.'); } /** @@ -93,6 +93,30 @@ protected function scope(string $key, Closure $callback): self return $this; } + /** + * Instantiate a new "scope" on the first child element. + * + * @param \Closure $callback + * @return $this + */ + public function first(Closure $callback): self + { + $props = $this->prop(); + + $path = $this->dotPath(); + + PHPUnit::assertNotEmpty($props, $path === '' + ? 'Cannot scope directly onto the first element of the root level because it is empty.' + : sprintf('Cannot scope directly onto the first element of property [%s] because it is empty.', $path) + ); + + $key = array_keys($props)[0]; + + $this->interactsWith($key); + + return $this->scope($key, $callback); + } + /** * Create a new instance from an array. * diff --git a/src/Illuminate/Testing/Fluent/Concerns/Has.php b/src/Illuminate/Testing/Fluent/Concerns/Has.php index dd91ee618790..979b9afa3625 100644 --- a/src/Illuminate/Testing/Fluent/Concerns/Has.php +++ b/src/Illuminate/Testing/Fluent/Concerns/Has.php @@ -11,12 +11,26 @@ trait Has /** * Assert that the prop is of the expected size. * - * @param string $key - * @param int $length + * @param string|int $key + * @param int|null $length * @return $this */ - protected function count(string $key, int $length): self + public function count($key, int $length = null): self { + if (is_null($length)) { + $path = $this->dotPath(); + + PHPUnit::assertCount( + $key, + $this->prop(), + $path + ? sprintf('Property [%s] does not have the expected size.', $path) + : sprintf('Root level does not have the expected size.') + ); + + return $this; + } + PHPUnit::assertCount( $length, $this->prop($key), @@ -29,15 +43,19 @@ protected function count(string $key, int $length): self /** * Ensure that the given prop exists. * - * @param string $key - * @param null $value - * @param \Closure|null $scope + * @param string|int $key + * @param int|\Closure|null $length + * @param \Closure|null $callback * @return $this */ - public function has(string $key, $value = null, Closure $scope = null): self + public function has($key, $length = null, Closure $callback = null): self { $prop = $this->prop(); + if (is_int($key) && is_null($length)) { + return $this->count($key); + } + PHPUnit::assertTrue( Arr::has($prop, $key), sprintf('Property [%s] does not exist.', $this->dotPath($key)) @@ -45,25 +63,20 @@ public function has(string $key, $value = null, Closure $scope = null): self $this->interactsWith($key); - // When all three arguments are provided this indicates a short-hand expression - // that combines both a `count`-assertion, followed by directly creating the - // `scope` on the first element. We can simply handle this correctly here. - if (is_int($value) && ! is_null($scope)) { - $prop = $this->prop($key); - $path = $this->dotPath($key); - - PHPUnit::assertTrue($value > 0, sprintf('Cannot scope directly onto the first entry of property [%s] when asserting that it has a size of 0.', $path)); - PHPUnit::assertIsArray($prop, sprintf('Direct scoping is unsupported for non-array like properties such as [%s].', $path)); - - $this->count($key, $value); + if (is_int($length) && ! is_null($callback)) { + return $this->has($key, function (self $scope) use ($length, $callback) { + return $scope->count($length) + ->first($callback) + ->etc(); + }); + } - return $this->scope($key.'.'.array_keys($prop)[0], $scope); + if (is_callable($length)) { + return $this->scope($key, $length); } - if (is_callable($value)) { - $this->scope($key, $value); - } elseif (! is_null($value)) { - $this->count($key, $value); + if (! is_null($length)) { + return $this->count($key, $length); } return $this; @@ -129,7 +142,7 @@ public function missing(string $key): self * @param string $key * @return string */ - abstract protected function dotPath(string $key): string; + abstract protected function dotPath(string $key = ''): string; /** * Marks the property as interacted. @@ -155,4 +168,19 @@ abstract protected function prop(string $key = null); * @return $this */ abstract protected function scope(string $key, Closure $callback); + + /** + * Disables the interaction check. + * + * @return $this + */ + abstract public function etc(); + + /** + * Instantiate a new "scope" on the first element. + * + * @param \Closure $callback + * @return $this + */ + abstract public function first(Closure $callback); } diff --git a/src/Illuminate/Testing/Fluent/Concerns/Matching.php b/src/Illuminate/Testing/Fluent/Concerns/Matching.php index 3cf1f82c471c..f64519023e43 100644 --- a/src/Illuminate/Testing/Fluent/Concerns/Matching.php +++ b/src/Illuminate/Testing/Fluent/Concerns/Matching.php @@ -87,7 +87,7 @@ protected function ensureSorted(&$value): void * @param string $key * @return string */ - abstract protected function dotPath(string $key): string; + abstract protected function dotPath(string $key = ''): string; /** * Ensure that the given prop exists. diff --git a/src/Illuminate/Testing/TestResponse.php b/src/Illuminate/Testing/TestResponse.php index aeee4fe59c7e..1d46a0ac5284 100644 --- a/src/Illuminate/Testing/TestResponse.php +++ b/src/Illuminate/Testing/TestResponse.php @@ -523,7 +523,7 @@ public function assertJson($value, $strict = false) $value($assert); - if ($strict) { + if (Arr::isAssoc($assert->toArray())) { $assert->interacted(); } } diff --git a/tests/Testing/Fluent/AssertTest.php b/tests/Testing/Fluent/AssertTest.php index acafd07589d2..20e03785a037 100644 --- a/tests/Testing/Fluent/AssertTest.php +++ b/tests/Testing/Fluent/AssertTest.php @@ -58,7 +58,7 @@ public function testAssertHasFailsWhenNestedPropMissing() $assert->has('example.another'); } - public function testAssertCountItemsInProp() + public function testAssertHasCountItemsInProp() { $assert = AssertableJson::fromArray([ 'bar' => [ @@ -70,7 +70,7 @@ public function testAssertCountItemsInProp() $assert->has('bar', 2); } - public function testAssertCountFailsWhenAmountOfItemsDoesNotMatch() + public function testAssertHasCountFailsWhenAmountOfItemsDoesNotMatch() { $assert = AssertableJson::fromArray([ 'bar' => [ @@ -85,7 +85,7 @@ public function testAssertCountFailsWhenAmountOfItemsDoesNotMatch() $assert->has('bar', 1); } - public function testAssertCountFailsWhenPropMissing() + public function testAssertHasCountFailsWhenPropMissing() { $assert = AssertableJson::fromArray([ 'bar' => [ @@ -111,6 +111,90 @@ public function testAssertHasFailsWhenSecondArgumentUnsupportedType() $assert->has('bar', 'invalid'); } + public function testAssertHasOnlyCounts() + { + $assert = AssertableJson::fromArray([ + 'foo', + 'bar', + 'baz', + ]); + + $assert->has(3); + } + + public function testAssertHasOnlyCountFails() + { + $assert = AssertableJson::fromArray([ + 'foo', + 'bar', + 'baz', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Root level does not have the expected size.'); + + $assert->has(2); + } + + public function testAssertHasOnlyCountFailsScoped() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'baz' => 'example', + 'prop' => 'value', + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [bar] does not have the expected size.'); + + $assert->has('bar', function ($bar) { + $bar->has(3); + }); + } + + public function testAssertCount() + { + $assert = AssertableJson::fromArray([ + 'foo', + 'bar', + 'baz', + ]); + + $assert->count(3); + } + + public function testAssertCountFails() + { + $assert = AssertableJson::fromArray([ + 'foo', + 'bar', + 'baz', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Root level does not have the expected size.'); + + $assert->count(2); + } + + public function testAssertCountFailsScoped() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'baz' => 'example', + 'prop' => 'value', + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [bar] does not have the expected size.'); + + $assert->has('bar', function ($bar) { + $bar->count(3); + }); + } + public function testAssertMissing() { $assert = AssertableJson::fromArray([ @@ -421,7 +505,7 @@ public function testScopeShorthandFailsWhenAssertingZeroItems() ]); $this->expectException(AssertionFailedError::class); - $this->expectExceptionMessage('Cannot scope directly onto the first entry of property [bar] when asserting that it has a size of 0.'); + $this->expectExceptionMessage('Property [bar] does not have the expected size.'); $assert->has('bar', 0, function (AssertableJson $item) { $item->where('key', 'first'); @@ -445,6 +529,64 @@ public function testScopeShorthandFailsWhenAmountOfItemsDoesNotMatch() }); } + public function testFirstScope() + { + $assert = AssertableJson::fromArray([ + 'foo' => [ + 'key' => 'first', + ], + 'bar' => [ + 'key' => 'second', + ], + ]); + + $assert->first(function (AssertableJson $item) { + $item->where('key', 'first'); + }); + } + + public function testFirstScopeFailsWhenNoProps() + { + $assert = AssertableJson::fromArray([]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Cannot scope directly onto the first element of the root level because it is empty.'); + + $assert->first(function (AssertableJson $item) { + // + }); + } + + public function testFirstNestedScopeFailsWhenNoProps() + { + $assert = AssertableJson::fromArray([ + 'foo' => [], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Cannot scope directly onto the first element of property [foo] because it is empty.'); + + $assert->has('foo', function (AssertableJson $assert) { + $assert->first(function (AssertableJson $item) { + // + }); + }); + } + + public function testFirstScopeFailsWhenPropSingleValue() + { + $assert = AssertableJson::fromArray([ + 'foo' => 'bar', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [foo] is not scopeable.'); + + $assert->first(function (AssertableJson $item) { + // + }); + } + public function testFailsWhenNotInteractingWithAllPropsInScope() { $assert = AssertableJson::fromArray([ diff --git a/tests/Testing/TestResponseTest.php b/tests/Testing/TestResponseTest.php index b6a1e5531a54..f35f13121e1e 100644 --- a/tests/Testing/TestResponseTest.php +++ b/tests/Testing/TestResponseTest.php @@ -587,16 +587,28 @@ public function testAssertJsonWithFluent() }); } - public function testAssertJsonWithFluentStrict() + public function testAssertJsonWithFluentFailsWhenNotInteractingWithAllProps() { - $response = TestResponse::fromBaseResponse(new Response(new JsonSerializableSingleResourceStub)); + $response = TestResponse::fromBaseResponse(new Response(new JsonSerializableMixedResourcesStub)); $this->expectException(AssertionFailedError::class); $this->expectExceptionMessage('Unexpected properties were found on the root level.'); $response->assertJson(function (AssertableJson $json) { - $json->where('0.foo', 'foo 0'); - }, true); + $json->where('foo', 'bar'); + }); + } + + public function testAssertJsonWithFluentSkipsInteractionWhenTopLevelKeysNonAssociative() + { + $response = TestResponse::fromBaseResponse(new Response([ + ['foo' => 'bar'], + ['foo' => 'baz'], + ])); + + $response->assertJson(function (AssertableJson $json) { + // + }); } public function testAssertSimilarJsonWithMixed()