diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index e31bba4fd..27a98c1eb 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -116,6 +116,16 @@ public static function updateOrCreate(array $find, array $update): self return $model->save(); } + public function refresh(): self + { + $model = self::find(id: $this->id)->first(); + foreach (new ClassReflector($model)->getPublicProperties() as $property) { + $property->setValue($this, $property->getValue($model)); + } + + return $this; + } + public function __get(string $name): mixed { $property = PropertyReflector::fromParts($this, $name); diff --git a/src/Tempest/Framework/Testing/Http/TestResponseHelper.php b/src/Tempest/Framework/Testing/Http/TestResponseHelper.php index e1aa959a3..07a1c229d 100644 --- a/src/Tempest/Framework/Testing/Http/TestResponseHelper.php +++ b/src/Tempest/Framework/Testing/Http/TestResponseHelper.php @@ -9,6 +9,7 @@ use PHPUnit\Framework\Assert; use Tempest\Http\Cookie\CookieManager; use Tempest\Http\Response; +use Tempest\Http\Responses\Invalid; use Tempest\Http\Session\Session; use Tempest\Http\Status; use Tempest\Validation\Rule; @@ -329,9 +330,152 @@ public function assertViewModel(string $expected, ?Closure $callback = null): se return $this; } + /** + * Assert the response body is an exact match to the given array. + * + * The keys can also be specified using dot notation. + * + * ### Example + * ``` + * // build the expected array with dot notation + * $this->http->get(uri([BookController::class, 'index'])) + * ->assertJson([ + * 'id' => 1, + * 'title' => 'Timeline Taxi', + * 'author.name' => 'Brent', + * ]); + * + * // build the expected array with a normal array + * $this->http->get(uri([BookController::class, 'index'])) + * ->assertJson([ + * 'id' => 1, + * 'title' => 'Timeline Taxi', + * 'author' => [ + * 'name' => 'Brent', + * ], + * ]); + * ``` + * + * @param array $expected + */ + public function assertJson(array $expected): self + { + Assert::assertEquals( + expected: arr($expected)->undot()->toArray(), + actual: $this->response->body, + ); + + return $this; + } + + /** + * Asserts the response contains the given keys. + * + * The keys can also be specified using dot notation. + * + * ### Example + * ``` + * $this->http->get(uri([BookController::class, 'index'])) + * ->assertJsonHasKeys('id', 'title', 'author.name'); + * ``` + */ + public function assertJsonHasKeys(string ...$keys): self + { + foreach ($keys as $key) { + Assert::assertArrayHasKey($key, arr($this->response->body)->dot()); + } + + return $this; + } + + /** + * Asserts the response contains the given keys and values. + * + * The keys can also be specified using dot notation. + * + * ### Example + * ``` + * $this->http->get(uri([BookController::class, 'index'])) + * ->assertJsonContains([ + * 'id' => 1, + * 'title' => 'Timeline Taxi', + * ]) + * ->assertJsonContains(['author' => ['name' => 'Brent']]) + * ->assertJsonContains(['author.name' => 'Brent']); + * ``` + * + * @template TKey of array-key + * @template TValue + * + * @param array $expected + */ + public function assertJsonContains(array $expected): self + { + foreach (arr($expected)->undot() as $key => $value) { + Assert::assertEquals($this->response->body[$key], $value); + } + + return $this; + } + + /** + * Asserts the response contains the given JSON validation errors. + * + * The keys can also be specified using dot notation. + * + * ### Example + * ``` + * $this->http->get(uri([BookController::class, 'index'])) + * ->assertJsonValidationErrors([ + * 'title' => 'The title field is required.', + * ]); + * ``` + * + * @param array $expectedErrors + */ + public function assertHasJsonValidationErrors(array $expectedErrors): self + { + Assert::assertInstanceOf(Invalid::class, $this->response); + Assert::assertContains($this->response->status, [Status::BAD_REQUEST, Status::FOUND]); + Assert::assertNotNull($this->response->getHeader('x-validation')); + + $session = get(Session::class); + $validationRules = arr($session->get(Session::VALIDATION_ERRORS))->dot(); + + $dottedExpectedErrors = arr($expectedErrors)->dot(); + arr($dottedExpectedErrors) + ->each(fn ($expectedErrorValue, $expectedErrorKey) => Assert::assertEquals( + $expectedErrorValue, + $validationRules->get($expectedErrorKey)->message(), + )); + + return $this; + } + + /** + * Asserts the response does not contain any JSON validation errors. + * + * ### Example + * ``` + * $this->http->get(uri([BookController::class, 'index'])) + * ->assertHasNoJsonValidationErrors(); + * ``` + */ + public function assertHasNoJsonValidationErrors(): self + { + Assert::assertNotContains($this->response->status, [Status::BAD_REQUEST, Status::FOUND]); + Assert::assertNotInstanceOf(Invalid::class, $this->response); + Assert::assertNull($this->response->getHeader('x-validation')); + + return $this; + } + public function dd(): void { - // @phpstan-ignore disallowed.function + /** + * @noinspection ForgottenDebugOutputInspection + * @phpstan-ignore disallowed.function + */ dd($this->response); // @mago-expect best-practices/no-debug-symbols } } diff --git a/tests/Fixtures/Controllers/RequestItemForValidationController.php b/tests/Fixtures/Controllers/RequestItemForValidationController.php deleted file mode 100644 index 6da6a3499..000000000 --- a/tests/Fixtures/Controllers/RequestItemForValidationController.php +++ /dev/null @@ -1,13 +0,0 @@ -load('author'); + + return new Json([ + 'id' => $book->id->id, + 'title' => $book->title, + 'author' => [ + 'id' => $book->author->id->id, + 'name' => $book->author->name, + ], + ]); + } + + #[Post(uri: '/test-validation-responses-json/{book}')] + public function updateBook(BookRequest $request, Book $book): Response + { + $book->load('author'); + + $book->update(title: $request->get('title')); + + return new Json([ + 'id' => $book->id->id, + 'title' => $book->title, + 'author' => [ + 'id' => $book->author->id->id, + 'name' => $book->author->name, + ], + 'chapters' => $book->chapters, + 'isbn' => $book->isbn, + ]); + } } diff --git a/tests/Fixtures/Requests/BookRequest.php b/tests/Fixtures/Requests/BookRequest.php new file mode 100644 index 000000000..a3fffd463 --- /dev/null +++ b/tests/Fixtures/Requests/BookRequest.php @@ -0,0 +1,28 @@ +assertEquals($values, $data); }); } + + public function test_update_book(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + $book = Book::create( + title: 'Timeline Taxi', + author: Author::create(name: 'Brent'), + ); + + $this->http + ->post( + uri([ValidationController::class, 'updateBook'], book: 1), + body: ['title' => 'Beyond the Odyssee'], + ) + ->assertOk() + ->assertHasNoJsonValidationErrors(); + + $book->refresh(); + + $this->assertSame($book->title, 'Beyond the Odyssee'); + } + + public function test_failing_post_request(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + Book::create( + title: 'Timeline Taxi', + author: Author::create(name: 'Brent'), + ); + + $this->http + ->post( + uri([ValidationController::class, 'updateBook'], book: 1), + body: ['book' => ['title' => 1]], + ) + ->assertHasJsonValidationErrors(['title' => ['Value should be between 1 and 120']]); + + $this->assertSame('Timeline Taxi', Book::find(id: 1)->first()->title); + } } diff --git a/tests/Integration/Testing/Http/TestResponseHelperTest.php b/tests/Integration/Testing/Http/TestResponseHelperTest.php index 53f82d0f6..4b51eb781 100644 --- a/tests/Integration/Testing/Http/TestResponseHelperTest.php +++ b/tests/Integration/Testing/Http/TestResponseHelperTest.php @@ -181,6 +181,36 @@ public function test_assert_status_fails_when_status_does_not_match(Status $expe $helper->assertStatus($expectedStatus); } + public function test_assert_json_has_keys(): void + { + $helper = new TestResponseHelper( + new GenericResponse(status: Status::OK, body: ['title' => 'Timeline Taxi', 'author' => ['name' => 'John']]), + ); + + $helper->assertJsonHasKeys('title', 'author.name'); + } + + public function test_assert_json_contains(): void + { + $helper = new TestResponseHelper( + new GenericResponse(status: Status::OK, body: ['title' => 'Timeline Taxi', 'author' => ['name' => 'John']]), + ); + + $helper->assertJsonContains(['title' => 'Timeline Taxi']); + $helper->assertJsonContains(['author' => ['name' => 'John']]); + $helper->assertJsonContains(['author.name' => 'John']); + } + + public function test_assert_json(): void + { + $helper = new TestResponseHelper( + new GenericResponse(status: Status::OK, body: ['title' => 'Timeline Taxi', 'author' => ['name' => 'John']]), + ); + + $helper->assertJson(['title' => 'Timeline Taxi', 'author' => ['name' => 'John']]); + $helper->assertJson(['title' => 'Timeline Taxi', 'author.name' => 'John']); + } + public static function provide_assert_status_cases(): iterable { return [