From c69edc81bef01c20ffbf3fe670ab0d7e968289f5 Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Wed, 14 Oct 2026 13:29:32 +0200 Subject: [PATCH] Add tests for billable rate restriction in importers and API edge cases --- .../Endpoint/Api/V1/MemberEndpointTest.php | 21 ++++++ .../Endpoint/Api/V1/TimeEntryEndpointTest.php | 67 +++++++++++++++++++ .../ClockifyProjectsImporterTest.php | 22 ++++++ .../ClockifyTimeEntriesImporterTest.php | 22 ++++++ .../Importers/GenericProjectsImporterTest.php | 22 ++++++ .../GenericTimeEntriesImporterTest.php | 22 ++++++ .../Importers/HarvestProjectsImporterTest.php | 22 ++++++ .../HarvestTimeEntriesImporterTest.php | 28 ++++++++ .../Importers/SolidtimeImporterTest.php | 11 +++ .../Importers/TogglDataImporterTest.php | 28 ++++++++ .../TogglTimeEntriesImporterTest.php | 22 ++++++ 11 files changed, 287 insertions(+) diff --git a/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php b/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php index b6b66cfa..0f43792c 100644 --- a/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php @@ -1186,4 +1186,25 @@ class MemberEndpointTest extends ApiEndpointTestAbstract $this->assertSame(10000, $timeEntry->refresh()->billable_rate); } + public function test_member_update_does_not_remove_billable_rate_if_organization_can_not_use_billable_rates(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'members:update', + ]); + $member = Member::factory()->forOrganization($data->organization)->forUser(User::factory()->create())->role(Role::Employee)->create([ + 'billable_rate' => 10000, + ]); + $this->actAsOrganizationWithoutBillableRates(); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $member->getKey()]), [ + 'billable_rate' => null, + ]); + + // Assert + $response->assertOk(); + $this->assertSame(10000, $member->refresh()->billable_rate); + } } diff --git a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php index bff9cafc..c6af4d2b 100644 --- a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php @@ -6253,6 +6253,73 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract $this->assertSame('Mass update', $nonBillableTimeEntry->description); } + public function test_time_entry_update_multiple_can_remove_billable_flag_if_organization_can_not_use_billable_rates(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:update:own', + ]); + $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->billable()->create(); + $this->actAsOrganizationWithoutBillableRates(); + Passport::actingAs($data->user); + + // Act + $response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [ + 'ids' => [$timeEntry->getKey()], + 'changes' => [ + 'billable' => false, + ], + ]); + + // Assert + $response->assertOk(); + $response->assertExactJson([ + 'success' => [$timeEntry->getKey()], + 'error' => [], + ]); + $timeEntry->refresh(); + $this->assertFalse($timeEntry->billable); + $this->assertNull($timeEntry->billable_rate); + } + + public function test_time_entry_update_with_project_change_does_not_make_time_entry_billable_if_organization_can_not_use_billable_rates(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:update:own', + ]); + $project = Project::factory()->forOrganization($data->organization)->isPublic()->billable(10000)->create(); + $otherProject = Project::factory()->forOrganization($data->organization)->isPublic()->billable(20000)->create(); + $billableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->forProject($project)->billable() + ->startWithDuration(Carbon::now()->subHours(5), 3600)->create(); + $nonBillableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->notBillable() + ->startWithDuration(Carbon::now()->subHours(3), 3600)->create(); + $this->actAsOrganizationWithoutBillableRates(); + Passport::actingAs($data->user); + + // Act + $responseBillable = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $billableTimeEntry->getKey()]), [ + 'project_id' => $otherProject->getKey(), + 'billable' => true, + ]); + $responseNonBillable = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $nonBillableTimeEntry->getKey()]), [ + 'project_id' => $otherProject->getKey(), + 'billable' => true, + ]); + + // Assert + $responseBillable->assertOk(); + $responseNonBillable->assertOk(); + $billableTimeEntry->refresh(); + $this->assertSame($otherProject->getKey(), $billableTimeEntry->project_id); + $this->assertTrue($billableTimeEntry->billable); + $this->assertSame(20000, $billableTimeEntry->billable_rate); + $nonBillableTimeEntry->refresh(); + $this->assertSame($otherProject->getKey(), $nonBillableTimeEntry->project_id); + $this->assertFalse($nonBillableTimeEntry->billable); + $this->assertNull($nonBillableTimeEntry->billable_rate); + } + public function test_time_entry_store_does_not_check_billable_rates_for_non_billable_time_entry(): void { // Arrange diff --git a/tests/Unit/Service/Import/Importers/ClockifyProjectsImporterTest.php b/tests/Unit/Service/Import/Importers/ClockifyProjectsImporterTest.php index 03cf3576..6e680170 100644 --- a/tests/Unit/Service/Import/Importers/ClockifyProjectsImporterTest.php +++ b/tests/Unit/Service/Import/Importers/ClockifyProjectsImporterTest.php @@ -157,4 +157,26 @@ class ClockifyProjectsImporterTest extends ImporterTestAbstract Task::query()->where('project_id', $project->id)->pluck('name')->all(), ); } + + public function test_import_drops_billable_flags_and_rates_if_organization_can_not_use_billable_rates(): void + { + // Arrange + $this->actAsOrganizationWithoutBillableRates(); + $organization = Organization::factory()->create(); + $timezone = 'Europe/Vienna'; + $importer = new ClockifyProjectsImporter; + $importer->init($organization); + $data = Storage::disk('testfiles')->get('clockify_projects_import_test_1.csv'); + + // Act + $importer->importData($data, $timezone); + + // Assert + $projects = Project::query()->whereBelongsTo($organization, 'organization')->get(); + $this->assertCount(2, $projects); + foreach ($projects as $project) { + $this->assertFalse($project->is_billable); + $this->assertNull($project->billable_rate); + } + } } diff --git a/tests/Unit/Service/Import/Importers/ClockifyTimeEntriesImporterTest.php b/tests/Unit/Service/Import/Importers/ClockifyTimeEntriesImporterTest.php index 15be2f52..60d3bda2 100644 --- a/tests/Unit/Service/Import/Importers/ClockifyTimeEntriesImporterTest.php +++ b/tests/Unit/Service/Import/Importers/ClockifyTimeEntriesImporterTest.php @@ -256,4 +256,26 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract } $this->fail(); } + + public function test_import_drops_billable_flags_and_rates_if_organization_can_not_use_billable_rates(): void + { + // Arrange + $this->actAsOrganizationWithoutBillableRates(); + $organization = Organization::factory()->create(); + $timezone = 'Europe/Vienna'; + $importer = new ClockifyTimeEntriesImporter; + $importer->init($organization); + $data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_1.csv'); + + // Act + $importer->importData($data, $timezone); + + // Assert + $timeEntries = TimeEntry::query()->whereBelongsTo($organization, 'organization')->get(); + $this->assertCount(2, $timeEntries); + foreach ($timeEntries as $timeEntry) { + $this->assertFalse($timeEntry->billable); + $this->assertNull($timeEntry->billable_rate); + } + } } diff --git a/tests/Unit/Service/Import/Importers/GenericProjectsImporterTest.php b/tests/Unit/Service/Import/Importers/GenericProjectsImporterTest.php index 5db1ec52..4a334c4d 100644 --- a/tests/Unit/Service/Import/Importers/GenericProjectsImporterTest.php +++ b/tests/Unit/Service/Import/Importers/GenericProjectsImporterTest.php @@ -82,4 +82,26 @@ class GenericProjectsImporterTest extends ImporterTestAbstract $this->assertSame(3, $report->projectsCreated); $this->assertSame(2, $report->clientsCreated); } + + public function test_import_drops_billable_flags_and_rates_if_organization_can_not_use_billable_rates(): void + { + // Arrange + $this->actAsOrganizationWithoutBillableRates(); + $organization = Organization::factory()->create(); + $timezone = 'Europe/Vienna'; + $importer = new GenericProjectsImporter; + $importer->init($organization); + $data = Storage::disk('testfiles')->get('generic_projects_import_test_1.csv'); + + // Act + $importer->importData($data, $timezone); + + // Assert + $projects = Project::query()->whereBelongsTo($organization, 'organization')->get(); + $this->assertCount(3, $projects); + foreach ($projects as $project) { + $this->assertFalse($project->is_billable); + $this->assertNull($project->billable_rate); + } + } } diff --git a/tests/Unit/Service/Import/Importers/GenericTimeEntriesImporterTest.php b/tests/Unit/Service/Import/Importers/GenericTimeEntriesImporterTest.php index fb9e76fd..b392eb98 100644 --- a/tests/Unit/Service/Import/Importers/GenericTimeEntriesImporterTest.php +++ b/tests/Unit/Service/Import/Importers/GenericTimeEntriesImporterTest.php @@ -113,4 +113,26 @@ class GenericTimeEntriesImporterTest extends ImporterTestAbstract } $this->fail(); } + + public function test_import_drops_billable_flags_and_rates_if_organization_can_not_use_billable_rates(): void + { + // Arrange + $this->actAsOrganizationWithoutBillableRates(); + $organization = Organization::factory()->create(); + $timezone = 'Europe/Vienna'; + $importer = new GenericTimeEntriesImporter; + $importer->init($organization); + $data = Storage::disk('testfiles')->get('generic_time_entries_import_test_1.csv'); + + // Act + $importer->importData($data, $timezone); + + // Assert + $timeEntries = TimeEntry::query()->whereBelongsTo($organization, 'organization')->get(); + $this->assertCount(2, $timeEntries); + foreach ($timeEntries as $timeEntry) { + $this->assertFalse($timeEntry->billable); + $this->assertNull($timeEntry->billable_rate); + } + } } diff --git a/tests/Unit/Service/Import/Importers/HarvestProjectsImporterTest.php b/tests/Unit/Service/Import/Importers/HarvestProjectsImporterTest.php index ec757212..42e76ee3 100644 --- a/tests/Unit/Service/Import/Importers/HarvestProjectsImporterTest.php +++ b/tests/Unit/Service/Import/Importers/HarvestProjectsImporterTest.php @@ -56,4 +56,26 @@ class HarvestProjectsImporterTest extends ImporterTestAbstract $this->assertSame(null, $project2->estimated_time); $this->assertSame(false, $project2->is_billable); } + + public function test_import_drops_billable_flags_and_rates_if_organization_can_not_use_billable_rates(): void + { + // Arrange + $this->actAsOrganizationWithoutBillableRates(); + $organization = Organization::factory()->create(); + $timezone = 'Europe/Vienna'; + $importer = new HarvestProjectsImporter; + $importer->init($organization); + $data = Storage::disk('testfiles')->get('harvest_projects_import_test_1.csv'); + + // Act + $importer->importData($data, $timezone); + + // Assert + $projects = Project::query()->whereBelongsTo($organization, 'organization')->get(); + $this->assertCount(2, $projects); + foreach ($projects as $project) { + $this->assertFalse($project->is_billable); + $this->assertNull($project->billable_rate); + } + } } diff --git a/tests/Unit/Service/Import/Importers/HarvestTimeEntriesImporterTest.php b/tests/Unit/Service/Import/Importers/HarvestTimeEntriesImporterTest.php index 81f986b1..c799215a 100644 --- a/tests/Unit/Service/Import/Importers/HarvestTimeEntriesImporterTest.php +++ b/tests/Unit/Service/Import/Importers/HarvestTimeEntriesImporterTest.php @@ -128,4 +128,32 @@ class HarvestTimeEntriesImporterTest extends ImporterTestAbstract } $this->fail(); } + + public function test_import_drops_billable_flags_and_rates_if_organization_can_not_use_billable_rates(): void + { + // Arrange + $this->actAsOrganizationWithoutBillableRates(); + $organization = Organization::factory()->create(); + $timezone = 'Europe/Vienna'; + $importer = new HarvestTimeEntriesImporter; + $importer->init($organization); + $data = Storage::disk('testfiles')->get('harvest_time_entries_import_test_1.csv'); + + // Act + $importer->importData($data, $timezone); + + // Assert + $timeEntries = TimeEntry::query()->whereBelongsTo($organization, 'organization')->get(); + $this->assertCount(2, $timeEntries); + foreach ($timeEntries as $timeEntry) { + $this->assertFalse($timeEntry->billable); + $this->assertNull($timeEntry->billable_rate); + } + $projects = Project::query()->whereBelongsTo($organization, 'organization')->get(); + $this->assertCount(2, $projects); + foreach ($projects as $project) { + $this->assertFalse($project->is_billable); + $this->assertNull($project->billable_rate); + } + } } diff --git a/tests/Unit/Service/Import/Importers/SolidtimeImporterTest.php b/tests/Unit/Service/Import/Importers/SolidtimeImporterTest.php index 5d064261..278785cf 100644 --- a/tests/Unit/Service/Import/Importers/SolidtimeImporterTest.php +++ b/tests/Unit/Service/Import/Importers/SolidtimeImporterTest.php @@ -7,6 +7,7 @@ namespace Tests\Unit\Service\Import\Importers; use App\Enums\TimeEntryType; use App\Jobs\RecalculateSpentTimeForProject; use App\Jobs\RecalculateSpentTimeForTask; +use App\Models\Member; use App\Models\Organization; use App\Models\Project; use App\Models\ProjectMember; @@ -111,6 +112,13 @@ class SolidtimeImporterTest extends ImporterTestAbstract // Arrange $this->actAsOrganizationWithoutBillableRates(); $zipPath = $this->createTestZip('solidtime_import_test_1'); + // Note: The member of the test file has no billable rate, so one is added to the zip + $zip = new ZipArchive; + $zip->open($zipPath); + $members = str_replace(',ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,admin,', ',ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,10003,admin,', $zip->getFromName('members.csv'), $replacements); + $this->assertSame(1, $replacements); + $zip->addFromString('members.csv', $members); + $zip->close(); $timezone = 'Europe/Vienna'; $organization = Organization::factory()->create(); $importer = new SolidtimeImporter; @@ -127,6 +135,9 @@ class SolidtimeImporterTest extends ImporterTestAbstract // Assert $this->assertSame(3, Project::query()->whereBelongsTo($organization, 'organization')->whereNull('billable_rate')->where('is_billable', '=', false)->count()); $this->assertSame(1, ProjectMember::query()->whereBelongsToOrganization($organization)->whereNull('billable_rate')->count()); + $members = Member::query()->whereBelongsTo($organization, 'organization')->get(); + $this->assertCount(1, $members); + $this->assertNull($members->first()->billable_rate); $timeEntries = TimeEntry::query()->whereBelongsTo($organization, 'organization')->get(); $this->assertCount(2, $timeEntries); foreach ($timeEntries as $timeEntry) { diff --git a/tests/Unit/Service/Import/Importers/TogglDataImporterTest.php b/tests/Unit/Service/Import/Importers/TogglDataImporterTest.php index 453ce069..e219632b 100644 --- a/tests/Unit/Service/Import/Importers/TogglDataImporterTest.php +++ b/tests/Unit/Service/Import/Importers/TogglDataImporterTest.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace Tests\Unit\Service\Import\Importers; use App\Models\Organization; +use App\Models\Project; +use App\Models\ProjectMember; use App\Models\User; use App\Service\Import\Importers\DefaultImporter; use App\Service\Import\Importers\ImportException; @@ -216,4 +218,30 @@ class TogglDataImporterTest extends ImporterTestAbstract $this->assertSame('UTC', $user->timezone); $this->assertTrue($user->is_placeholder); } + + public function test_import_drops_billable_flags_and_rates_if_organization_can_not_use_billable_rates(): void + { + // Arrange + $this->actAsOrganizationWithoutBillableRates(); + $zipPath = $this->createTestZip('toggl_data_import_test_1'); + $timezone = 'Europe/Vienna'; + $organization = Organization::factory()->create(); + $importer = new TogglDataImporter; + $importer->init($organization); + $data = file_get_contents($zipPath); + + // Act + $importer->importData($data, $timezone); + + // Assert + $projects = Project::query()->whereBelongsTo($organization, 'organization')->get(); + $this->assertCount(3, $projects); + foreach ($projects as $project) { + $this->assertFalse($project->is_billable); + $this->assertNull($project->billable_rate); + } + $projectMembers = ProjectMember::query()->whereBelongsToOrganization($organization)->get(); + $this->assertCount(1, $projectMembers); + $this->assertNull($projectMembers->first()->billable_rate); + } } diff --git a/tests/Unit/Service/Import/Importers/TogglTimeEntriesImporterTest.php b/tests/Unit/Service/Import/Importers/TogglTimeEntriesImporterTest.php index 61e9f11a..8196ab86 100644 --- a/tests/Unit/Service/Import/Importers/TogglTimeEntriesImporterTest.php +++ b/tests/Unit/Service/Import/Importers/TogglTimeEntriesImporterTest.php @@ -143,4 +143,26 @@ class TogglTimeEntriesImporterTest extends ImporterTestAbstract } $this->fail(); } + + public function test_import_drops_billable_flags_and_rates_if_organization_can_not_use_billable_rates(): void + { + // Arrange + $this->actAsOrganizationWithoutBillableRates(); + $organization = Organization::factory()->create(); + $timezone = 'Europe/Vienna'; + $importer = new TogglTimeEntriesImporter; + $importer->init($organization); + $data = Storage::disk('testfiles')->get('toggl_time_entries_import_test_1.csv'); + + // Act + $importer->importData($data, $timezone); + + // Assert + $timeEntries = TimeEntry::query()->whereBelongsTo($organization, 'organization')->get(); + $this->assertCount(2, $timeEntries); + foreach ($timeEntries as $timeEntry) { + $this->assertFalse($timeEntry->billable); + $this->assertNull($timeEntry->billable_rate); + } + } }