From 2df229ee964398cdd78ad0d2543e7f97fd84704b Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Tue, 29 Oct 2024 14:02:16 +0100 Subject: [PATCH] Add tests for export endpoints --- .env.ci | 22 +- .env.example | 18 +- .github/workflows/phpunit.yml | 10 +- ...reIsNotAvailableInFreePlanApiException.php | 10 + .../Api/V1/TimeEntryController.php | 31 +- app/Service/TimeEntryAggregationService.php | 37 +- config/services.php | 2 + lang/en/exceptions.php | 2 + tests/TestCase.php | 17 +- .../Api/V1/ApiEndpointTestAbstract.php | 12 +- .../Endpoint/Api/V1/TimeEntryEndpointTest.php | 487 ++++++++++++++++-- 11 files changed, 563 insertions(+), 85 deletions(-) create mode 100644 app/Exceptions/Api/FeatureIsNotAvailableInFreePlanApiException.php diff --git a/.env.ci b/.env.ci index 688472e6..79e1a884 100644 --- a/.env.ci +++ b/.env.ci @@ -6,12 +6,13 @@ APP_URL=http://localhost APP_FORCE_HTTPS=false SESSION_SECURE_COOKIE=false +# Logging LOG_CHANNEL=stack LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug +# Database DB_CONNECTION=pgsql_test - DB_TEST_HOST=127.0.0.1 DB_TEST_PORT=5432 DB_TEST_DATABASE=laravel @@ -20,26 +21,21 @@ DB_TEST_PASSWORD=root BROADCAST_DRIVER=log CACHE_DRIVER=file -FILESYSTEM_DISK=local QUEUE_CONNECTION=sync SESSION_DRIVER=database SESSION_LIFETIME=120 -MEMCACHED_HOST=127.0.0.1 - -REDIS_HOST=127.0.0.1 -REDIS_PASSWORD=null -REDIS_PORT=6379 - +# Mail MAIL_MAILER=log MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_NAME="${APP_NAME}" -S3_ACCESS_KEY_ID= -S3_SECRET_ACCESS_KEY= -S3_REGION=us-east-1 -S3_BUCKET= -S3_USE_PATH_STYLE_ENDPOINT=false +# Filesystems +FILESYSTEM_DISK=local +PUBLIC_FILESYSTEM_DISK=public + +# Services +GOTENBERG_URL=http://gotenberg:3000 PUSHER_APP_ID= PUSHER_APP_KEY= diff --git a/.env.example b/.env.example index a5613f04..1886023d 100644 --- a/.env.example +++ b/.env.example @@ -4,15 +4,15 @@ APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk= APP_DEBUG=true APP_URL=https://solidtime.test AUDITING_ENABLED=true - SUPER_ADMINS=admin@example.com +# Logging LOG_CHANNEL=single LOG_DEPRECATIONS_CHANNEL=deprecation LOG_LEVEL=debug +# Database DB_CONNECTION=pgsql - DB_HOST=pgsql DB_PORT=5432 DB_DATABASE=laravel @@ -31,14 +31,7 @@ QUEUE_CONNECTION=sync SESSION_DRIVER=database SESSION_LIFETIME=120 -GOTENBERG_URL=http://gotenberg:3000 - -MEMCACHED_HOST=127.0.0.1 - -REDIS_HOST=127.0.0.1 -REDIS_PASSWORD=null -REDIS_PORT=6379 - +# Mail MAIL_MAILER=smtp MAIL_HOST=mailpit MAIL_PORT=1025 @@ -56,7 +49,7 @@ PUSHER_PORT=443 PUSHER_SCHEME=https PUSHER_APP_CLUSTER=mt1 -# Storage +# Filesystems FILESYSTEM_DISK=s3 PUBLIC_FILESYSTEM_DISK=s3 S3_ACCESS_KEY_ID=sail @@ -67,6 +60,9 @@ S3_URL=http://storage.solidtime.test/local S3_ENDPOINT=http://storage.solidtime.test S3_USE_PATH_STYLE_ENDPOINT=true +# Services +GOTENBERG_URL=http://gotenberg:3000 + VITE_HOST_NAME=vite.solidtime.test VITE_APP_NAME="${APP_NAME}" VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}" diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index a64075d3..927e29e0 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -20,7 +20,15 @@ jobs: --health-interval 10s --health-timeout 5s --health-retries 5 - + gotenberg: + image: gotenberg/gotenberg:8 + ports: + - 3000:3000 + options: >- + --health-cmd "curl --silent --fail http://localhost:3000/health" + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - name: "Checkout code" uses: actions/checkout@v4 diff --git a/app/Exceptions/Api/FeatureIsNotAvailableInFreePlanApiException.php b/app/Exceptions/Api/FeatureIsNotAvailableInFreePlanApiException.php new file mode 100644 index 00000000..890ed646 --- /dev/null +++ b/app/Exceptions/Api/FeatureIsNotAvailableInFreePlanApiException.php @@ -0,0 +1,10 @@ +checkPermission($organization, 'time-entries:view:all'); } + $format = $request->getFormatValue(); + if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) { + throw new FeatureIsNotAvailableInFreePlanApiException; + } $timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member); $timeEntriesQuery->with([ @@ -180,7 +186,6 @@ public function indexExport(Organization $organization, TimeEntryIndexExportRequ 'user', 'tagsRelation', ]); - $format = $request->getFormatValue(); $filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension(); $folderPath = 'exports'; $path = $folderPath.'/'.$filename; @@ -201,6 +206,12 @@ public function indexExport(Organization $organization, TimeEntryIndexExportRequ throw new \LogicException('View file not found'); } $footerHtml = Blade::render($footerViewFile); + $client = new Client([ + 'auth' => config('services.gotenberg.basic_auth_username') !== null && config('services.gotenberg.basic_auth_password') !== null ? [ + config('services.gotenberg.basic_auth_username'), + config('services.gotenberg.basic_auth_password'), + ] : null, + ]); $request = Gotenberg::chromium(config('services.gotenberg.url')) ->pdf() ->pdfa('PDF/A-3b') @@ -208,7 +219,7 @@ public function indexExport(Organization $organization, TimeEntryIndexExportRequ ->footer(Stream::string('footer', $footerHtml)) ->html(Stream::string('body', $html)); $tempFolder = TemporaryDirectory::make(); - $filenameTemp = Gotenberg::save($request, $tempFolder->path()); + $filenameTemp = Gotenberg::save($request, $tempFolder->path(), $client); Storage::disk(config('filesystems.private')) ->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename); } else { @@ -301,6 +312,7 @@ public function aggregate(Organization $organization, TimeEntryAggregateRequest * @throws PdfRendererIsNotConfiguredException * @throws GotenbergApiErrored * @throws NoOutputFileInResponse + * @throws FeatureIsNotAvailableInFreePlanApiException */ public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse { @@ -311,6 +323,10 @@ public function aggregateExport(Organization $organization, TimeEntryAggregateEx } else { $this->checkPermission($organization, 'time-entries:view:all'); } + $format = $request->getFormatValue(); + if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) { + throw new FeatureIsNotAvailableInFreePlanApiException; + } $user = $this->user(); $group = $request->getGroup(); @@ -340,7 +356,6 @@ public function aggregateExport(Organization $organization, TimeEntryAggregateEx $currency = $organization->currency; $timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user()); - $format = $request->getFormatValue(); $filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension(); $folderPath = 'exports'; $path = $folderPath.'/'.$filename; @@ -349,6 +364,12 @@ public function aggregateExport(Organization $organization, TimeEntryAggregateEx if (config('services.gotenberg.url') === null) { throw new PdfRendererIsNotConfiguredException; } + $client = new Client([ + 'auth' => config('services.gotenberg.basic_auth_username') !== null && config('services.gotenberg.basic_auth_password') !== null ? [ + config('services.gotenberg.basic_auth_username'), + config('services.gotenberg.basic_auth_password'), + ] : null, + ]); $viewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate-index.blade.php')); if ($viewFile === false) { throw new \LogicException('View file not found'); @@ -374,7 +395,7 @@ public function aggregateExport(Organization $organization, TimeEntryAggregateEx ->footer(Stream::string('footer', $footerHtml)) ->html(Stream::string('body', $html)); $tempFolder = TemporaryDirectory::make(); - $filenameTemp = Gotenberg::save($request, $tempFolder->path()); + $filenameTemp = Gotenberg::save($request, $tempFolder->path(), $client); Storage::disk(config('filesystems.private')) ->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename); } else { diff --git a/app/Service/TimeEntryAggregationService.php b/app/Service/TimeEntryAggregationService.php index 01635bd8..eecbebff 100644 --- a/app/Service/TimeEntryAggregationService.php +++ b/app/Service/TimeEntryAggregationService.php @@ -184,20 +184,6 @@ public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQue $descriptionMapGroup2 = $group2Type !== null ? $this->loadDescriptionMap($keysGroup2, $group2Type) : []; if ($aggregatedTimeEntries['grouped_data'] !== null) { - /* - $aggregatedTimeEntries['grouped_data'] = array_map(function (array $value) use ($descriptionMapGroup1, $descriptionMapGroup2): array { - $value['description'] = $value['key'] !== null ? ($descriptionMapGroup1[$value['key']] ?? null) : null; - if ($value['grouped_data'] !== null) { - $value['grouped_data'] = array_map(function (array $value) use ($descriptionMapGroup2): array { - $value['description'] = $value['key'] !== null ? ($descriptionMapGroup2[$value['key']] ?? null) : null; - - return $value; - }, $value['grouped_data']); - } - - return $value; - }, $aggregatedTimeEntries['grouped_data']); - */ foreach ($aggregatedTimeEntries['grouped_data'] as $keyGroup1 => $group1) { $aggregatedTimeEntries['grouped_data'][$keyGroup1]['description'] = $group1['key'] !== null ? ($descriptionMapGroup1[$group1['key']] ?? null) : null; if ($aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'] !== null) { @@ -208,6 +194,29 @@ public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQue } } + /** + * @var array{ + * grouped_type: string|null, + * grouped_data: null|array + * }>, + * seconds: int, + * cost: int + * } $aggregatedTimeEntries + */ + return $aggregatedTimeEntries; } diff --git a/config/services.php b/config/services.php index b70a19ba..57c8cdb7 100644 --- a/config/services.php +++ b/config/services.php @@ -5,5 +5,7 @@ return [ 'gotenberg' => [ 'url' => env('GOTENBERG_URL'), + 'basic_auth_username' => env('GOTENBERG_BASIC_AUTH_USERNAME'), + 'basic_auth_password' => env('GOTENBERG_BASIC_AUTH_PASSWORD'), ], ]; diff --git a/lang/en/exceptions.php b/lang/en/exceptions.php index 28295e5b..629f6f6b 100644 --- a/lang/en/exceptions.php +++ b/lang/en/exceptions.php @@ -6,6 +6,7 @@ use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization; use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed; use App\Exceptions\Api\EntityStillInUseApiException; +use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException; use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException; use App\Exceptions\Api\OnlyOwnerCanChangeOwnership; use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException; @@ -35,6 +36,7 @@ ExportException::KEY => 'Export failed, please try again later or contact support', OrganizationHasNoSubscriptionButMultipleMembersException::KEY => 'Organization has no subscription but multiple members', PdfRendererIsNotConfiguredException::KEY => 'PDF renderer is not configured', + FeatureIsNotAvailableInFreePlanApiException::KEY => 'Feature is not available in free plan', ], 'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.', ]; diff --git a/tests/TestCase.php b/tests/TestCase.php index 6e459af0..54f86be5 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -25,12 +25,7 @@ protected function setUp(): void parent::setUp(); Mail::fake(); LogFake::bind(); - $this->mock(BillingContract::class, function (MockInterface $mock): void { - $mock->shouldReceive('hasSubscription')->andReturn(false); - $mock->shouldReceive('hasTrial')->andReturn(false); - $mock->shouldReceive('getTrialUntil')->andReturn(null); - $mock->shouldReceive('isBlocked')->andReturn(false); - }); + $this->actAsOrganizationWithoutSubscriptionAndWithoutTrial(); // Note: The following line can be used to test timezone edge cases. // $this->travelTo(Carbon::now()->timezone('Europe/Vienna')->setHour(0)->setMinute(59)->setSecond(0)); } @@ -89,4 +84,14 @@ protected function actAsOrganizationWithSubscription(): void $mock->shouldReceive('isBlocked')->andReturn(false); }); } + + protected function actAsOrganizationWithoutSubscriptionAndWithoutTrial(): void + { + $this->mock(BillingContract::class, function (MockInterface $mock): void { + $mock->shouldReceive('hasSubscription')->andReturn(false); + $mock->shouldReceive('hasTrial')->andReturn(false); + $mock->shouldReceive('getTrialUntil')->andReturn(null); + $mock->shouldReceive('isBlocked')->andReturn(false); + }); + } } diff --git a/tests/Unit/Endpoint/Api/V1/ApiEndpointTestAbstract.php b/tests/Unit/Endpoint/Api/V1/ApiEndpointTestAbstract.php index 26f89c44..b3c7adeb 100644 --- a/tests/Unit/Endpoint/Api/V1/ApiEndpointTestAbstract.php +++ b/tests/Unit/Endpoint/Api/V1/ApiEndpointTestAbstract.php @@ -4,6 +4,16 @@ namespace Tests\Unit\Endpoint\Api\V1; +use Illuminate\Testing\TestResponse; use Tests\TestCaseWithDatabase; -class ApiEndpointTestAbstract extends TestCaseWithDatabase {} +class ApiEndpointTestAbstract extends TestCaseWithDatabase +{ + protected function assertResponseCode(TestResponse $response, int $statusCode): void + { + if ($response->getStatusCode() !== $statusCode) { + dump($response->getContent()); + } + $response->assertStatus($statusCode); + } +} diff --git a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php index 0f8a57fa..ae7fb9e7 100644 --- a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php @@ -4,7 +4,10 @@ namespace Tests\Unit\Endpoint\Api\V1; +use App\Enums\ExportFormat; use App\Enums\Role; +use App\Enums\TimeEntryAggregationType; +use App\Enums\TimeEntryAggregationTypeInterval; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Http\Controllers\Api\V1\TimeEntryController; use App\Jobs\RecalculateSpentTimeForProject; @@ -17,6 +20,7 @@ use App\Models\TimeEntry; use App\Models\User; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Str; @@ -73,7 +77,7 @@ public function test_index_endpoint_returns_time_entries_for_current_user(): voi ])); // Assert - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $response->assertJsonPath('data.0.id', $timeEntry->getKey()); } @@ -114,7 +118,7 @@ public function test_index_endpoint_returns_time_entries_for_other_user_in_organ $response = $this->getJson(route('api.v1.time-entries.index', [$data->organization->getKey(), 'user_id' => $user->getKey()])); // Assert - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $response->assertJsonPath('data.0.id', $timeEntry->getKey()); } @@ -141,7 +145,7 @@ public function test_index_endpoint_returns_time_entries_for_all_users_in_organi $response = $this->getJson(route('api.v1.time-entries.index', [$data->organization->getKey()])); // Assert - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $response->assertJsonPath('data.0.id', $timeEntry1->getKey()); $response->assertJsonPath('data.1.id', $timeEntry2->getKey()); $response->assertJsonPath('data.2.id', $timeEntry3->getKey()); @@ -165,7 +169,7 @@ public function test_index_endpoint_returns_only_active_time_entries(): void ])); // Assert - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $response->assertJsonCount(1, 'data'); $response->assertJsonPath('meta.total', 1); $response->assertJsonPath('data.0.id', $activeTimeEntry->getKey()); @@ -189,7 +193,7 @@ public function test_index_endpoint_returns_only_non_active_time_entries(): void ])); // Assert - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $response->assertJsonCount(1, 'data'); $response->assertJsonPath('meta.total', 1); $response->assertJsonPath('data.0.id', $nonActiveTimeEntries->getKey()); @@ -213,7 +217,7 @@ public function test_index_endpoint_filter_only_full_dates_returns_time_entries_ ])); // Assert - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $response->assertJsonCount(3, 'data'); $response->assertJsonPath('meta.total', 3); } @@ -241,7 +245,7 @@ public function test_index_endpoint_filter_only_full_dates_returns_time_entries_ ])); // Assert - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $response->assertJsonCount(3, 'data'); $response->assertJsonPath('meta.total', 6); } @@ -290,7 +294,7 @@ public function test_index_endpoint_filter_only_full_dates_returns_time_entries_ ])); // Assert - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $response->assertJsonCount(2, 'data'); $response->assertJsonPath('meta.total', 7); } @@ -324,7 +328,7 @@ public function test_index_endpoint_filter_only_full_dates_returns_time_entries_ ])); // Assert - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $response->assertJsonCount(7, 'data'); $response->assertJsonPath('meta.total', 10); Log::assertLogged(fn (LogEntry $log) => $log->level === 'warning' @@ -365,7 +369,7 @@ public function test_index_endpoint_before_filter_returns_time_entries_before_da ])); // Assert - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $response->assertJson(fn (AssertableJson $json) => $json ->has('data') ->has('meta') @@ -405,7 +409,7 @@ public function test_index_endpoint_after_filter_returns_time_entries_after_date ])); // Assert - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $response->assertJson(fn (AssertableJson $json) => $json ->has('data') ->has('meta') @@ -458,7 +462,7 @@ public function test_index_endpoint_with_all_available_filters(): void // Assert $response->assertValid(); - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $response->assertJson(fn (AssertableJson $json) => $json ->has('data') ->has('meta') @@ -499,12 +503,189 @@ public function test_index_endpoint_with_limit_offset_and_only_full_dates_deacti ])); // Assert - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $response->assertJsonCount(1, 'data'); $response->assertJsonPath('meta.total', 3); $response->assertJsonPath('data.*.id', [$timeEntry->getKey()]); } + public function test_index_export_endpoint_fails_if_user_has_no_permission_to_view_time_entries(): void + { + // Arrange + $data = $this->createUserWithPermission(); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.index-export', [ + $data->organization->getKey(), + 'format' => ExportFormat::CSV, + ])); + + // Assert + $response->assertForbidden(); + } + + public function test_index_export_endpoint_fails_if_pdf_renderer_is_not_configured_but_a_user_want_a_pdf_report(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + Passport::actingAs($data->user); + Config::set('services.gotenberg.url', null); + $this->actAsOrganizationWithSubscription(); + + // Act + $response = $this->getJson(route('api.v1.time-entries.index-export', [ + $data->organization->getKey(), + 'format' => ExportFormat::PDF, + ])); + + // Assert + $response->assertStatus(400); + $response->assertExactJson([ + 'error' => true, + 'key' => 'pdf_renderer_is_not_configured', + 'message' => 'PDF renderer is not configured', + ]); + } + + public function test_index_export_endpoint_fails_if_user_wants_a_pdf_export_but_has_no_subscription(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + Passport::actingAs($data->user); + $this->actAsOrganizationWithoutSubscriptionAndWithoutTrial(); + + // Act + $response = $this->getJson(route('api.v1.time-entries.index-export', [ + $data->organization->getKey(), + 'format' => ExportFormat::PDF, + ])); + + // Assert + $response->assertStatus(400); + $response->assertExactJson([ + 'error' => true, + 'key' => 'feature_is_not_available_in_free_plan', + 'message' => 'Feature is not available in free plan', + ]); + } + + public function test_index_export_endpoint_fails_if_user_has_only_access_to_own_time_entries_but_does_not_filter_for_this(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:own', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.index-export', [ + $data->organization->getKey(), + 'format' => ExportFormat::CSV, + ])); + + // Assert + $response->assertForbidden(); + } + + public function test_index_endpoint_can_create_a_detailed_time_entry_report_in_format_csv(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.index-export', [ + $data->organization->getKey(), + 'format' => ExportFormat::CSV, + ])); + + // Assert + $this->assertResponseCode($response, 200); + } + + public function test_index_endpoint_can_create_a_detailed_time_entry_report_in_format_ods(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.index-export', [ + $data->organization->getKey(), + 'format' => ExportFormat::ODS, + ])); + + // Assert + $this->assertResponseCode($response, 200); + } + + public function test_index_endpoint_can_create_a_detailed_time_entry_report_in_format_xlxs(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.index-export', [ + $data->organization->getKey(), + 'format' => ExportFormat::XLSX, + ])); + + // Assert + $this->assertResponseCode($response, 200); + } + + public function test_index_endpoint_can_create_a_detailed_time_entry_report_in_format_pdf(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + Passport::actingAs($data->user); + $this->actAsOrganizationWithSubscription(); + + // Act + $response = $this->getJson(route('api.v1.time-entries.index-export', [ + $data->organization->getKey(), + 'format' => ExportFormat::PDF, + ])); + + // Assert + $this->assertResponseCode($response, 200); + } + + public function test_aggregate_export_endpoints_fails_if_user_no_permission_to_view_time_entries(): void + { + // Arrange + $data = $this->createUserWithPermission(); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [ + $data->organization->getKey(), + 'format' => ExportFormat::CSV, + 'group' => TimeEntryAggregationType::Client, + 'sub_group' => TimeEntryAggregationType::Project, + 'history_group' => TimeEntryAggregationTypeInterval::Month, + 'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(), + 'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(), + ])); + + // Assert + $response->assertForbidden(); + } + public function test_aggregate_endpoint_fails_if_user_has_no_permission_to_view_time_entries(): void { // Arrange @@ -512,12 +693,250 @@ public function test_aggregate_endpoint_fails_if_user_has_no_permission_to_view_ Passport::actingAs($data->user); // Act - $response = $this->getJson(route('api.v1.time-entries.aggregate', [$data->organization->getKey()])); + $response = $this->getJson(route('api.v1.time-entries.aggregate', [ + $data->organization->getKey(), + ])); + + // Assert + $response->assertForbidden(); + } + + public function test_aggregate_export_endpoint_fails_if_user_wants_a_pdf_export_but_has_no_subscription(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + Passport::actingAs($data->user); + $this->actAsOrganizationWithoutSubscriptionAndWithoutTrial(); + + // Act + $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [ + $data->organization->getKey(), + 'format' => ExportFormat::PDF, + 'group' => TimeEntryAggregationType::Client, + 'sub_group' => TimeEntryAggregationType::Project, + 'history_group' => TimeEntryAggregationTypeInterval::Month, + 'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(), + 'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(), + ])); + + // Assert + $response->assertStatus(400); + $response->assertExactJson([ + 'error' => true, + 'key' => 'feature_is_not_available_in_free_plan', + 'message' => 'Feature is not available in free plan', + ]); + } + + public function test_aggregate_export_endpoint_fails_if_user_has_only_access_to_own_time_entries_but_does_not_filter_for_this(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:own', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [ + $data->organization->getKey(), + 'format' => ExportFormat::CSV, + 'group' => TimeEntryAggregationType::Client, + 'sub_group' => TimeEntryAggregationType::Project, + 'history_group' => TimeEntryAggregationTypeInterval::Month, + 'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(), + 'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(), + ])); // Assert $response->assertForbidden(); } + public function test_aggregate_export_endpoints_can_create_a_csv_report(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [ + $data->organization->getKey(), + 'format' => ExportFormat::CSV, + 'group' => TimeEntryAggregationType::Client, + 'sub_group' => TimeEntryAggregationType::Project, + 'history_group' => TimeEntryAggregationTypeInterval::Month, + 'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(), + 'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(), + ])); + + // Assert + $this->assertResponseCode($response, 200); + } + + public function test_aggregate_export_endpoints_can_create_a_xlsx_report(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [ + $data->organization->getKey(), + 'format' => ExportFormat::XLSX, + 'group' => TimeEntryAggregationType::Client, + 'sub_group' => TimeEntryAggregationType::Project, + 'history_group' => TimeEntryAggregationTypeInterval::Month, + 'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(), + 'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(), + ])); + + // Assert + $this->assertResponseCode($response, 200); + } + + public function test_aggregate_export_endpoints_can_create_a_ods_report(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [ + $data->organization->getKey(), + 'format' => ExportFormat::ODS, + 'group' => TimeEntryAggregationType::User, + 'sub_group' => TimeEntryAggregationType::Project, + 'history_group' => TimeEntryAggregationTypeInterval::Month, + 'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(), + 'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(), + ])); + + // Assert + $this->assertResponseCode($response, 200); + } + + public function test_aggregate_export_endpoint_fails_if_pdf_renderer_is_not_configured_but_a_user_want_a_pdf_report(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + Passport::actingAs($data->user); + $this->actAsOrganizationWithSubscription(); + Config::set('services.gotenberg.url', null); + + // Act + $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [ + $data->organization->getKey(), + 'format' => ExportFormat::PDF, + 'group' => TimeEntryAggregationType::User, + 'sub_group' => TimeEntryAggregationType::Project, + 'history_group' => TimeEntryAggregationTypeInterval::Month, + 'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(), + 'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(), + ])); + + // Assert + $response->assertStatus(400); + $response->assertExactJson([ + 'error' => true, + 'key' => 'pdf_renderer_is_not_configured', + 'message' => 'PDF renderer is not configured', + ]); + } + + public function test_aggregate_export_endpoints_can_create_a_pdf_report(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + Passport::actingAs($data->user); + $this->actAsOrganizationWithSubscription(); + + // Act + $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [ + $data->organization->getKey(), + 'format' => ExportFormat::PDF, + 'group' => TimeEntryAggregationType::User, + 'sub_group' => TimeEntryAggregationType::Project, + 'history_group' => TimeEntryAggregationTypeInterval::Month, + 'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(), + 'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(), + ])); + + // Assert + $this->assertResponseCode($response, 200); + } + + public function test_aggregate_endpoint_fails_if_user_has_only_access_to_own_time_entries_but_does_not_filter_for_this(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:own', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.aggregate', [ + $data->organization->getKey(), + 'group' => 'day', + 'sub_group' => 'project', + ])); + + // Assert + $response->assertForbidden(); + } + + public function test_aggregate_endpoint_works_for_user_with_only_access_to_own_time_entries(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:own', + ]); + $otherUser = User::factory()->create(); + $otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->create(); + $project = Project::factory()->forOrganization($data->organization)->create(); + $start = Carbon::now()->timezone($data->user->timezone)->subDays(2); + $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->forProject($project)->startWithDuration($start, 100)->create(); + $timeEntryOtherMember = TimeEntry::factory()->forOrganization($data->organization)->forMember($otherMember)->forProject($project)->startWithDuration($start, 100)->create(); + + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.aggregate', [ + $data->organization->getKey(), + 'member_id' => $data->member->getKey(), + 'group' => 'project', + ])); + + // Assert + $response->assertSuccessful(); + $response->assertExactJson([ + 'data' => [ + 'seconds' => 100, + 'cost' => 0, + 'grouped_data' => [ + 0 => [ + 'key' => $project->getKey(), + 'seconds' => 100, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + ], + 'grouped_type' => 'project', + ], + ]); + } + public function test_aggregate_endpoint_groups_by_two_groups(): void { // Arrange @@ -1314,7 +1733,7 @@ public function test_update_endpoint_updates_time_entry_for_current_user(): void ]); // Assert - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $this->assertDatabaseHas(TimeEntry::class, [ 'id' => $timeEntry->getKey(), 'member_id' => $data->member->getKey(), @@ -1343,7 +1762,7 @@ public function test_update_endpoints_sets_billable_rate(): void ]); // Assert - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $this->assertDatabaseHas(TimeEntry::class, [ 'id' => $timeEntry->getKey(), 'member_id' => $data->member->getKey(), @@ -1372,7 +1791,7 @@ public function test_update_endpoint_updates_time_entry_for_current_user_but_doe ]); // Assert - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $this->assertDatabaseHas(TimeEntry::class, [ 'id' => $timeEntry->getKey(), 'member_id' => $data->member->getKey(), @@ -1429,7 +1848,7 @@ public function test_update_endpoint_updates_time_entry_of_other_user_in_organiz ]); // Assert - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $this->assertDatabaseHas(TimeEntry::class, [ 'id' => $timeEntry->getKey(), 'member_id' => $member->getKey(), @@ -1457,7 +1876,7 @@ public function test_update_endpoint_can_update_project_and_automatically_set_cl // Assert $response->assertValid(); - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $this->assertDatabaseHas(TimeEntry::class, [ 'id' => $timeEntry->getKey(), 'member_id' => $member->getKey(), @@ -1487,7 +1906,7 @@ public function test_update_endpoint_can_removed_project_from_time_entry_and_aut // Assert $response->assertValid(); - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $this->assertDatabaseHas(TimeEntry::class, [ 'id' => $timeEntry->getKey(), 'member_id' => $member->getKey(), @@ -1520,7 +1939,7 @@ public function test_update_endpoint_recalculates_project_and_task_spend_time_af ]); // Assert - $response->assertStatus(200); + $this->assertResponseCode($response, 200); Queue::assertPushed(RecalculateSpentTimeForProject::class, 1); Queue::assertPushed(RecalculateSpentTimeForTask::class, 1); Queue::assertPushed(function (RecalculateSpentTimeForProject $job) use ($project): bool { @@ -1556,7 +1975,7 @@ public function test_update_endpoint_recalculates_project_and_task_spend_time_af ]); // Assert - $response->assertStatus(200); + $this->assertResponseCode($response, 200); Queue::assertPushed(RecalculateSpentTimeForProject::class, 2); Queue::assertPushed(RecalculateSpentTimeForTask::class, 2); Queue::assertPushed(function (RecalculateSpentTimeForProject $job) use ($project): bool { @@ -1749,7 +2168,7 @@ public function test_destroy_multiple_endpoint_own_time_entries_and_fails_for_ti // Assert $response->assertValid(); - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $response->assertExactJson([ 'success' => [ $ownTimeEntry->getKey(), @@ -1799,7 +2218,7 @@ public function test_destroy_multiple_deletes_all_time_entries_and_fails_for_tim // Assert $response->assertValid(); - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $response->assertExactJson([ 'success' => [ $ownTimeEntry->getKey(), @@ -1851,7 +2270,7 @@ public function test_destroy_multiple_recalculates_project_and_task_spend_time_a // Assert $response->assertValid(); - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $response->assertExactJson([ 'success' => [ $timeEntryWithProject->getKey(), @@ -1982,7 +2401,7 @@ public function test_update_multiple_remove_task_from_time_entries_only_if_proje // Assert $response->assertValid(); - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $response->assertExactJson([ 'success' => [ $timeEntry1->getKey(), @@ -2034,7 +2453,7 @@ public function test_update_multiple_updates_own_time_entries_and_fails_for_time // Assert $response->assertValid(); - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $response->assertExactJson([ 'success' => [ $ownTimeEntry->getKey(), @@ -2098,7 +2517,7 @@ public function test_update_multiple_updates_own_time_entries_and_fails_for_time // Assert $response->assertValid(); - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $response->assertExactJson([ 'success' => [ $ownTimeEntry->getKey(), @@ -2170,7 +2589,7 @@ public function test_update_multiple_updates_all_time_entries_and_fails_for_time // Assert $response->assertValid(); - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $response->assertExactJson([ 'success' => [ $ownTimeEntry->getKey(), @@ -2234,7 +2653,7 @@ public function test_update_multiple_updates_all_time_entries_and_fails_for_time // Assert $response->assertValid(); - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $response->assertExactJson([ 'success' => [ $ownTimeEntry->getKey(), @@ -2300,7 +2719,7 @@ public function test_update_multiple_refreshes_billable_rate_on_updates_time_ent // Assert $response->assertValid(); - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $response->assertExactJson([ 'success' => [ $timeEntry1->getKey(), @@ -2348,7 +2767,7 @@ public function test_update_multiple_ignores_other_fields_in_changes(): void // Assert $response->assertValid(); - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $response->assertExactJson([ 'success' => [ $timeEntry1->getKey(), @@ -2387,7 +2806,7 @@ public function test_update_multiple_can_update_project_and_sets_client_automati // Assert $response->assertValid(); - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $response->assertExactJson([ 'success' => [ $timeEntry1->getKey(), @@ -2438,7 +2857,7 @@ public function test_update_multiple_can_remove_project_from_time_entries_and_se // Assert $response->assertValid(); - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $response->assertExactJson([ 'success' => [ $timeEntry1->getKey(),