diff --git a/.env.ci b/.env.ci index 688472e6..0deb6363 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://0.0.0.0: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/Models/Tag.php b/app/Models/Tag.php index 634f8d07..67c844a7 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -7,6 +7,7 @@ use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\HasUuids; use Database\Factories\TagFactory; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -21,6 +22,7 @@ * @property string $organization_id * @property Carbon|null $created_at * @property Carbon|null $updated_at + * @property-read Collection $timeEntries * @property-read Organization $organization * * @method static TagFactory factory() 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 d9fbb739..4308cc0d 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,8 +20,10 @@ 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\Facades\Storage; use Illuminate\Support\Str; use Illuminate\Testing\Fluent\AssertableJson; use Laravel\Passport\Passport; @@ -29,6 +34,12 @@ #[UsesClass(TimeEntryController::class)] class TimeEntryEndpointTest extends ApiEndpointTestAbstract { + protected function setUp(): void + { + parent::setUp(); + Storage::fake('local'); + } + public function test_index_endpoint_fails_if_user_has_no_permission_to_view_time_entries(): void { // Arrange @@ -73,7 +84,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 +125,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 +152,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 +176,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 +200,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 +224,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 +252,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 +301,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 +335,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 +376,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 +416,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 +469,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 +510,201 @@ 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_export_endpoint_can_create_a_detailed_time_entry_report_in_format_csv(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + $client = Client::factory()->forOrganization($data->organization)->create(); + $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create(); + $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create(); + $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create(); + 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_export_endpoint_can_create_a_detailed_time_entry_report_in_format_ods(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + $client = Client::factory()->forOrganization($data->organization)->create(); + $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create(); + $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create(); + $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create(); + 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_export_endpoint_can_create_a_detailed_time_entry_report_in_format_xlxs(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + $client = Client::factory()->forOrganization($data->organization)->create(); + $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create(); + $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create(); + $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create(); + 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_export_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 +712,266 @@ 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', + ]); + $client = Client::factory()->forOrganization($data->organization)->create(); + $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create(); + $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create(); + $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create(); + 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', + ]); + $client = Client::factory()->forOrganization($data->organization)->create(); + $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create(); + $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create(); + $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create(); + 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', + ]); + $client = Client::factory()->forOrganization($data->organization)->create(); + $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create(); + $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create(); + $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create(); + 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', + ]); + $client = Client::factory()->forOrganization($data->organization)->create(); + $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create(); + $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create(); + $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create(); + 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 +1768,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 +1797,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 +1826,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 +1883,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 +1911,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 +1941,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 +1974,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 +2010,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 +2203,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 +2253,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 +2305,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 +2436,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 +2488,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 +2552,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(), @@ -2215,7 +2669,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(), @@ -2279,7 +2733,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(), @@ -2345,7 +2799,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(), @@ -2393,7 +2847,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(), @@ -2432,7 +2886,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(), @@ -2483,7 +2937,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(), diff --git a/tests/Unit/Model/TagModelTest.php b/tests/Unit/Model/TagModelTest.php index 2e1d88c0..336870e8 100644 --- a/tests/Unit/Model/TagModelTest.php +++ b/tests/Unit/Model/TagModelTest.php @@ -6,6 +6,7 @@ use App\Models\Organization; use App\Models\Tag; +use App\Models\TimeEntry; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; @@ -17,14 +18,39 @@ public function test_it_belongs_to_a_organization(): void { // Arrange $organization = Organization::factory()->create(); - $task = Tag::factory()->forOrganization($organization)->create(); + $tag = Tag::factory()->forOrganization($organization)->create(); // Act - $task->refresh(); - $organizationRel = $task->organization; + $tag->refresh(); + $organizationRel = $tag->organization; // Assert $this->assertNotNull($organizationRel); $this->assertTrue($organizationRel->is($organization)); } + + public function test_it_has_many_time_entries_via_json_field(): void + { + // Arrange + $organization = Organization::factory()->create(); + $tag1 = Tag::factory()->forOrganization($organization)->create(); + $tag2 = Tag::factory()->forOrganization($organization)->create(); + $timeEntry1 = TimeEntry::factory()->forOrganization($organization)->create([ + 'tags' => [$tag1->id, $tag2->id], + ]); + $timeEntry2 = TimeEntry::factory()->forOrganization($organization)->create([ + 'tags' => [$tag1->id], + ]); + $timeEntry3 = TimeEntry::factory()->forOrganization($organization)->create([ + 'tags' => [$tag2->id], + ]); + + // Act + $tag1->refresh(); + $timeEntries = $tag1->timeEntries; + + // Assert + $this->assertCount(2, $timeEntries); + $this->assertEqualsCanonicalizing([$timeEntry1->getKey(), $timeEntry2->getKey()], $timeEntries->pluck('id')->toArray()); + } } diff --git a/tests/Unit/Model/TimeEntryModelTest.php b/tests/Unit/Model/TimeEntryModelTest.php index a790c429..eca36a2c 100644 --- a/tests/Unit/Model/TimeEntryModelTest.php +++ b/tests/Unit/Model/TimeEntryModelTest.php @@ -179,4 +179,50 @@ public function test_computed_client_id_returns_project_client_id(): void // Assert $this->assertSame($project->client_id, $clientId); } + + public function test_has_many_tags_via_json_relation(): void + { + // Arrange + $tag1 = Tag::factory()->create(); + $tag2 = Tag::factory()->create(); + $timeEntry = TimeEntry::factory()->create([ + 'tags' => [$tag1->getKey(), $tag2->getKey()], + ]); + + // Act + $timeEntry->refresh(); + $tags = $timeEntry->tagsRelation; + + // Assert + $this->assertCount(2, $tags); + $this->assertTrue($tags->contains($tag1)); + $this->assertTrue($tags->contains($tag2)); + } + + public function test_has_many_tags_via_json_relation_eager_loaded(): void + { + // Arrange + $tag1 = Tag::factory()->create(); + $tag2 = Tag::factory()->create(); + $timeEntry1 = TimeEntry::factory()->create([ + 'tags' => [$tag1->getKey(), $tag2->getKey()], + 'created_at' => Carbon::now()->subDay(), + ]); + $timeEntry2 = TimeEntry::factory()->create([ + 'tags' => [$tag1->getKey()], + 'created_at' => Carbon::now()->subDays(2), + ]); + + // Act + $timeEntries = TimeEntry::with('tagsRelation')->orderBy('created_at', 'desc')->get(); + $tags1 = $timeEntries->get(0)->tagsRelation; + $tags2 = $timeEntries->get(1)->tagsRelation; + + // Assert + $this->assertCount(2, $tags1); + $this->assertTrue($tags1->contains($tag1)); + $this->assertTrue($tags1->contains($tag2)); + $this->assertCount(1, $tags2); + $this->assertTrue($tags2->contains($tag1)); + } }