diff --git a/docs/.vitepress/sidebar.ts b/docs/.vitepress/sidebar.ts index 855dfc670..e3175e4f6 100644 --- a/docs/.vitepress/sidebar.ts +++ b/docs/.vitepress/sidebar.ts @@ -101,7 +101,8 @@ export function sidebar(): DefaultTheme.SidebarItem[] { { text: 'Sharp Context', link: '/guide/context.md' }, { text: 'Sharp built-in solution for uploads', link: '/guide/sharp-uploads.md' }, { text: 'Data localization in Form and Show Page', link: '/guide/data-localization.md' }, - { text: 'Testing with Sharp', link: '/guide/testing-with-sharp.md' }, + { text: 'Testing', link: '/guide/testing.md' }, + { text: 'Testing (legacy API)', link: '/guide/testing-legacy.md' }, { text: 'Artisan Generators', link: '/guide/artisan-generators.md' }, { text: 'Style & Visual Theme', link: '/guide/style-visual-theme.md' } ] diff --git a/docs/guide/testing-with-sharp.md b/docs/guide/testing-legacy.md similarity index 97% rename from docs/guide/testing-with-sharp.md rename to docs/guide/testing-legacy.md index 330826f28..c21095edf 100644 --- a/docs/guide/testing-with-sharp.md +++ b/docs/guide/testing-legacy.md @@ -1,4 +1,8 @@ -# Testing with Sharp +# Testing with Sharp (legacy API) + +::: warning +This page documents the old Testing API, we recommend using the new [Testing API](/guide/testing). +::: Sharp provides a few assertions and helpers to help you test your Sharp code. diff --git a/docs/guide/testing.md b/docs/guide/testing.md new file mode 100644 index 000000000..83bc4541e --- /dev/null +++ b/docs/guide/testing.md @@ -0,0 +1,248 @@ +# Testing + +Sharp provides a fluent testing API to help you test your Sharp code. These assertions and helpers are designed to be used in Feature tests. + +## The `SharpAssertions` trait + +To use Sharp's testing helpers, include the `Code16\Sharp\Utils\Testing\SharpAssertions` trait in your TestCase class: + +```php +use Code16\Sharp\Utils\Testing\SharpAssertions; + +abstract class TestCase extends BaseTestCase +{ + use SharpAssertions; + + // ... +} +``` + +or in `Pest.php`: + +```php +use Code16\Sharp\Utils\Testing\SharpAssertions; + +pest() + ->extend(\Tests\TestCase::class) + ->use(SharpAssertions::class) +``` + +## Authentication + +### `loginAsSharpUser($user)` + +Sharp provides a helper to log in a user. By default, it will use the `SharpAssertions` internal logic to ensure the user is authorized to access Sharp. + +```php +it('allows the user to access the list', function () { + $user = User::factory()->create(); + + $this + ->loginAsSharpUser($user) + ->sharpList(Post::class) + ->get() + ->assertOk(); +}); +``` + +## Testing Entity Lists + +Use `sharpList()` to test your Entity Lists. + +### `sharpList(string $entityKey)` + +Starts a fluent interaction with an Entity List. + +```php +$this->sharpList(Post::class) + ->get() + ->assertOk() + ->assertListCount(3) + ->assertListContains(['title' => 'My first post']); +``` + +### Filtering the list + +You can use `withFilter()` to apply filters to the list before calling `get()` or a command. + +```php +$this->sharpList(Post::class) + ->withFilter(CategoryFilter::class, 1) + ->get() + ->assertOk(); +``` + +### Entity Commands + +You can call an Entity Command directly from the list: + +```php +$this->sharpList(Post::class) + ->entityCommand(ExportPosts::class) + ->post() + ->assertOk() + ->assertReturnsDownload('posts.csv'); +``` + +If the command has a form, you can test it: + +```php +$this->sharpList(Post::class) + ->entityCommand(ExportPosts::class) + ->getForm() + ->post(['format' => 'csv']) + ->assertOk(); +``` + +### Instance Commands + +Similarly, you can call an Instance Command: + +```php +$this->sharpList(Post::class) + ->instanceCommand(PublishPost::class, 1) + ->post() + ->assertOk() + ->assertReturnsReload(); +``` + +### Multi-step Commands (Wizards) + +For commands that have multiple steps, you can use `getNextStepForm()`: + +```php +$this->sharpList(Post::class) + ->entityCommand(MyWizardCommand::class) + ->getForm() + ->post(['step1_data' => 'value']) + ->assertReturnsStep('step2') + ->getNextStepForm() + ->post(['step2_data' => 'value']) + ->assertOk(); +``` + +## Testing Show Pages + +Use `sharpShow()` to test your Show Pages. + +### `sharpShow(string $entityKey, $instanceId)` + +Starts a fluent interaction with a Show Page. + +```php +$this->sharpShow(Post::class, 1) + ->get() + ->assertOk() + ->assertShowData([ + 'title' => 'My first post', + 'author' => 'John Doe' + ]); +``` + +### Instance Commands from Show + +```php +$this->sharpShow(Post::class, 1) + ->instanceCommand(PublishPost::class) + ->post() + ->assertOk(); +``` + +## Testing Forms + +Use `sharpForm()` to test your Forms. + +### `sharpForm(string $entityKey, $instanceId = null)` + +Starts a fluent interaction with a Form. If `$instanceId` is provided, it targets an edit form; otherwise, it targets a creation form. + +### Creating and Updating + +```php +// Create +$this->sharpForm(Post::class) + ->store(['title' => 'New Post']) + ->assertValid() + ->assertRedirect(); + +// Update +$this->sharpForm(Post::class, 1) + ->update(['title' => 'Updated Post']) + ->assertValid() + ->assertRedirect(); +``` + +### Testing the "Creation" or "Edit" request itself + +If you want to test that the form displays correctly: + +```php +$this->sharpForm(Post::class, 1) + ->edit() + ->assertOk() + ->assertFormData(['title' => 'Existing Post']); +``` + +From an `AssertableForm` (the result of `edit()` or `create()`), you can also call `update()` or `store()`: + +```php +$this->sharpForm(Post::class, 1) + ->edit() + ->update(['title' => 'New title']) + ->assertValid(); +``` + +## Testing Dashboards + +Use `sharpDashboard()` to test your Dashboards. + +### `sharpDashboard(string $entityKey)` + +Starts a fluent interaction with a Dashboard. + +```php +$this->sharpDashboard(MyDashboard::class) + ->get() + ->assertOk(); +``` + +### Filtering the dashboard + +```php +$this->sharpDashboard(MyDashboard::class) + ->withFilter(PeriodFilter::class, ['start' => '2023-01-01', 'end' => '2023-01-31']) + ->get() + ->assertOk(); +``` + +### Dashboard Commands + +```php +$this->sharpDashboard(MyDashboard::class) + ->dashboardCommand(RefreshStats::class) + ->post() + ->assertOk(); +``` + +## Testing Embedded Components + +Show Pages can contain embedded Entity Lists or Dashboards. You can test them using `sharpListField()` and `sharpDashboardField()`. + +### `sharpListField(string $entityKey)` + +```php +$this->sharpShow(Post::class, 1) + ->sharpListField(Comment::class) + ->get() + ->assertOk() + ->assertListCount(5); +``` + +### `sharpDashboardField(string $entityKey)` + +```php +$this->sharpShow(User::class, 1) + ->sharpDashboardField(UserStatsDashboard::class) + ->get() + ->assertOk(); +``` diff --git a/docs/guide/upgrading/9.0.md b/docs/guide/upgrading/9.0.md index 96447aa82..73b7e3fc3 100644 --- a/docs/guide/upgrading/9.0.md +++ b/docs/guide/upgrading/9.0.md @@ -404,7 +404,7 @@ All assertions, like for instance `assertSharpHasAuthorization`, were removed be This means you also need to remove all `$this->initSharpAssertions()` calls from your tests. -Of course, the test helpers remain available, see the dedicated [testing documentation](../testing-with-sharp.md). +Of course, the test helpers remain available, see the dedicated [testing documentation](../testing.md). Also take note that the `withSharpCurrentBreadcrumb()` method is now deprecated, in favor of the new `withSharpBreadcrumb()` method also documented in the section linked above. diff --git a/ide.json b/ide.json index 06aefa593..d0bef3472 100644 --- a/ide.json +++ b/ide.json @@ -14,6 +14,17 @@ "parameters": [ 1 ] + }, + { + "classFqn": [ + "Code16\\Sharp\\Utils\\Testing\\Commands\\AssertableCommand" + ], + "methodNames": [ + "assertReturnsView" + ], + "parameters": [ + 1 + ] } ] }, diff --git a/src/Http/Controllers/ShowController.php b/src/Http/Controllers/ShowController.php index e71d9adb1..cb4c595b2 100644 --- a/src/Http/Controllers/ShowController.php +++ b/src/Http/Controllers/ShowController.php @@ -57,6 +57,10 @@ public function show(string $globalFilter, string $parentUri, EntityKey $entityK $this->addPreloadHeadersForShowEntityLists($payload); + if (app()->environment('testing')) { + Inertia::share('_rawData', $showData); + } + return Inertia::render('Show/Show', [ 'show' => $payload, 'breadcrumb' => BreadcrumbData::from([ diff --git a/src/Http/Controllers/SingleShowController.php b/src/Http/Controllers/SingleShowController.php index 925aad556..9583a80d0 100644 --- a/src/Http/Controllers/SingleShowController.php +++ b/src/Http/Controllers/SingleShowController.php @@ -53,6 +53,10 @@ public function show(string $globalFilter, EntityKey $entityKey) $this->addPreloadHeadersForShowEntityLists($payload); + if (app()->environment('testing')) { + Inertia::share('_rawData', $showData); + } + return Inertia::render('Show/Show', [ 'show' => $payload, 'breadcrumb' => BreadcrumbData::from([ diff --git a/src/Show/Fields/SharpShowEntityListField.php b/src/Show/Fields/SharpShowEntityListField.php index 8b6fa64a1..a572f9926 100644 --- a/src/Show/Fields/SharpShowEntityListField.php +++ b/src/Show/Fields/SharpShowEntityListField.php @@ -157,6 +157,7 @@ public function toArray(): array ]), function (array &$options) { $options['endpointUrl'] = route('code16.sharp.api.list', [ + 'globalFilter' => sharp()->context()->globalFilterUrlSegmentValue(), 'entityKey' => $this->entityListKey, 'current_page_url' => request()->url(), ...app(SharpEntityManager::class) diff --git a/src/Utils/Testing/Commands/AssertableCommand.php b/src/Utils/Testing/Commands/AssertableCommand.php new file mode 100644 index 000000000..c605c3c72 --- /dev/null +++ b/src/Utils/Testing/Commands/AssertableCommand.php @@ -0,0 +1,172 @@ +response = $this->post(); + } + + public function assertViewHas(mixed $key, mixed $value = null): static + { + $this->response->original = $this->createdView; + $this->response->assertViewHas($key, $value); + + return $this; + } + + public function assertViewHasAll(mixed $bindings): static + { + $this->response->original = $this->createdView; + $this->response->assertViewHas($bindings); + + return $this; + } + + public function assertViewIs($value) + { + $this->response->original = $this->createdView; + $this->response->assertViewIs($value); + + return $this; + } + + public function assertReturnsView(?string $view = null, ?array $data = null): static + { + $this->response->assertOk()->assertJson(fn (AssertableJson $json) => $json + ->where('action', 'view') + ->etc() + ); + + if ($view) { + $this->assertViewIs($view); + } + + if ($data) { + $this->assertViewHasAll($data); + } + + return $this; + } + + public function assertReturnsInfo(string $message = ''): static + { + $this->response->assertOk()->assertJson(fn (AssertableJson $json) => $json + ->where('action', 'info') + ->when($message)->where('message', $message) + ->etc() + ); + + return $this; + } + + public function assertReturnsLink(string $url = ''): static + { + $this->response->assertOk()->assertJson(fn (AssertableJson $json) => $json + ->where('action', 'link') + ->when($url)->where('link', $url) + ->etc() + ); + + return $this; + } + + public function assertReturnsReload(): static + { + $this->response->assertOk()->assertJson(fn (AssertableJson $json) => $json + ->where('action', 'reload') + ->etc() + ); + + return $this; + } + + public function assertReturnsRefresh(array $ids): static + { + $this->response->assertOk()->assertJson(fn (AssertableJson $json) => $json + ->where('action', 'refresh') + ->etc() + ); + + PHPUnit::assertEqualsCanonicalizing( + $ids, + collect($this->response->json('items'))->pluck($this->commandContainer->getInstanceIdAttribute())->all() + ); + + return $this; + } + + public function assertReturnsStep(?string $step = null): static + { + $this->response->assertOk()->assertJson(fn (AssertableJson $json) => $json + ->where('action', 'step') + ->etc() + ); + + if ($step) { + PHPUnit::assertEquals($step, Str::before($this->response->json('step'), ':')); + } + + return $this; + } + + public function assertReturnsDownload(?string $filename = null): static + { + $this->response->assertOk()->assertStreamed(); + + if ($filename) { + preg_match('/filename="?([^";]+)"?/', $this->response->headers->get('Content-Disposition'), $matches); + PHPUnit::assertEquals($filename, $matches[1] ?? null); + } + + return $this; + } + + public function getNextStepForm(): AssertableCommandForm + { + $this->assertReturnsStep(); + + return new AssertableCommandForm( + post: $this->postCommand, + getForm: $this->getForm, + commandContainer: $this->commandContainer, + step: $this->response->json('step'), + ); + } + + protected function post(): TestResponse + { + Facades\View::creator('*', function (View $view) { + $this->createdView = $view; + }); + + return tap(($this->postCommand)($this->data, $this->step), function () { + Facades\Event::forget('creating: *'); + }); + } +} diff --git a/src/Utils/Testing/Commands/AssertableCommandForm.php b/src/Utils/Testing/Commands/AssertableCommandForm.php new file mode 100644 index 000000000..2ea3c8b27 --- /dev/null +++ b/src/Utils/Testing/Commands/AssertableCommandForm.php @@ -0,0 +1,42 @@ +response = ($this->getForm)($this->step); + } + + public function post(array $data = []): AssertableCommand + { + return new AssertableCommand( + postCommand: fn ($data, $step) => ($this->post)($data, $step, $this->formData()), + getForm: $this->getForm, + commandContainer: $this->commandContainer, + data: $data, + step: $this->step, + ); + } + + public function formData(): ?array + { + return $this->response->json('data'); + } +} diff --git a/src/Utils/Testing/Commands/FormatsDataForCommand.php b/src/Utils/Testing/Commands/FormatsDataForCommand.php new file mode 100644 index 000000000..f790ff89b --- /dev/null +++ b/src/Utils/Testing/Commands/FormatsDataForCommand.php @@ -0,0 +1,18 @@ +applyFormatters($data)) + ->when($baseData)->only(array_keys($data)) + ->all(), + ]; + } +} diff --git a/src/Utils/Testing/Commands/PendingCommand.php b/src/Utils/Testing/Commands/PendingCommand.php new file mode 100644 index 000000000..dc713078b --- /dev/null +++ b/src/Utils/Testing/Commands/PendingCommand.php @@ -0,0 +1,45 @@ +setGlobalFilterUrlDefault(); + + return new AssertableCommandForm( + post: $this->post, + getForm: $this->getForm, + commandContainer: $this->commandContainer, + step: $this->step + ); + } + + public function post(): AssertableCommand + { + $this->setGlobalFilterUrlDefault(); + + return new AssertableCommand( + postCommand: $this->post, + getForm: $this->getForm, + commandContainer: $this->commandContainer, + step: $this->step + ); + } +} diff --git a/src/Utils/Testing/Dashboard/AssertableDashboard.php b/src/Utils/Testing/Dashboard/AssertableDashboard.php new file mode 100644 index 000000000..94f623eb7 --- /dev/null +++ b/src/Utils/Testing/Dashboard/AssertableDashboard.php @@ -0,0 +1,24 @@ +pendingDashboard->parent instanceof PendingShow + ? $this->response->json('data') + : $this->response->inertiaProps('dashboard.data'); + } +} diff --git a/src/Utils/Testing/Dashboard/PendingDashboard.php b/src/Utils/Testing/Dashboard/PendingDashboard.php new file mode 100644 index 000000000..aced2f3d3 --- /dev/null +++ b/src/Utils/Testing/Dashboard/PendingDashboard.php @@ -0,0 +1,137 @@ +entityKey = app(SharpEntityManager::class)->entityKeyFor($entityKey); + $this->dashboard = app(SharpEntityManager::class)->entityFor($this->entityKey)->getViewOrFail(); + } + + public function withFilter(string $filterKey, mixed $value): static + { + $key = $this->dashboard->filterContainer()->findFilterHandler($filterKey)->getKey(); + $this->filterValues[$key] = $value; + + return $this; + } + + public function get(): AssertableDashboard + { + $this->setGlobalFilterUrlDefault(); + + return new AssertableDashboard( + $this->parent instanceof PendingShow + ? $this->test + ->getJson( + route('code16.sharp.api.dashboard', [ + 'dashboardKey' => $this->entityKey, + ...$this->dashboard + ->filterContainer() + ->getQueryParamsFromFilterValues($this->dashboardShowField()->toArray()['hiddenFilters'] ?? []), + ...$this->dashboardQueryParams(), + ]), + headers: [ + SharpBreadcrumb::CURRENT_PAGE_URL_HEADER => $this->getCurrentPageUrlFromParents(), + ] + ) + : $this->test->get(route('code16.sharp.dashboard', [ + 'dashboardKey' => $this->entityKey, + ...$this->dashboardQueryParams(), + ])), + $this, + ); + } + + public function dashboardCommand(string $commandKeyOrClassName): PendingCommand + { + $this->setGlobalFilterUrlDefault(); + + $commandKey = class_exists($commandKeyOrClassName) + ? class_basename($commandKeyOrClassName) + : $commandKeyOrClassName; + + return new PendingCommand( + getForm: fn (?string $step = null) => $this + ->test + ->getJson( + route( + 'code16.sharp.api.dashboard.command.form', + [ + 'dashboardKey' => $this->entityKey, + 'commandKey' => $commandKey, + 'command_step' => $step, + ...$this->dashboardQueryParams(), + ] + ), + headers: [ + SharpBreadcrumb::CURRENT_PAGE_URL_HEADER => $this->getCurrentPageUrlFromParents(), + ] + ), + post: fn (array $data, ?string $step = null, ?array $baseData = null) => $this + ->test + ->postJson( + route( + 'code16.sharp.api.dashboard.command', + ['dashboardKey' => $this->entityKey, 'commandKey' => $commandKey] + ), + [ + 'data' => $this->formatDataForCommand( + $this->dashboard->findDashboardCommandHandler($commandKey), + $data, + $baseData + ), + 'query' => $this->dashboardQueryParams(), + 'command_step' => $step, + ], + headers: [ + SharpBreadcrumb::CURRENT_PAGE_URL_HEADER => $this->getCurrentPageUrlFromParents(), + ] + ), + commandContainer: $this->dashboard, + ); + } + + protected function dashboardShowField(): ?SharpShowDashboardField + { + /** @noinspection PhpIncompatibleReturnTypeInspection */ + return $this->parent instanceof PendingShow + ? $this->parent->show->getBuiltFields() + ->first(fn (SharpShowField $field) => $field instanceof SharpShowDashboardField + && $field->toArray()['dashboardKey'] === $this->entityKey + ) + : null; + } + + protected function dashboardQueryParams(): array + { + return $this->dashboard + ->filterContainer() + ->getQueryParamsFromFilterValues($this->filterValues) + ->all(); + } +} diff --git a/src/Utils/Testing/DelegatesToResponse.php b/src/Utils/Testing/DelegatesToResponse.php new file mode 100644 index 000000000..7493cda7b --- /dev/null +++ b/src/Utils/Testing/DelegatesToResponse.php @@ -0,0 +1,27 @@ +response->{$name}(...$arguments); + + return $this; + } + + public function __get(string $name) + { + return $this->response->{$name}; + } +} diff --git a/src/Utils/Testing/EntityList/AssertableEntityList.php b/src/Utils/Testing/EntityList/AssertableEntityList.php new file mode 100644 index 000000000..241d1cf29 --- /dev/null +++ b/src/Utils/Testing/EntityList/AssertableEntityList.php @@ -0,0 +1,48 @@ +listData()); + + return $this; + } + + public function assertListContains(array $attributes): self + { + PHPUnit::assertTrue( + collect($this->listData()) + ->contains(fn ($item) => collect($attributes) + ->every(fn ($value, $key) => isset($item[$key]) && $item[$key] === $value) + ), + sprintf( + 'Failed asserting that data contains an item with attributes: %s', + json_encode($attributes) + ) + ); + + return $this; + } + + protected function listData(): array + { + return $this->pendingEntityList->parent instanceof PendingShow + ? $this->response->json('data') + : $this->response->inertiaProps('entityList.data'); + } +} diff --git a/src/Utils/Testing/EntityList/PendingEntityList.php b/src/Utils/Testing/EntityList/PendingEntityList.php new file mode 100644 index 000000000..43167bd75 --- /dev/null +++ b/src/Utils/Testing/EntityList/PendingEntityList.php @@ -0,0 +1,203 @@ +entityKey = app(SharpEntityManager::class)->entityKeyFor($entityKey); + $this->entityList = app(SharpEntityManager::class)->entityFor($this->entityKey)->getListOrFail(); + + if ($this->parent instanceof PendingShow) { + PHPUnit::assertNotNull($this->entityListShowField(), sprintf('Unknown entity list show field "%s"', $this->entityKey)); + } + } + + public function sharpShow(string $entityClassNameOrKey, string|int $instanceId): PendingShow + { + return new PendingShow($this->test, $entityClassNameOrKey, $instanceId, parent: $this); + } + + public function sharpForm(string $entityClassNameOrKey, string|int $instanceId): PendingForm + { + return new PendingForm($this->test, $entityClassNameOrKey, $instanceId, parent: $this); + } + + public function withFilter(string $filterKey, mixed $value): static + { + $key = $this->entityList->filterContainer()->findFilterHandler($filterKey)->getKey(); + $this->filterValues[$key] = $value; + + return $this; + } + + public function get(): AssertableEntityList + { + $this->setGlobalFilterUrlDefault(); + + return new AssertableEntityList( + $this->parent instanceof PendingShow + ? $this->test + ->getJson( + route('code16.sharp.api.list', [ + 'entityKey' => $this->entityKey, + ...$this->entityList + ->filterContainer() + ->getQueryParamsFromFilterValues($this->entityListShowField()->toArray()['hiddenFilters'] ?? []), + ...$this->entityListQueryParams(), + ]), + headers: [ + SharpBreadcrumb::CURRENT_PAGE_URL_HEADER => $this->getCurrentPageUrlFromParents(), + ] + ) + : $this->test->get(route('code16.sharp.list', [ + 'entityKey' => $this->entityKey, + ...$this->entityListQueryParams(), + ])), + $this, + ); + } + + public function entityCommand(string $commandKeyOrClassName): PendingCommand + { + $this->setGlobalFilterUrlDefault(); + + $commandKey = class_exists($commandKeyOrClassName) + ? class_basename($commandKeyOrClassName) + : $commandKeyOrClassName; + + return new PendingCommand( + getForm: fn (?string $step = null) => $this + ->test + ->getJson( + route( + 'code16.sharp.api.list.command.entity.form', + [ + 'entityKey' => $this->entityKey, + 'commandKey' => $commandKey, + 'command_step' => $step, + ...$this->entityListQueryParams(), + ] + ), + headers: [ + SharpBreadcrumb::CURRENT_PAGE_URL_HEADER => $this->getCurrentPageUrlFromParents(), + ] + ), + post: fn (array $data, ?string $step = null, ?array $baseData = null) => $this + ->test + ->postJson( + route( + 'code16.sharp.api.list.command.entity', + ['entityKey' => $this->entityKey, 'commandKey' => $commandKey] + ), + [ + 'data' => $this->formatDataForCommand( + $this->entityList->findEntityCommandHandler($commandKey), + $data, + $baseData + ), + 'query' => $this->entityListQueryParams(), + 'command_step' => $step, + ], + headers: [ + SharpBreadcrumb::CURRENT_PAGE_URL_HEADER => $this->getCurrentPageUrlFromParents(), + ] + ), + commandContainer: $this->entityList, + ); + } + + public function instanceCommand(string $commandKeyOrClassName, int|string $instanceId): PendingCommand + { + $this->setGlobalFilterUrlDefault(); + + $commandKey = class_exists($commandKeyOrClassName) + ? class_basename($commandKeyOrClassName) + : $commandKeyOrClassName; + + return new PendingCommand( + getForm: fn (?string $step = null) => $this + ->test + ->getJson( + route( + 'code16.sharp.api.list.command.instance.form', + [ + 'entityKey' => $this->entityKey, + 'instanceId' => $instanceId, + 'commandKey' => $commandKey, + 'command_step' => $step, + ...$this->entityListQueryParams(), + ] + ), + headers: [ + SharpBreadcrumb::CURRENT_PAGE_URL_HEADER => $this->getCurrentPageUrlFromParents(), + ] + ), + post: fn (array $data, ?string $step = null, ?array $baseData = null) => $this + ->test + ->postJson( + route( + 'code16.sharp.api.list.command.instance', + ['entityKey' => $this->entityKey, 'instanceId' => $instanceId, 'commandKey' => $commandKey] + ), + [ + 'data' => $this->formatDataForCommand( + $this->entityList->findInstanceCommandHandler($commandKey), + $data, + $baseData + ), + 'query' => $this->entityListQueryParams(), + 'command_step' => $step, + ], + headers: [ + SharpBreadcrumb::CURRENT_PAGE_URL_HEADER => $this->getCurrentPageUrlFromParents(), + ] + ), + commandContainer: $this->entityList, + ); + } + + protected function entityListShowField(): ?SharpShowEntityListField + { + /** @noinspection PhpIncompatibleReturnTypeInspection */ + return $this->parent instanceof PendingShow + ? $this->parent->show->getBuiltFields() + ->first(fn (SharpShowField $field) => $field instanceof SharpShowEntityListField + && $field->toArray()['entityListKey'] === $this->entityKey + ) + : null; + } + + protected function entityListQueryParams(): array + { + return $this->entityList + ->filterContainer() + ->getQueryParamsFromFilterValues($this->filterValues) + ->all(); + } +} diff --git a/src/Utils/Testing/Form/AssertableForm.php b/src/Utils/Testing/Form/AssertableForm.php new file mode 100644 index 000000000..f24f24259 --- /dev/null +++ b/src/Utils/Testing/Form/AssertableForm.php @@ -0,0 +1,36 @@ +pendingForm->store( + $this->formatDataForUpdate($this->pendingForm->form, $data, baseData: $this->formData()) + ); + } + + public function update(array $data = []): TestResponse + { + return $this->pendingForm->update( + $this->formatDataForUpdate($this->pendingForm->form, $data, baseData: $this->formData()) + ); + } + + public function formData(): array + { + return $this->response->inertiaProps('form.data'); + } +} diff --git a/src/Utils/Testing/Form/FormatsDataForUpdate.php b/src/Utils/Testing/Form/FormatsDataForUpdate.php new file mode 100644 index 000000000..44ed12938 --- /dev/null +++ b/src/Utils/Testing/Form/FormatsDataForUpdate.php @@ -0,0 +1,18 @@ +applyFormatters($data)) + ->when($baseData)->only(array_keys($data)) + ->all(), + ]; + } +} diff --git a/src/Utils/Testing/Form/PendingForm.php b/src/Utils/Testing/Form/PendingForm.php new file mode 100644 index 000000000..da515272a --- /dev/null +++ b/src/Utils/Testing/Form/PendingForm.php @@ -0,0 +1,114 @@ +entityKey = app(SharpEntityManager::class)->entityKeyFor($entityKey); + $this->form = app(SharpEntityManager::class)->entityFor($this->entityKey)->getFormOrFail(); + } + + public function create(): AssertableForm + { + $this->setGlobalFilterUrlDefault(); + + PHPUnit::assertNotInstanceOf(SharpSingleForm::class, $this->form); + + return new AssertableForm( + $this->test + ->get(route('code16.sharp.form.create', [ + 'parentUri' => $this->getParentUri(), + 'entityKey' => $this->entityKey, + ])), + pendingForm: $this->forSubsequentRequest(), + ); + } + + public function edit(): AssertableForm + { + $this->setGlobalFilterUrlDefault(); + + if (! $this->form instanceof SharpSingleForm) { + PHPUnit::assertNotNull($this->instanceId, 'You can’t edit a form without an instance ID.'); + } + + return new AssertableForm( + $this->test + ->get(route('code16.sharp.form.edit', [ + 'parentUri' => $this->getParentUri(), + 'entityKey' => $this->entityKey, + 'instanceId' => $this->instanceId, + ])), + pendingForm: $this->forSubsequentRequest(), + ); + } + + public function store(array $data): TestResponse + { + $this->setGlobalFilterUrlDefault(); + + PHPUnit::assertNotInstanceOf(SharpSingleForm::class, $this->form); + + return $this->test + ->post( + route('code16.sharp.form.store', [ + 'parentUri' => $this->getParentUri(), + 'entityKey' => $this->entityKey, + ]), + $this->isSubsequentRequest ? $data : $this->formatDataForUpdate($this->form, $data), + ); + } + + public function update(array $data): TestResponse + { + $this->setGlobalFilterUrlDefault(); + + if (! $this->form instanceof SharpSingleForm) { + PHPUnit::assertNotNull($this->instanceId, 'You can’t update a form without an instance ID.'); + } + + return $this->test + ->post( + route('code16.sharp.form.update', [ + 'parentUri' => $this->getParentUri(), + 'entityKey' => $this->entityKey, + 'instanceId' => $this->instanceId, + ]), + $this->isSubsequentRequest ? $data : $this->formatDataForUpdate($this->form, $data), + ); + } + + protected function forSubsequentRequest(): static + { + $pendingForm = clone $this; + $pendingForm->isSubsequentRequest = true; + + return $pendingForm; + } +} diff --git a/src/Utils/Testing/GeneratesCurrentPageUrl.php b/src/Utils/Testing/GeneratesCurrentPageUrl.php new file mode 100644 index 000000000..6bb9c58d0 --- /dev/null +++ b/src/Utils/Testing/GeneratesCurrentPageUrl.php @@ -0,0 +1,20 @@ +config()->get('custom_url_segment'), + sharp()->context()->globalFilterUrlSegmentValue(), + $builder->generateUri() + ) + ); + } +} diff --git a/src/Utils/Testing/GeneratesGlobalFilterUrl.php b/src/Utils/Testing/GeneratesGlobalFilterUrl.php new file mode 100644 index 000000000..14b910eab --- /dev/null +++ b/src/Utils/Testing/GeneratesGlobalFilterUrl.php @@ -0,0 +1,24 @@ +globalFilter = collect((array) $globalFilterValues) + ->implode(GlobalFilters::$valuesUrlSeparator); + + return $this; + } + + private function setGlobalFilterUrlDefault(): void + { + URL::defaults(['globalFilter' => $this->globalFilter ?: GlobalFilters::$defaultKey]); + } +} diff --git a/src/Utils/Testing/IsPendingComponent.php b/src/Utils/Testing/IsPendingComponent.php new file mode 100644 index 000000000..b981ec4ce --- /dev/null +++ b/src/Utils/Testing/IsPendingComponent.php @@ -0,0 +1,71 @@ +breadcrumbBuilder($this->parents())->generateUri(); + } + + protected function getCurrentPageUrlFromParents(): string + { + return $this->buildCurrentPageUrl($this->breadcrumbBuilder([...$this->parents(), $this])); + } + + protected function breadcrumbBuilder(array $components): BreadcrumbBuilder + { + $breadcrumb = new BreadcrumbBuilder(); + $first = $components[0] ?? $this; + + // fill the breadcrumb if needed + if ($first instanceof PendingShow && $first->instanceId) { + $breadcrumb->appendEntityList($first->entityKey); + } elseif ($first instanceof PendingForm) { + if ($first->form instanceof SharpSingleForm) { + $breadcrumb->appendSingleShowPage($first->entityKey); + } else { + $breadcrumb->appendEntityList($first->entityKey); + if (app(SharpEntityManager::class)->entityFor($first->entityKey)->hasShow() && $first->instanceId) { + $breadcrumb->appendShowPage($first->entityKey, $first->instanceId); + } + } + } + + foreach ($components as $component) { + if ($component instanceof PendingEntityList && ! $component->parent) { + $breadcrumb->appendEntityList($component->entityKey); + } elseif ($component instanceof PendingShow) { + if ($component->instanceId) { + $breadcrumb->appendShowPage($component->entityKey, $component->instanceId); + } else { + $breadcrumb->appendSingleShowPage($component->entityKey); + } + } + } + + return $breadcrumb; + } + + protected function parents(): array + { + $parents = []; + + for ($parent = $this->parent; $parent; $parent = $parent->parent) { + $parents[] = $parent; + } + + return array_reverse($parents); + } +} diff --git a/src/Utils/Testing/SharpAssertions.php b/src/Utils/Testing/SharpAssertions.php index 1e1bf980b..6d2ab40ed 100644 --- a/src/Utils/Testing/SharpAssertions.php +++ b/src/Utils/Testing/SharpAssertions.php @@ -3,16 +3,40 @@ namespace Code16\Sharp\Utils\Testing; use Closure; -use Code16\Sharp\Filters\GlobalFilters\GlobalFilters; use Code16\Sharp\Http\Context\SharpBreadcrumb; use Code16\Sharp\Utils\Entities\SharpEntityManager; use Code16\Sharp\Utils\Links\BreadcrumbBuilder; -use Illuminate\Support\Facades\URL; +use Code16\Sharp\Utils\Testing\Dashboard\PendingDashboard; +use Code16\Sharp\Utils\Testing\EntityList\PendingEntityList; +use Code16\Sharp\Utils\Testing\Form\PendingForm; +use Code16\Sharp\Utils\Testing\Show\PendingShow; trait SharpAssertions { + use GeneratesCurrentPageUrl; + use GeneratesGlobalFilterUrl; + private BreadcrumbBuilder $breadcrumbBuilder; - private ?string $globalFilter = null; + + public function sharpList(string $entityClassNameOrKey): PendingEntityList + { + return new PendingEntityList($this, $entityClassNameOrKey); + } + + public function sharpShow(string $entityClassNameOrKey, int|string|null $instanceId = null): PendingShow + { + return new PendingShow($this, $entityClassNameOrKey, $instanceId); + } + + public function sharpForm(string $entityClassNameOrKey, int|string|null $instanceId = null): PendingForm + { + return new PendingForm($this, $entityClassNameOrKey, $instanceId); + } + + public function sharpDashboard(string $entityClassNameOrKey): PendingDashboard + { + return new PendingDashboard($this, $entityClassNameOrKey); + } /** * @deprecated use withSharpBreadcrumb() instead @@ -43,17 +67,9 @@ public function withSharpBreadcrumb(Closure $callback): self return $this; } - public function withSharpGlobalFilterValues(array|string $globalFilterValues): self - { - $this->globalFilter = collect((array) $globalFilterValues) - ->implode(GlobalFilters::$valuesUrlSeparator); - - return $this; - } - public function deleteFromSharpShow(string $entityClassNameOrKey, mixed $instanceId) { - URL::defaults(['globalFilter' => $this->globalFilter ?: GlobalFilters::$defaultKey]); + $this->setGlobalFilterUrlDefault(); $entityKey = $this->resolveEntityKey($entityClassNameOrKey); @@ -72,7 +88,7 @@ public function deleteFromSharpShow(string $entityClassNameOrKey, mixed $instanc public function deleteFromSharpList(string $entityClassNameOrKey, mixed $instanceId) { - URL::defaults(['globalFilter' => $this->globalFilter ?: GlobalFilters::$defaultKey]); + $this->setGlobalFilterUrlDefault(); $entityKey = $this->resolveEntityKey($entityClassNameOrKey); @@ -94,7 +110,7 @@ public function deleteFromSharpList(string $entityClassNameOrKey, mixed $instanc public function getSharpForm(string $entityClassNameOrKey, mixed $instanceId = null) { - URL::defaults(['globalFilter' => $this->globalFilter ?: GlobalFilters::$defaultKey]); + $this->setGlobalFilterUrlDefault(); $entityKey = $this->resolveEntityKey($entityClassNameOrKey); @@ -123,7 +139,7 @@ public function getSharpForm(string $entityClassNameOrKey, mixed $instanceId = n public function getSharpSingleForm(string $entityClassNameOrKey) { - URL::defaults(['globalFilter' => $this->globalFilter ?: GlobalFilters::$defaultKey]); + $this->setGlobalFilterUrlDefault(); $entityKey = $this->resolveEntityKey($entityClassNameOrKey); @@ -142,7 +158,7 @@ public function getSharpSingleForm(string $entityClassNameOrKey) public function updateSharpForm(string $entityClassNameOrKey, $instanceId, array $data) { - URL::defaults(['globalFilter' => $this->globalFilter ?: GlobalFilters::$defaultKey]); + $this->setGlobalFilterUrlDefault(); $entityKey = $this->resolveEntityKey($entityClassNameOrKey); @@ -162,7 +178,7 @@ public function updateSharpForm(string $entityClassNameOrKey, $instanceId, array public function updateSharpSingleForm(string $entityClassNameOrKey, array $data) { - URL::defaults(['globalFilter' => $this->globalFilter ?: GlobalFilters::$defaultKey]); + $this->setGlobalFilterUrlDefault(); $entityKey = $this->resolveEntityKey($entityClassNameOrKey); @@ -181,7 +197,7 @@ public function updateSharpSingleForm(string $entityClassNameOrKey, array $data) public function getSharpShow(string $entityClassNameOrKey, $instanceId) { - URL::defaults(['globalFilter' => $this->globalFilter ?: GlobalFilters::$defaultKey]); + $this->setGlobalFilterUrlDefault(); $entityKey = $this->resolveEntityKey($entityClassNameOrKey); @@ -201,7 +217,7 @@ public function getSharpShow(string $entityClassNameOrKey, $instanceId) public function storeSharpForm(string $entityClassNameOrKey, array $data) { - URL::defaults(['globalFilter' => $this->globalFilter ?: GlobalFilters::$defaultKey]); + $this->setGlobalFilterUrlDefault(); $entityKey = $this->resolveEntityKey($entityClassNameOrKey); @@ -226,7 +242,7 @@ public function callSharpInstanceCommandFromList( array $data = [], ?string $commandStep = null ) { - URL::defaults(['globalFilter' => $this->globalFilter ?: GlobalFilters::$defaultKey]); + $this->setGlobalFilterUrlDefault(); $entityKey = $this->resolveEntityKey($entityClassNameOrKey); @@ -257,7 +273,7 @@ public function callSharpInstanceCommandFromShow( array $data = [], ?string $commandStep = null ) { - URL::defaults(['globalFilter' => $this->globalFilter ?: GlobalFilters::$defaultKey]); + $this->setGlobalFilterUrlDefault(); $entityKey = $this->resolveEntityKey($entityClassNameOrKey); @@ -287,7 +303,7 @@ public function callSharpEntityCommandFromList( array $data = [], ?string $commandStep = null ) { - URL::defaults(['globalFilter' => $this->globalFilter ?: GlobalFilters::$defaultKey]); + $this->setGlobalFilterUrlDefault(); $entityKey = $this->resolveEntityKey($entityClassNameOrKey); @@ -322,18 +338,6 @@ private function breadcrumbBuilder(string $entityKey, ?string $instanceId = null ->when($instanceId, fn ($builder) => $builder->appendShowPage($entityKey, $instanceId)); } - private function buildCurrentPageUrl(BreadcrumbBuilder $builder): string - { - return url( - sprintf( - '/%s/%s/%s', - sharp()->config()->get('custom_url_segment'), - sharp()->context()->globalFilterUrlSegmentValue(), - $builder->generateUri() - ) - ); - } - private function resolveEntityKey(string $entityClassNameOrKey): string { return app(SharpEntityManager::class)->entityKeyFor($entityClassNameOrKey); diff --git a/src/Utils/Testing/Show/AssertableShow.php b/src/Utils/Testing/Show/AssertableShow.php new file mode 100644 index 000000000..19ea8b900 --- /dev/null +++ b/src/Utils/Testing/Show/AssertableShow.php @@ -0,0 +1,31 @@ +response->inertiaProps('_rawData'); + } + + public function assertShowData(array $expectedData): self + { + $this->response->assertInertia(fn (AssertableInertia $page) => $page + ->has('_rawData', fn (AssertableJson $json) => $json->whereAll($expectedData)->etc()) + ); + + return $this; + } +} diff --git a/src/Utils/Testing/Show/PendingShow.php b/src/Utils/Testing/Show/PendingShow.php new file mode 100644 index 000000000..3ac412f6d --- /dev/null +++ b/src/Utils/Testing/Show/PendingShow.php @@ -0,0 +1,120 @@ +entityKey = app(SharpEntityManager::class)->entityKeyFor($entityKey); + $this->show = app(SharpEntityManager::class)->entityFor($this->entityKey)->getShowOrFail(); + } + + public function sharpForm(string $entityClassNameOrKey): PendingForm + { + return new PendingForm($this->test, $entityClassNameOrKey, $this->instanceId, parent: $this); + } + + public function sharpListField(string $entityClassNameOrKey): PendingEntityList + { + return new PendingEntityList($this->test, $entityClassNameOrKey, parent: $this); + } + + public function sharpDashboardField(string $entityClassNameOrKey): PendingDashboard + { + return new PendingDashboard($this->test, $entityClassNameOrKey, parent: $this); + } + + public function get(): AssertableShow + { + $this->setGlobalFilterUrlDefault(); + + return new AssertableShow( + $this->test + ->get($this->show instanceof SharpSingleShow + ? route('code16.sharp.single-show', [ + 'entityKey' => $this->entityKey, + ]) + : route('code16.sharp.show.show', [ + 'parentUri' => $this->getParentUri(), + 'entityKey' => $this->entityKey, + 'instanceId' => $this->instanceId, + ]) + ) + ); + } + + public function instanceCommand(string $commandKeyOrClassName): PendingCommand + { + $this->setGlobalFilterUrlDefault(); + + $commandKey = class_exists($commandKeyOrClassName) + ? class_basename($commandKeyOrClassName) + : $commandKeyOrClassName; + + return new PendingCommand( + getForm: fn (?string $step = null) => $this + ->test + ->getJson( + $this->show instanceof SharpSingleShow + ? route( + 'code16.sharp.api.show.command.singleInstance.form', + ['entityKey' => $this->entityKey, 'commandKey' => $commandKey, 'command_step' => $step] + ) + : route( + 'code16.sharp.api.show.command.instance.form', + [ + 'entityKey' => $this->entityKey, + 'instanceId' => $this->instanceId, + 'commandKey' => $commandKey, + 'command_step' => $step, + ] + ), + headers: [ + SharpBreadcrumb::CURRENT_PAGE_URL_HEADER => $this->getCurrentPageUrlFromParents(), + ] + ), + post: fn (?array $data, ?string $step = null, ?array $baseData = null) => $this + ->test + ->postJson( + route( + 'code16.sharp.api.show.command.instance', + ['entityKey' => $this->entityKey, 'instanceId' => $this->instanceId, 'commandKey' => $commandKey] + ), + [ + 'data' => $this->formatDataForCommand($this->show->findInstanceCommandHandler($commandKey), $data, $baseData), + 'command_step' => $step, + ], + headers: [ + SharpBreadcrumb::CURRENT_PAGE_URL_HEADER => $this->getCurrentPageUrlFromParents(), + ] + ), + commandContainer: $this->show, + ); + } +} diff --git a/tests/Fixtures/Entities/DashboardEntity.php b/tests/Fixtures/Entities/DashboardEntity.php index 18e38a5ab..c18004f20 100644 --- a/tests/Fixtures/Entities/DashboardEntity.php +++ b/tests/Fixtures/Entities/DashboardEntity.php @@ -23,7 +23,7 @@ public function setShow(?SharpDashboard $show): self protected function getView(): SharpDashboard { - return $this->fakeView ?? parent::getView(); + return isset($this->fakeView) ? clone $this->fakeView : parent::getView(); } public function setPolicy(SharpEntityPolicy $policy): self diff --git a/tests/Fixtures/Entities/PersonEntity.php b/tests/Fixtures/Entities/PersonEntity.php index 15e1e402f..41b78a3f5 100644 --- a/tests/Fixtures/Entities/PersonEntity.php +++ b/tests/Fixtures/Entities/PersonEntity.php @@ -57,17 +57,17 @@ public function setForm(?SharpForm $form): self public function getForm(): ?SharpForm { - return $this->fakeForm ?? parent::getForm(); + return isset($this->fakeForm) ? clone $this->fakeForm : parent::getForm(); } protected function getShow(): ?SharpShow { - return $this->fakeShow ?? parent::getShow(); + return isset($this->fakeShow) ? clone $this->fakeShow : parent::getShow(); } protected function getList(): ?SharpEntityList { - return $this->fakeList ?? parent::getList(); + return isset($this->fakeList) ? clone $this->fakeList : parent::getList(); } public function setValidator(string $validatorClass, ?string $subentity = null): self diff --git a/tests/Http/Api/ApiEntityListControllerTest.php b/tests/Http/Api/ApiEntityListControllerTest.php index 644bb4a99..64889b187 100644 --- a/tests/Http/Api/ApiEntityListControllerTest.php +++ b/tests/Http/Api/ApiEntityListControllerTest.php @@ -20,29 +20,27 @@ }); it('allows to reorder instances', function () { - $this->withoutExceptionHandling(); + $ids = null; - $list = new class() extends PersonList + fakeListFor('person', new class($ids) extends PersonList { - public array $reorderedInstances = []; + public function __construct(public &$ids) {} public function buildListConfig(): void { $this->configureReorderable( - new class($this->reorderedInstances) implements ReorderHandler + new class($this->ids) implements ReorderHandler { - public function __construct(public array &$reorderedInstances) {} + public function __construct(public &$ids) {} public function reorder(array $ids): void { - $this->reorderedInstances = $ids; + $this->ids = $ids; } } ); } - }; - - fakeListFor('person', $list); + }); $this ->postJson( @@ -51,52 +49,45 @@ public function reorder(array $ids): void ) ->assertOk(); - expect($list->reorderedInstances)->toEqual([3, 2, 1]); + expect($ids)->toEqual([3, 2, 1]); }); it('allows to delete an instance in the entity list if delete method is implemented', function () { - $list = new class() extends PersonList + $deletedId = null; + + fakeListFor('person', new class($deletedId) extends PersonList { - public ?int $deletedInstance = null; + public function __construct(public &$deletedId) {} public function delete($id): void { - $this->deletedInstance = $id; + $this->deletedId = $id; } - }; - - fakeListFor('person', $list); - - $idToDelete = rand(1, 10); + }); - $this->deleteJson(route('code16.sharp.api.list.delete', ['root', 'person', $idToDelete])) + $this->deleteJson(route('code16.sharp.api.list.delete', ['root', 'person', 1])) ->assertOk(); - expect($list->deletedInstance)->toEqual($idToDelete); + expect($deletedId)->toEqual(1); }); it('delegates deletion to the show page if it exists', function () { - $this->withoutExceptionHandling(); - - fakeListFor('person', new PersonList()); + $deletedId = null; - $show = new class() extends PersonShow + fakeShowFor('person', new class($deletedId) extends PersonShow { - public ?int $deletedInstance = null; + public function __construct(public &$deletedId) {} public function delete($id): void { - $this->deletedInstance = $id; + $this->deletedId = $id; } - }; - fakeShowFor('person', $show); - - $idToDelete = rand(1, 10); + }); - $this->deleteJson(route('code16.sharp.api.list.delete', ['root', 'person', $idToDelete])) + $this->deleteJson(route('code16.sharp.api.list.delete', ['root', 'person', 1])) ->assertOk(); - expect($show->deletedInstance)->toEqual($idToDelete); + expect($deletedId)->toEqual(1); }); it('checks if the entity list allows deletion', function () { diff --git a/tests/Http/SharpAssertionsHttpTest.php b/tests/Http/SharpAssertionsHttpTest.php new file mode 100644 index 000000000..ace547b3a --- /dev/null +++ b/tests/Http/SharpAssertionsHttpTest.php @@ -0,0 +1,748 @@ +use(SharpAssertions::class); + +beforeEach(function () { + login(); + sharp()->config()->declareEntity(PersonEntity::class); + sharp()->config()->declareEntity(SinglePersonEntity::class); + sharp()->config()->declareEntity(DashboardEntity::class); +}); + +it('get & assert an entity list', function () { + $filterValues = []; + + fakeListFor(PersonEntity::class, new class($filterValues) extends PersonList + { + public function __construct(public &$filterValues) {} + + protected function getFilters(): ?array + { + return [ + new class() extends CheckFilter + { + public function buildFilterConfig(): void + { + $this->configureKey('is_valid'); + } + }, + ]; + } + + public function getListData(): array + { + $this->filterValues = ['is_valid' => $this->queryParams->filterFor('is_valid')]; + + return [ + ['id' => 1, 'name' => 'Marie Curie'], + ]; + } + }); + + $this->sharpList(PersonEntity::class) + ->withFilter('is_valid', true) + ->get() + ->assertOk() + ->assertListCount(1) + ->assertListContains(['name' => 'Marie Curie']); + + expect($filterValues)->toEqual(['is_valid' => true]); +}); + +it('call & assert an entity list entity command form', function () { + $postedData = []; + + fakeListFor(PersonEntity::class, new class($postedData) extends PersonList + { + public function __construct(public array &$postedData) {} + + protected function getEntityCommands(): ?array + { + return [ + 'cmd-form' => new class($this->postedData) extends EntityCommand + { + public function __construct(public array &$postedData) {} + + public function label(): ?string + { + return 'entity'; + } + + public function buildFormFields(FieldsContainer $formFields): void + { + $formFields + ->addField(SharpFormTextField::make('action')) + ->addField(SharpFormTextField::make('field_with_initial_value')); + } + + protected function initialData(): array + { + return ['field_with_initial_value' => 'test']; + } + + public function execute(array $data = []): array + { + $this->postedData = $data; + + if ($data['action'] === 'download') { + Storage::fake('files'); + UploadedFile::fake() + ->create('account.pdf', 100, 'application/pdf') + ->storeAs('pdf', 'account.pdf', ['disk' => 'files']); + } + + return match ($data['action']) { + 'info' => $this->info('ok'), + 'link' => $this->link('https://example.org'), + 'view' => $this->view('fixtures::test', ['text' => 'text']), + 'reload' => $this->reload(), + 'download' => $this->download('pdf/account.pdf', 'account.pdf', 'files'), + 'refresh' => $this->refresh([1, 2]), + }; + } + }, + ]; + } + }); + + $this->sharpList(PersonEntity::class) + ->entityCommand('cmd-form')->getForm()->post(['action' => 'info']) + ->assertReturnsInfo('ok'); + + expect($postedData)->toEqual(['action' => 'info', 'field_with_initial_value' => 'test']); + + $this->sharpList(PersonEntity::class) + ->entityCommand('cmd-form')->getForm()->post(['action' => 'link']) + ->assertReturnsLink('https://example.org'); + + $this->sharpList(PersonEntity::class) + ->entityCommand('cmd-form')->getForm()->post(['action' => 'view']) + ->assertReturnsView('fixtures::test', [ + 'text' => 'text', + ]); + + $this->sharpList(PersonEntity::class) + ->entityCommand('cmd-form')->getForm()->post(['action' => 'reload']) + ->assertReturnsReload(); + + $this->sharpList(PersonEntity::class) + ->entityCommand('cmd-form')->getForm()->post(['action' => 'download']) + ->assertReturnsDownload('account.pdf'); + + $this->sharpList(PersonEntity::class) + ->entityCommand('cmd-form')->getForm()->post(['action' => 'refresh']) + ->assertReturnsRefresh([1, 2]); +}); + +it('call & assert an entity list entity command with filters', function () { + $filterValues = []; + + fakeListFor(PersonEntity::class, new class($filterValues) extends PersonList + { + public function __construct(public &$filterValues) {} + + protected function getFilters(): ?array + { + return [ + new class() extends CheckFilter + { + public function buildFilterConfig(): void + { + $this->configureKey('is_valid'); + } + }, + ]; + } + + protected function getEntityCommands(): ?array + { + return [ + 'cmd' => new class($this->filterValues) extends EntityCommand + { + public function __construct(public &$filterValues) {} + + public function label(): ?string + { + return 'entity'; + } + + public function execute(array $data = []): array + { + $this->filterValues = ['is_valid' => $this->queryParams->filterFor('is_valid')]; + + return $this->info('ok'); + } + }, + ]; + } + }); + + $this->withoutExceptionHandling(); + + $this->sharpList(PersonEntity::class) + ->withFilter('is_valid', true) + ->entityCommand('cmd')->post() + ->assertReturnsInfo('ok'); + + expect($filterValues)->toEqual(['is_valid' => true]); +}); + +it('call & assert an entity list entity wizard command', function () { + $postedData = []; + + fakeListFor(PersonEntity::class, new class($postedData) extends PersonList + { + public function __construct(public &$postedData) {} + + protected function getEntityCommands(): ?array + { + return [ + 'wizard' => new class($this->postedData) extends EntityWizardCommand + { + public function __construct(public &$postedData) {} + + public function label(): ?string + { + return 'my command'; + } + + protected function initialDataForFirstStep(): array + { + return ['field_with_initial_value' => 'test']; + } + + public function buildFormFieldsForFirstStep(FieldsContainer $formFields): void + { + $formFields + ->addField(SharpFormTextField::make('name')) + ->addField(SharpFormTextField::make('field_with_initial_value')); + } + + protected function executeFirstStep(array $data): array + { + $this->postedData = $data; + + $this->validate($data, ['name' => 'required']); + + return $this->toStep('second-step'); + } + + public function initialDataForStepSecondStep(): array + { + return ['field_with_initial_value' => 'test']; + } + + public function buildFormFieldsForStepSecondStep(FieldsContainer $formFields): void + { + $formFields + ->addField(SharpFormTextField::make('age')) + ->addField(SharpFormTextField::make('field_with_initial_value')); + } + + protected function executeStepSecondStep(array $data): array + { + $this->postedData = $data; + + return $this->reload(); + } + }, + ]; + } + }); + + $this->withoutExceptionHandling(); + + $this->sharpList(PersonEntity::class) + ->entityCommand('wizard') + ->getForm()->post(['name' => 'John']) + ->assertReturnsStep('second-step') + ->tap(fn () => expect($postedData)->toEqual(['name' => 'John', 'field_with_initial_value' => 'test'])) + ->getNextStepForm()->post(['age' => 30]) + ->assertReturnsReload() + ->tap(fn () => expect($postedData)->toEqual(['age' => 30, 'field_with_initial_value' => 'test'])); +}); + +it('call & assert a entity list instance command', function () { + fakeListFor(PersonEntity::class, new class() extends PersonList + { + protected function getInstanceCommands(): ?array + { + return [ + 'cmd-form' => new class() extends InstanceCommand + { + public function label(): ?string + { + return 'entity'; + } + + public function buildFormFields(FieldsContainer $formFields): void + { + $formFields->addField(SharpFormTextField::make('action')); + } + + public function execute($instanceId, array $data = []): array + { + return match ($data['action']) { + 'info' => $this->info('instance '.$instanceId), + }; + } + }, + 'cmd' => new class() extends InstanceCommand + { + public function label(): ?string + { + return 'entity'; + } + + public function execute($instanceId, array $data = []): array + { + return $this->info('instance '.$instanceId); + } + }, + ]; + } + }); + + $this->sharpList(PersonEntity::class) + ->instanceCommand('cmd', 1)->post() + ->assertReturnsInfo('instance 1'); + + $this->sharpList(PersonEntity::class) + ->instanceCommand('cmd-form', 1)->getForm()->post(['action' => 'info']) + ->assertReturnsInfo('instance 1'); +}); + +it('call & assert a show instance command', function () { + fakeShowFor(PersonEntity::class, new class() extends PersonShow + { + public function getInstanceCommands(): ?array + { + return [ + 'cmd-form' => new class() extends InstanceCommand + { + public function label(): ?string + { + return 'entity'; + } + + public function buildFormFields(FieldsContainer $formFields): void + { + $formFields->addField(SharpFormTextField::make('action')); + } + + public function execute($instanceId, array $data = []): array + { + return match ($data['action']) { + 'info' => $this->info('instance '.$instanceId), + }; + } + }, + 'cmd' => new class() extends InstanceCommand + { + public function label(): ?string + { + return 'entity'; + } + + public function execute($instanceId, array $data = []): array + { + return $this->info('instance '.$instanceId); + } + }, + ]; + } + }); + + $this->sharpShow(PersonEntity::class, 1) + ->instanceCommand('cmd')->post() + ->assertReturnsInfo('instance 1'); + + $this->sharpShow(PersonEntity::class, 1) + ->instanceCommand('cmd-form')->getForm()->post(['action' => 'info']) + ->assertReturnsInfo('instance 1'); +}); + +test('get show', function () { + fakeShowFor(PersonEntity::class, new class() extends PersonShow + { + public function find($id): array + { + return ['name' => 'John Doe', 'age' => 31]; + } + }); + + $this->sharpShow(PersonEntity::class, 1) + ->get() + ->assertOk() + ->assertShowData(['name' => 'John Doe']); + + expect(sharp()->context()->breadcrumb()->getCurrentPath())->toEqual('s-list/person/s-show/person/1'); +}); + +test('get single show', function () { + + fakeShowFor(SinglePersonEntity::class, new class() extends SinglePersonShow + { + public function findSingle(): array + { + return ['name' => 'John Doe', 'age' => 31]; + } + }); + + $this->sharpShow(SinglePersonEntity::class) + ->get() + ->assertOk() + ->assertShowData(['name' => 'John Doe']); + + expect(sharp()->context()->breadcrumb()->getCurrentPath())->toEqual('s-show/single-person'); +}); + +test('get show EEL', function () { + fakeShowFor(PersonEntity::class, new class() extends PersonShow + { + public function buildShowFields(FieldsContainer $showFields): void + { + $showFields->addField( + SharpShowEntityListField::make(PersonEntity::class) + ); + } + + public function find($id): array + { + return ['name' => 'John Doe', 'age' => 31]; + } + }); + + fakeListFor(PersonEntity::class, new class() extends PersonList + { + public function getListData(): array + { + return [ + ['id' => 1, 'name' => 'Marie Curie'], + ['id' => 2, 'name' => 'Albert Einstein'], + ]; + } + }); + + $this->sharpShow(PersonEntity::class, 1) + ->sharpListField(PersonEntity::class) + ->get() + ->assertOk() + ->assertListCount(2) + ->assertListContains(['name' => 'Marie Curie']); +}); + +test('get nested show', function () { + fakeShowFor(PersonEntity::class, new class() extends PersonShow + { + public function buildShowFields(FieldsContainer $showFields): void + { + $showFields->addField( + SharpShowEntityListField::make(PersonEntity::class) + ); + } + + public function find($id): array + { + return ['name' => 'John Doe', 'age' => 31]; + } + }); + + fakeListFor(PersonEntity::class, new class() extends PersonList + { + public function getListData(): array + { + return [ + ['id' => 1, 'name' => 'Marie Curie'], + ['id' => 2, 'name' => 'Albert Einstein'], + ]; + } + }); + + $this->sharpShow(PersonEntity::class, 1) + ->sharpListField(PersonEntity::class) + ->sharpShow(PersonEntity::class, 2) + ->get() + ->assertOk(); + + expect(sharp()->context()->breadcrumb()->getCurrentPath())->toEqual('s-list/person/s-show/person/1/s-show/person/2'); +}); + +test('create & store form', function () { + fakeFormFor(PersonEntity::class, new class() extends PersonForm + { + public function buildFormFields(FieldsContainer $formFields): void + { + $formFields + ->addField(SharpFormEditorField::make('name')) + ->addField(SharpFormEditorField::make('job')); + } + + public function create(): array + { + return ['name' => 'John Wayne', 'job' => 'actor']; + } + + public function update($id, array $data) + { + expect($data)->toEqual(['name' => 'John Doe', 'job' => 'actor']); + + return 1; + } + }); + + $this->sharpForm(PersonEntity::class) + ->create() + ->assertOk() + ->store(['name' => 'John Doe']) + ->assertValid() + ->assertRedirect(); + + expect(sharp()->context()->breadcrumb()->getCurrentPath())->toEqual('s-list/person/s-form/person'); +}); + +test('store form', function () { + fakeFormFor(PersonEntity::class, new class() extends PersonForm + { + public function buildFormFields(FieldsContainer $formFields): void + { + $formFields + ->addField(SharpFormEditorField::make('name')) + ->addField(SharpFormEditorField::make('job')); + } + + public function update($id, array $data) + { + expect($data)->toEqual(['name' => 'John Doe', 'job' => null]); + + return 1; + } + }); + + $this->sharpForm(PersonEntity::class) + ->store(['name' => 'John Doe']) + ->assertValid() + ->assertRedirect(); + + expect(sharp()->context()->breadcrumb()->getCurrentPath())->toEqual('s-list/person/s-form/person'); +}); + +test('edit & update form', function () { + fakeFormFor(PersonEntity::class, new class() extends PersonForm + { + public function buildFormFields(FieldsContainer $formFields): void + { + $formFields + ->addField(SharpFormEditorField::make('name')) + ->addField(SharpFormEditorField::make('job')); + } + + public function find($id): array + { + return ['name' => 'John Wayne', 'job' => 'actor']; + } + + public function update($id, array $data) + { + expect($data)->toEqual(['name' => 'John Doe', 'job' => 'actor']); + + return 1; + } + }); + + $this->sharpForm(PersonEntity::class, 1) + ->edit() + ->assertOk() + ->update(['name' => 'John Doe']) + ->assertValid() + ->assertRedirect(); + + expect(sharp()->context()->breadcrumb()->getCurrentPath())->toEqual('s-list/person/s-show/person/1/s-form/person/1'); +}); + +test('update form', function () { + fakeFormFor(PersonEntity::class, new class() extends PersonForm + { + public function buildFormFields(FieldsContainer $formFields): void + { + $formFields + ->addField(SharpFormEditorField::make('name')) + ->addField(SharpFormEditorField::make('job')); + } + + public function find($id): array + { + return ['name' => 'John Wayne', 'job' => 'actor']; + } + + public function update($id, array $data) + { + expect($data)->toEqual(['name' => 'John Doe', 'job' => null]); + + return 1; + } + }); + + $this->sharpForm(PersonEntity::class, 1) + ->update(['name' => 'John Doe']) + ->assertValid() + ->assertRedirect(); + + expect(sharp()->context()->breadcrumb()->getCurrentPath())->toEqual('s-list/person/s-show/person/1/s-form/person/1'); +}); + +test('update single form', function () { + $this->sharpForm(SinglePersonEntity::class) + ->update(['name' => 'John Doe']) + ->assertValid() + ->assertRedirect(); + + expect(sharp()->context()->breadcrumb()->getCurrentPath())->toEqual('s-show/single-person/s-form/single-person'); +}); + +test('get dashboard', function () { + /** @var array{'period':DateRangeFilterValue} $filterValues */ + $filterValues = []; + + fakeDashboardFor(DashboardEntity::class, new class($filterValues) extends TestDashboard + { + public function __construct(public &$filterValues) {} + + public function getFilters(): ?array + { + return [ + new class() extends DateRangeFilter + { + public function label(): string + { + return 'Period'; + } + + public function buildFilterConfig(): void + { + $this->configureKey('period'); + } + }, + ]; + } + + protected function buildWidgetsData(): void + { + $this->filterValues = ['period' => $this->queryParams->filterFor('period')]; + $this->setPanelData('panel', ['name' => 'Marie Curie']); + } + }); + + $this->sharpDashboard(DashboardEntity::class) + ->withFilter('period', ['start' => '2021-01-01', 'end' => '2021-01-31']) + ->get() + ->assertOk(); + + expect($filterValues['period']->getStart()->format('Y-m-d'))->toEqual('2021-01-01') + ->and($filterValues['period']->getEnd()->format('Y-m-d'))->toEqual('2021-01-31'); +}); + +test('get show dashboard field', function () { + sharp()->config()->declareEntity(DashboardEntity::class); + + fakeShowFor(PersonEntity::class, new class() extends PersonShow + { + public function buildShowFields(FieldsContainer $showFields): void + { + $showFields->addField( + SharpShowDashboardField::make(DashboardEntity::class) + ); + } + + public function find($id): array + { + return ['name' => 'John Doe', 'age' => 31]; + } + }); + + fakeDashboardFor(DashboardEntity::class, new class() extends TestDashboard + { + protected function buildWidgetsData(): void + { + $this->setPanelData('panel', ['name' => 'Albert Einstein']); + } + }); + + $this->sharpShow(PersonEntity::class, 1) + ->sharpDashboardField(DashboardEntity::class) + ->get() + ->assertOk(); +}); + +it('call & assert a dashboard command', function () { + fakeDashboardFor(DashboardEntity::class, new class() extends TestDashboard + { + public function getDashboardCommands(): ?array + { + return [ + 'cmd-form' => new class() extends DashboardCommand + { + public function label(): ?string + { + return 'dashboard'; + } + + public function buildFormFields(FieldsContainer $formFields): void + { + $formFields->addField(SharpFormTextField::make('action')); + } + + public function execute(array $data = []): array + { + return match ($data['action']) { + 'info' => $this->info('dashboard'), + }; + } + }, + 'cmd' => new class() extends DashboardCommand + { + public function label(): ?string + { + return 'dashboard'; + } + + public function execute(array $data = []): array + { + return $this->info('dashboard'); + } + }, + ]; + } + }); + + $this->sharpDashboard(DashboardEntity::class) + ->dashboardCommand('cmd')->post() + ->assertReturnsInfo('dashboard'); + + $this->sharpDashboard(DashboardEntity::class) + ->dashboardCommand('cmd-form')->getForm()->post(['action' => 'info']) + ->assertReturnsInfo('dashboard'); +}); diff --git a/tests/Http/ShowControllerTest.php b/tests/Http/ShowControllerTest.php index fb89557cf..f87ab0841 100644 --- a/tests/Http/ShowControllerTest.php +++ b/tests/Http/ShowControllerTest.php @@ -219,22 +219,22 @@ public function findSingle(): array }); it('allows instance deletion from the show', function () { - $personShow = new class() extends PersonShow + $deletedId = null; + + fakeShowFor('person', new class($deletedId) extends PersonShow { - public bool $wasDeleted = false; + public function __construct(public &$deletedId) {} public function delete($id): void { - $this->wasDeleted = true; + $this->deletedId = $id; } - }; - - fakeShowFor('person', $personShow); + }); $this->delete('/sharp/root/s-list/person/s-show/person/1') ->assertRedirect('/sharp/root/s-list/person'); - expect($personShow->wasDeleted)->toBeTrue(); + expect($deletedId)->toEqual(1); }); it('disallows instance deletion without authorization', function () { diff --git a/tests/Unit/Utils/Testing/SharpAssertionsTest.php b/tests/Unit/Utils/Testing/SharpLegacyAssertionsTest.php similarity index 99% rename from tests/Unit/Utils/Testing/SharpAssertionsTest.php rename to tests/Unit/Utils/Testing/SharpLegacyAssertionsTest.php index 64b2fc166..551deb40a 100644 --- a/tests/Unit/Utils/Testing/SharpAssertionsTest.php +++ b/tests/Unit/Utils/Testing/SharpLegacyAssertionsTest.php @@ -2,8 +2,6 @@ use Code16\Sharp\Utils\Testing\SharpAssertions; -uses(SharpAssertions::class); - it('allows to test getSharpShow', function () { $response = fakeResponse()->getSharpShow('leaves', 6);