From 2184b3c83541ab2e6102b2377cb77d8924314f30 Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Fri, 14 Jun 2024 16:46:39 +0200 Subject: [PATCH] Add ability to update billable rate of existing time entries --- .../Controllers/Api/V1/MemberController.php | 15 +- .../Api/V1/OrganizationController.php | 7 +- .../Controllers/Api/V1/ProjectController.php | 7 +- .../Api/V1/ProjectMemberController.php | 7 +- .../V1/Member/MemberUpdateRequest.php | 17 +- .../OrganizationUpdateRequest.php | 10 + .../V1/Project/ProjectUpdateRequest.php | 20 +- .../ProjectMemberUpdateRequest.php | 10 + app/Models/Member.php | 9 + app/Service/BillableRateService.php | 66 +++ database/factories/MemberFactory.php | 15 + database/factories/OrganizationFactory.php | 16 +- database/factories/TimeEntryFactory.php | 20 + tests/TestCase.php | 12 + tests/TestCaseWithDatabase.php | 22 + .../Endpoint/Api/V1/ImportEndpointTest.php | 20 + .../Endpoint/Api/V1/MemberEndpointTest.php | 29 + .../Api/V1/OrganizationEndpointTest.php | 38 +- .../Endpoint/Api/V1/ProjectEndpointTest.php | 43 +- .../Api/V1/ProjectMemberEndpointTest.php | 40 +- tests/Unit/Model/MemberModelTest.php | 70 +++ .../Unit/Service/BillableRateServiceTest.php | 533 +++++++++++++++++- 22 files changed, 1001 insertions(+), 25 deletions(-) create mode 100644 tests/Unit/Model/MemberModelTest.php diff --git a/app/Http/Controllers/Api/V1/MemberController.php b/app/Http/Controllers/Api/V1/MemberController.php index 450e2dce..b0eaf22a 100644 --- a/app/Http/Controllers/Api/V1/MemberController.php +++ b/app/Http/Controllers/Api/V1/MemberController.php @@ -17,6 +17,7 @@ use App\Models\Member; use App\Models\Organization; use App\Models\ProjectMember; use App\Models\TimeEntry; +use App\Service\BillableRateService; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -59,14 +60,22 @@ class MemberController extends Controller * * @operationId updateMember */ - public function update(Organization $organization, Member $member, MemberUpdateRequest $request): JsonResource + public function update(Organization $organization, Member $member, MemberUpdateRequest $request, BillableRateService $billableRateService): JsonResource { $this->checkPermission($organization, 'members:update', $member); - $member->billable_rate = $request->input('billable_rate'); - $member->role = $request->input('role'); + if ($request->has('billable_rate')) { + $member->billable_rate = $request->getBillableRate(); + } + if ($request->has('role')) { + $member->role = $request->input('role'); + } $member->save(); + if ($request->getBillableRateUpdateTimeEntries()) { + $billableRateService->updateTimeEntriesBillableRateForMember($member); + } + return new MemberResource($member); } diff --git a/app/Http/Controllers/Api/V1/OrganizationController.php b/app/Http/Controllers/Api/V1/OrganizationController.php index 686b30ca..61e4e6fd 100644 --- a/app/Http/Controllers/Api/V1/OrganizationController.php +++ b/app/Http/Controllers/Api/V1/OrganizationController.php @@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api\V1; use App\Http\Requests\V1\Organization\OrganizationUpdateRequest; use App\Http\Resources\V1\Organization\OrganizationResource; use App\Models\Organization; +use App\Service\BillableRateService; use Illuminate\Auth\Access\AuthorizationException; class OrganizationController extends Controller @@ -32,7 +33,7 @@ class OrganizationController extends Controller * * @throws AuthorizationException */ - public function update(Organization $organization, OrganizationUpdateRequest $request): OrganizationResource + public function update(Organization $organization, OrganizationUpdateRequest $request, BillableRateService $billableRateService): OrganizationResource { $this->checkPermission($organization, 'organizations:update'); @@ -40,6 +41,10 @@ class OrganizationController extends Controller $organization->billable_rate = $request->getBillableRate(); $organization->save(); + if ($request->getBillableRateUpdateTimeEntries()) { + $billableRateService->updateTimeEntriesBillableRateForOrganization($organization); + } + return new OrganizationResource($organization); } } diff --git a/app/Http/Controllers/Api/V1/ProjectController.php b/app/Http/Controllers/Api/V1/ProjectController.php index eb4a6ccc..2cdfca5a 100644 --- a/app/Http/Controllers/Api/V1/ProjectController.php +++ b/app/Http/Controllers/Api/V1/ProjectController.php @@ -14,6 +14,7 @@ use App\Models\Organization; use App\Models\Project; use App\Models\ProjectMember; use App\Models\User; +use App\Service\BillableRateService; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource; @@ -101,7 +102,7 @@ class ProjectController extends Controller * * @operationId updateProject */ - public function update(Organization $organization, Project $project, ProjectUpdateRequest $request): JsonResource + public function update(Organization $organization, Project $project, ProjectUpdateRequest $request, BillableRateService $billableRateService): JsonResource { $this->checkPermission($organization, 'projects:update', $project); $project->name = $request->input('name'); @@ -111,6 +112,10 @@ class ProjectController extends Controller $project->client_id = $request->input('client_id'); $project->save(); + if ($request->getBillableRateUpdateTimeEntries()) { + $billableRateService->updateTimeEntriesBillableRateForProject($project); + } + return new ProjectResource($project); } diff --git a/app/Http/Controllers/Api/V1/ProjectMemberController.php b/app/Http/Controllers/Api/V1/ProjectMemberController.php index a74aa50f..804ef8df 100644 --- a/app/Http/Controllers/Api/V1/ProjectMemberController.php +++ b/app/Http/Controllers/Api/V1/ProjectMemberController.php @@ -14,6 +14,7 @@ use App\Models\Member; use App\Models\Organization; use App\Models\Project; use App\Models\ProjectMember; +use App\Service\BillableRateService; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource; @@ -87,12 +88,16 @@ class ProjectMemberController extends Controller * * @operationId updateProjectMember */ - public function update(Organization $organization, ProjectMember $projectMember, ProjectMemberUpdateRequest $request): JsonResource + public function update(Organization $organization, ProjectMember $projectMember, ProjectMemberUpdateRequest $request, BillableRateService $billableRateService): JsonResource { $this->checkPermission($organization, 'project-members:update', projectMember: $projectMember); $projectMember->billable_rate = $request->getBillableRate(); $projectMember->save(); + if ($request->getBillableRateUpdateTimeEntries()) { + $billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember); + } + return new ProjectMemberResource($projectMember); } diff --git a/app/Http/Requests/V1/Member/MemberUpdateRequest.php b/app/Http/Requests/V1/Member/MemberUpdateRequest.php index 5f9ae09e..eaaa1bb4 100644 --- a/app/Http/Requests/V1/Member/MemberUpdateRequest.php +++ b/app/Http/Requests/V1/Member/MemberUpdateRequest.php @@ -23,16 +23,19 @@ class MemberUpdateRequest extends FormRequest public function rules(): array { return [ + 'role' => [ + 'string', + // TODO: placeholder role should not be allowed + Rule::enum(Role::class), + ], 'billable_rate' => [ 'nullable', 'integer', 'min:0', ], - 'role' => [ - 'required', + 'billable_rate_update_time_entries' => [ 'string', - // TODO: placeholder role should not be allowed - Rule::enum(Role::class), + 'in:true,false', ], ]; } @@ -43,4 +46,10 @@ class MemberUpdateRequest extends FormRequest return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null; } + + public function getBillableRateUpdateTimeEntries(): bool + { + return $this->has('billable_rate_update_time_entries') && + $this->input('billable_rate_update_time_entries') === 'true'; + } } diff --git a/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php b/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php index 7c25fa37..1915d527 100644 --- a/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php +++ b/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php @@ -31,6 +31,10 @@ class OrganizationUpdateRequest extends FormRequest 'integer', 'min:0', ], + 'billable_rate_update_time_entries' => [ + 'string', + 'in:true,false', + ], ]; } @@ -40,4 +44,10 @@ class OrganizationUpdateRequest extends FormRequest return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null; } + + public function getBillableRateUpdateTimeEntries(): bool + { + return $this->has('billable_rate_update_time_entries') && + $this->input('billable_rate_update_time_entries') === 'true'; + } } diff --git a/app/Http/Requests/V1/Project/ProjectUpdateRequest.php b/app/Http/Requests/V1/Project/ProjectUpdateRequest.php index 6e6ca391..89ebdc4c 100644 --- a/app/Http/Requests/V1/Project/ProjectUpdateRequest.php +++ b/app/Http/Requests/V1/Project/ProjectUpdateRequest.php @@ -41,11 +41,6 @@ class ProjectUpdateRequest extends FormRequest 'required', 'boolean', ], - 'billable_rate' => [ - 'nullable', - 'integer', - 'min:0', - ], 'client_id' => [ 'nullable', new ExistsEloquent(Client::class, null, function (Builder $builder): Builder { @@ -53,6 +48,15 @@ class ProjectUpdateRequest extends FormRequest return $builder->whereBelongsTo($this->organization, 'organization'); }), ], + 'billable_rate' => [ + 'nullable', + 'integer', + 'min:0', + ], + 'billable_rate_update_time_entries' => [ + 'string', + 'in:true,false', + ], ]; } @@ -62,4 +66,10 @@ class ProjectUpdateRequest extends FormRequest return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null; } + + public function getBillableRateUpdateTimeEntries(): bool + { + return $this->has('billable_rate_update_time_entries') && + $this->input('billable_rate_update_time_entries') === 'true'; + } } diff --git a/app/Http/Requests/V1/ProjectMember/ProjectMemberUpdateRequest.php b/app/Http/Requests/V1/ProjectMember/ProjectMemberUpdateRequest.php index 664a2d56..05542449 100644 --- a/app/Http/Requests/V1/ProjectMember/ProjectMemberUpdateRequest.php +++ b/app/Http/Requests/V1/ProjectMember/ProjectMemberUpdateRequest.php @@ -26,6 +26,10 @@ class ProjectMemberUpdateRequest extends FormRequest 'integer', 'min:0', ], + 'billable_rate_update_time_entries' => [ + 'string', + 'in:true,false', + ], ]; } @@ -35,4 +39,10 @@ class ProjectMemberUpdateRequest extends FormRequest return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null; } + + public function getBillableRateUpdateTimeEntries(): bool + { + return $this->has('billable_rate_update_time_entries') && + $this->input('billable_rate_update_time_entries') === 'true'; + } } diff --git a/app/Models/Member.php b/app/Models/Member.php index b12742e5..4af1091d 100644 --- a/app/Models/Member.php +++ b/app/Models/Member.php @@ -8,6 +8,7 @@ use App\Models\Concerns\HasUuids; use Database\Factories\MemberFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Laravel\Jetstream\Membership as JetstreamMembership; /** @@ -50,4 +51,12 @@ class Member extends JetstreamMembership { return $this->belongsTo(Organization::class, 'organization_id'); } + + /** + * @return HasMany + */ + public function projectMembers(): HasMany + { + return $this->hasMany(ProjectMember::class, 'member_id'); + } } diff --git a/app/Service/BillableRateService.php b/app/Service/BillableRateService.php index 9b25deb1..9074f24c 100644 --- a/app/Service/BillableRateService.php +++ b/app/Service/BillableRateService.php @@ -9,9 +9,75 @@ use App\Models\Organization; use App\Models\Project; use App\Models\ProjectMember; use App\Models\TimeEntry; +use Illuminate\Database\Eloquent\Builder; class BillableRateService { + public function updateTimeEntriesBillableRateForProjectMember(ProjectMember $projectMember): void + { + TimeEntry::query() + ->where('billable', '=', true) + ->where('member_id', '=', $projectMember->member_id) + ->where('project_id', '=', $projectMember->project_id) + ->update(['billable_rate' => $projectMember->billable_rate]); + } + + public function updateTimeEntriesBillableRateForProject(Project $project): void + { + TimeEntry::query() + ->where('billable', '=', true) + ->where('organization_id', '=', $project->organization_id) + ->whereBelongsTo($project, 'project') + ->whereDoesntHave('member', function (Builder $query) use ($project) { + /** @var Builder $query */ + $query->whereHas('projectMembers', function (Builder $query) use ($project) { + /** @var Builder $query */ + $query->whereBelongsTo($project, 'project') + ->whereNotNull('billable_rate'); + }); + }) + ->update(['billable_rate' => $project->billable_rate]); + } + + public function updateTimeEntriesBillableRateForMember(Member $member): void + { + TimeEntry::query() + ->where('billable', '=', true) + ->where('organization_id', '=', $member->organization_id) + ->where('member_id', '=', $member->getKey()) + ->whereDoesntHave('project', function (Builder $builder) use ($member): void { + /** @var Builder $builder */ + $builder->whereNotNull('billable_rate') + ->orWhereHas('members', function (Builder $builder) use ($member): void { + /** @var Builder $builder */ + $builder->whereNotNull('billable_rate') + ->where('member_id', '=', $member->getKey()); + }); + }) + ->update(['billable_rate' => $member->billable_rate]); + } + + public function updateTimeEntriesBillableRateForOrganization(Organization $organization): void + { + TimeEntry::query() + ->where('billable', '=', true) + ->where('organization_id', '=', $organization->getKey()) + ->whereDoesntHave('member', function (Builder $builder) { + /** @var Builder $builder */ + $builder->whereNotNull('billable_rate'); + }) + ->whereDoesntHave('project', function (Builder $builder): void { + /** @var Builder $builder */ + $builder->whereNotNull('billable_rate') + ->orWhereHas('members', function (Builder $builder): void { + /** @var Builder $builder */ + $builder->whereNotNull('billable_rate') + ->whereRaw('member_id = time_entries.member_id'); + }); + }) + ->update(['billable_rate' => $organization->billable_rate]); + } + public function getBillableRateForTimeEntryWithGivenRelations(TimeEntry $timeEntry, ?ProjectMember $projectMember, ?Project $project, ?Member $member, ?Organization $organization): ?int { if (! $timeEntry->billable) { diff --git a/database/factories/MemberFactory.php b/database/factories/MemberFactory.php index 3b2bf185..9aa7a0bf 100644 --- a/database/factories/MemberFactory.php +++ b/database/factories/MemberFactory.php @@ -23,6 +23,7 @@ class MemberFactory extends Factory public function definition(): array { return [ + 'billable_rate' => null, 'role' => Role::Employee, 'organization_id' => Organization::factory(), 'user_id' => User::factory(), @@ -68,6 +69,20 @@ class MemberFactory extends Factory }); } + public function billableRate(?int $billableRate): self + { + return $this->state(fn (array $attributes) => [ + 'billable_rate' => $billableRate, + ]); + } + + public function withBillableRate(): self + { + return $this->state(fn (array $attributes) => [ + 'billable_rate' => $this->faker->numberBetween(50, 1000) * 100, + ]); + } + public function attachToOrganization(Organization $organization, array $pivot = []): static { return $this->afterCreating(function (User $user) use ($organization, $pivot) { diff --git a/database/factories/OrganizationFactory.php b/database/factories/OrganizationFactory.php index 4e529a9a..bfa8ae46 100644 --- a/database/factories/OrganizationFactory.php +++ b/database/factories/OrganizationFactory.php @@ -23,12 +23,26 @@ class OrganizationFactory extends Factory return [ 'name' => $this->faker->unique()->company(), 'currency' => $this->faker->currencyCode(), - 'billable_rate' => $this->faker->numberBetween(50, 1000) * 100, + 'billable_rate' => null, 'user_id' => User::factory(), 'personal_team' => true, ]; } + public function billableRate(?int $billableRate): self + { + return $this->state(fn (array $attributes) => [ + 'billable_rate' => $billableRate, + ]); + } + + public function withBillableRate(): self + { + return $this->state(fn (array $attributes) => [ + 'billable_rate' => $this->faker->numberBetween(50, 1000) * 100, + ]); + } + public function withOwner(?User $owner = null): self { return $this->state(fn (array $attributes) => [ diff --git a/database/factories/TimeEntryFactory.php b/database/factories/TimeEntryFactory.php index 65c36789..2bb83514 100644 --- a/database/factories/TimeEntryFactory.php +++ b/database/factories/TimeEntryFactory.php @@ -40,9 +40,29 @@ class TimeEntryFactory extends Factory 'task_id' => null, 'project_id' => null, 'organization_id' => Organization::factory(), + 'billable_rate' => null, ]; } + public function notBillable(): self + { + return $this->state(function (array $attributes): array { + return [ + 'billable' => false, + ]; + }); + } + + public function billableRate(int $billableRate): self + { + return $this->state(function (array $attributes) use ($billableRate): array { + return [ + 'billable' => true, + 'billable_rate' => $billableRate, + ]; + }); + } + public function withTask(Organization $organization): self { return $this->state(function (array $attributes) use (&$organization): array { diff --git a/tests/TestCase.php b/tests/TestCase.php index 42bee124..553173bb 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,12 +4,14 @@ declare(strict_types=1); namespace Tests; +use App\Service\BillableRateService; use App\Service\PermissionStore; use Carbon\CarbonImmutable; use Illuminate\Database\Eloquent\Collection; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Mail; +use Mockery\MockInterface; use TiMacDonald\Log\LogFake; abstract class TestCase extends BaseTestCase @@ -47,4 +49,14 @@ abstract class TestCase extends BaseTestCase { parent::travelTo($date->utc()); } + + protected function assertBillableRateServiceIsUnused(): void + { + $this->mock(BillableRateService::class, function (MockInterface $mock) { + $mock->shouldNotReceive('updateTimeEntriesBillableRateForProjectMember'); + $mock->shouldNotReceive('updateTimeEntriesBillableRateForProject'); + $mock->shouldNotReceive('updateTimeEntriesBillableRateForMember'); + $mock->shouldNotReceive('updateTimeEntriesBillableRateForOrganization'); + }); + } } diff --git a/tests/TestCaseWithDatabase.php b/tests/TestCaseWithDatabase.php index 880a3652..9942eeca 100644 --- a/tests/TestCaseWithDatabase.php +++ b/tests/TestCaseWithDatabase.php @@ -8,6 +8,7 @@ use App\Models\Member; use App\Models\Organization; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Laravel\Jetstream\Jetstream; @@ -40,4 +41,25 @@ abstract class TestCaseWithDatabase extends TestCase 'member' => $member, ]; } + + protected function enableQueryLog(): void + { + DB::flushQueryLog(); + DB::enableQueryLog(); + } + + protected function getQueryLog(): array + { + if (! DB::logging()) { + throw new \LogicException('Query log is not enabled. Call enableQueryLog() before calling getQueryLog()'); + } + + return DB::getQueryLog(); + } + + protected function assertQueryCount(int $count, string $message = ''): void + { + $queryLog = $this->getQueryLog(); + $this->assertCount($count, $queryLog, $message); + } } diff --git a/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php index a5ab75ea..22353d0f 100644 --- a/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php @@ -78,6 +78,26 @@ class ImportEndpointTest extends ApiEndpointTestAbstract $response->assertForbidden(); } + public function test_import_fails_if_data_can_not_be_base64_decoded(): void + { + $user = $this->createUserWithPermission([ + 'import', + ]); + Passport::actingAs($user->user); + + // Act + $response = $this->postJson(route('api.v1.import.import', ['organization' => $user->organization->getKey()]), [ + 'type' => 'toggl_time_entries', + 'data' => 'some invalid data ...', + ]); + + // Assert + $response->assertStatus(400); + $response->assertExactJson([ + 'message' => 'Invalid base64 encoded data', + ]); + } + public function test_import_return_error_message_if_import_fails(): void { $user = $this->createUserWithPermission([ diff --git a/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php b/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php index 33a26848..3be1142e 100644 --- a/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php @@ -12,7 +12,9 @@ use App\Models\Project; use App\Models\ProjectMember; use App\Models\TimeEntry; use App\Models\User; +use App\Service\BillableRateService; use Laravel\Passport\Passport; +use Mockery\MockInterface; use PHPUnit\Framework\Attributes\UsesClass; #[UsesClass(MemberController::class)] @@ -91,6 +93,7 @@ class MemberEndpointTest extends ApiEndpointTestAbstract $data = $this->createUserWithPermission([ 'members:update', ]); + $this->assertBillableRateServiceIsUnused(); Passport::actingAs($data->user); // Act @@ -107,6 +110,32 @@ class MemberEndpointTest extends ApiEndpointTestAbstract $this->assertSame(Role::Employee->value, $member->role); } + public function test_update_member_can_update_billable_rate_of_member_and_update_time_entries(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'members:update', + ]); + $this->mock(BillableRateService::class, function (MockInterface $mock) use ($data) { + $mock->shouldReceive('updateTimeEntriesBillableRateForMember') + ->once() + ->withArgs(fn (Member $memberArg) => $memberArg->is($data->member) && $memberArg->billable_rate === 10001); + }); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $data->member]), [ + 'billable_rate' => 10001, + 'billable_rate_update_time_entries' => 'true', + ]); + + // Assert + $response->assertStatus(200); + $member = $data->member; + $member->refresh(); + $this->assertSame(10001, $member->billable_rate); + } + public function test_invite_placeholder_succeeds_if_data_is_valid(): void { $data = $this->createUserWithPermission([ diff --git a/tests/Unit/Endpoint/Api/V1/OrganizationEndpointTest.php b/tests/Unit/Endpoint/Api/V1/OrganizationEndpointTest.php index 2fb8cde8..c3741349 100644 --- a/tests/Unit/Endpoint/Api/V1/OrganizationEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/OrganizationEndpointTest.php @@ -6,7 +6,9 @@ namespace Tests\Unit\Endpoint\Api\V1; use App\Http\Controllers\Api\V1\OrganizationController; use App\Models\Organization; +use App\Service\BillableRateService; use Laravel\Passport\Passport; +use Mockery\MockInterface; use PHPUnit\Framework\Attributes\UsesClass; #[UsesClass(OrganizationController::class)] @@ -45,8 +47,8 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract public function test_update_endpoint_fails_if_user_has_no_permission_to_update_organizations(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); + $this->assertBillableRateServiceIsUnused(); $organizationFake = Organization::factory()->make(); Passport::actingAs($data->user); @@ -65,6 +67,7 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract $data = $this->createUserWithPermission([ 'organizations:update', ]); + $this->assertBillableRateServiceIsUnused(); $organizationFake = Organization::factory()->make(); Passport::actingAs($data->user); @@ -88,6 +91,7 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract $data = $this->createUserWithPermission([ 'organizations:update', ]); + $this->assertBillableRateServiceIsUnused(); $organizationFake = Organization::factory()->make(); Passport::actingAs($data->user); @@ -104,4 +108,34 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract 'billable_rate' => $organizationFake->billable_rate, ]); } + + public function test_update_endpoint_can_update_billable_rate_of_organization_and_update_time_entries(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'organizations:update', + ]); + $billableRate = 111; + $organizationFake = Organization::factory()->billableRate($billableRate)->make(); + $this->mock(BillableRateService::class, function (MockInterface $mock) use ($data, $billableRate) { + $mock->shouldReceive('updateTimeEntriesBillableRateForOrganization') + ->once() + ->withArgs(fn (Organization $organization) => $organization->is($data->organization) && $organization->billable_rate === $billableRate); + }); + Passport::actingAs($data->user); + + // Act + $response = $this->withoutExceptionHandling()->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [ + 'name' => $organizationFake->name, + 'billable_rate' => $organizationFake->billable_rate, + 'billable_rate_update_time_entries' => 'true', + ]); + + // Assert + $response->assertStatus(200); + $this->assertDatabaseHas(Organization::class, [ + 'name' => $organizationFake->name, + 'billable_rate' => $organizationFake->billable_rate, + ]); + } } diff --git a/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php index 5be6af3f..1b9119b8 100644 --- a/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php @@ -11,7 +11,9 @@ use App\Models\Project; use App\Models\ProjectMember; use App\Models\Task; use App\Models\TimeEntry; +use App\Service\BillableRateService; use Laravel\Passport\Passport; +use Mockery\MockInterface; use PHPUnit\Framework\Attributes\UsesClass; #[UsesClass(ProjectController::class)] @@ -200,6 +202,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract 'projects:create', ]); $projectFake = Project::factory()->forOrganization($data->organization)->make(); + $this->assertBillableRateServiceIsUnused(); Passport::actingAs($data->user); // Act @@ -230,6 +233,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract $otherOrganization = Organization::factory()->create(); $project = Project::factory()->forOrganization($otherOrganization)->create(); $projectFake = Project::factory()->make(); + $this->assertBillableRateServiceIsUnused(); Passport::actingAs($data->user); // Act @@ -246,10 +250,10 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract public function test_update_endpoint_fails_if_user_has_no_permission_to_update_projects(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); $project = Project::factory()->forOrganization($data->organization)->create(); $projectFake = Project::factory()->make(); + $this->assertBillableRateServiceIsUnused(); Passport::actingAs($data->user); // Act @@ -272,6 +276,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract $project = Project::factory()->forOrganization($data->organization)->create(); $projectFake = Project::factory()->make(); $client = Client::factory()->forOrganization($data->organization)->create(); + $this->assertBillableRateServiceIsUnused(); Passport::actingAs($data->user); // Act @@ -299,6 +304,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract ]); $project = Project::factory()->forOrganization($data->organization)->create(); $projectFake = Project::factory()->make(); + $this->assertBillableRateServiceIsUnused(); Passport::actingAs($data->user); // Act @@ -318,6 +324,39 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract ]); } + public function test_update_endpoint_can_update_projects_billable_rate_and_update_time_entries(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'projects:update', + ]); + $project = Project::factory()->forOrganization($data->organization)->create(); + $projectFake = Project::factory()->make(); + $this->mock(BillableRateService::class, function (MockInterface $mock) use ($project): void { + $mock->shouldReceive('updateTimeEntriesBillableRateForProject') + ->once() + ->withArgs(fn (Project $projectArg) => $projectArg->is($project) && $projectArg->billable_rate === 10003); + }); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [ + 'name' => $projectFake->name, + 'color' => $projectFake->color, + 'is_billable' => $projectFake->is_billable, + 'billable_rate' => 10003, + 'billable_rate_update_time_entries' => 'true', + ]); + + // Assert + $response->assertStatus(200); + $this->assertDatabaseHas(Project::class, [ + 'name' => $projectFake->name, + 'color' => $projectFake->color, + 'billable_rate' => 10003, + ]); + } + public function test_destroy_endpoint_fails_if_user_is_not_part_of_project_organization(): void { // Arrange diff --git a/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php index 24e6487a..704f28a7 100644 --- a/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php @@ -9,7 +9,9 @@ use App\Models\Member; use App\Models\Project; use App\Models\ProjectMember; use App\Models\User; +use App\Service\BillableRateService; use Laravel\Passport\Passport; +use Mockery\MockInterface; use PHPUnit\Framework\Attributes\UsesClass; #[UsesClass(ProjectMemberController::class)] @@ -289,20 +291,52 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract 'project-members:update', ]); $project = Project::factory()->forOrganization($data->organization)->create(); + $billableRate = 1001; $projectMember = ProjectMember::factory()->forProject($project)->create(); - $projectMemberFake = ProjectMember::factory()->make(); + $this->assertBillableRateServiceIsUnused(); Passport::actingAs($data->user); // Act $response = $this->putJson(route('api.v1.project-members.update', [$data->organization->getKey(), $projectMember->getKey()]), [ - 'billable_rate' => $projectMemberFake->billable_rate, + 'billable_rate' => $billableRate, ]); // Assert $response->assertStatus(200); $this->assertDatabaseHas(ProjectMember::class, [ 'id' => $projectMember->getKey(), - 'billable_rate' => $projectMemberFake->billable_rate, + 'billable_rate' => $billableRate, + 'member_id' => $projectMember->member_id, + ]); + } + + public function test_update_endpoints_can_update_billable_rate_and_update_time_entries(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'project-members:update', + ]); + $project = Project::factory()->forOrganization($data->organization)->create(); + $billableRate = 1001; + $projectMember = ProjectMember::factory()->forProject($project)->create(); + $this->mock(BillableRateService::class, function (MockInterface $mock) use ($projectMember, $billableRate): void { + $mock->shouldReceive('updateTimeEntriesBillableRateForProjectMember') + ->once() + ->withArgs(fn (ProjectMember $projectMemberArg) => $projectMemberArg->is($projectMember) && $projectMemberArg->billable_rate === $billableRate); + }); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.project-members.update', [$data->organization->getKey(), $projectMember->getKey()]), [ + 'billable_rate' => $billableRate, + 'billable_rate_update_time_entries' => 'true', + ]); + + // Assert + $response->assertStatus(200); + $this->assertDatabaseHas(ProjectMember::class, [ + 'id' => $projectMember->getKey(), + 'billable_rate' => $billableRate, 'member_id' => $projectMember->member_id, ]); } diff --git a/tests/Unit/Model/MemberModelTest.php b/tests/Unit/Model/MemberModelTest.php new file mode 100644 index 00000000..abe8fcb8 --- /dev/null +++ b/tests/Unit/Model/MemberModelTest.php @@ -0,0 +1,70 @@ +create(); + $member = Member::factory()->forUser($user)->create(); + + // Act + $member->refresh(); + $userRel = $member->user; + + // Assert + $this->assertNotNull($userRel); + $this->assertTrue($userRel->is($user)); + } + + public function test_it_belongs_to_a_organization(): void + { + // Arrange + $organization = Organization::factory()->create(); + $member = Member::factory()->forOrganization($organization)->create(); + + // Act + $member->refresh(); + $organizationRel = $member->organization; + + // Assert + $this->assertNotNull($organizationRel); + $this->assertTrue($organizationRel->is($organization)); + } + + public function test_it_has_many_project_members(): void + { + // Arrange + $member = Member::factory()->create(); + $project1 = Project::factory()->create(); + $project2 = Project::factory()->create(); + $projectMember1 = ProjectMember::factory()->forMember($member)->forProject($project1)->create(); + $projectMember2 = ProjectMember::factory()->forMember($member)->forProject($project2)->createMany(); + + // Act + $member->refresh(); + $projectMembersRel = $member->projectMembers; + + // Assert + $this->assertNotNull($projectMembersRel); + $this->assertCount(2, $projectMembersRel); + $this->assertEqualsCanonicalizing([ + $projectMember1->getKey(), + $projectMember2->first()->getKey(), + ], $projectMembersRel->pluck('id')->all()); + } +} diff --git a/tests/Unit/Service/BillableRateServiceTest.php b/tests/Unit/Service/BillableRateServiceTest.php index f42d2e97..90bdc98e 100644 --- a/tests/Unit/Service/BillableRateServiceTest.php +++ b/tests/Unit/Service/BillableRateServiceTest.php @@ -11,14 +11,15 @@ use App\Models\ProjectMember; use App\Models\TimeEntry; use App\Models\User; use App\Service\BillableRateService; +use DB; use Illuminate\Foundation\Testing\RefreshDatabase; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; -use Tests\TestCase; +use Tests\TestCaseWithDatabase; #[CoversClass(BillableRateService::class)] #[UsesClass(BillableRateService::class)] -class BillableRateServiceTest extends TestCase +class BillableRateServiceTest extends TestCaseWithDatabase { use RefreshDatabase; @@ -30,6 +31,516 @@ class BillableRateServiceTest extends TestCase $this->billableRateService = app(BillableRateService::class); } + /* + * Function: updateTimeEntriesBillableRateForProjectMember + */ + + public function test_update_time_entries_billable_rate_for_project_member_updates_time_entries_of_project_member(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $project = Project::factory()->forOrganization($user->organization)->create(); + $projectMember = ProjectMember::factory()->forMember($user->member)->forProject($project)->create([ + 'billable_rate' => 123, + ]); + $timeEntry = TimeEntry::factory()->forMember($user->member)->forProject($project)->billableRate(1)->create(); + $this->enableQueryLog(); + + // Act + $this->billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember); + + // Assert + $this->assertQueryCount(1); + $this->assertDatabaseCount(TimeEntry::class, 1); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $timeEntry->getKey(), + 'billable_rate' => 123, + ]); + } + + public function test_update_time_entries_billable_rate_for_project_member_updates_time_entries_of_project_member_even_if_all_other_billable_rates_are_set(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $organization = $user->organization; + $member = $user->member; + $organization->billable_rate = 111; + $organization->save(); + $member->billable_rate = 222; + $member->save(); + $project = Project::factory()->forOrganization($user->organization)->create([ + 'billable_rate' => 321, + ]); + $projectMember = ProjectMember::factory()->forMember($user->member)->forProject($project)->create([ + 'billable_rate' => 123, + ]); + $timeEntry = TimeEntry::factory()->forMember($user->member)->forProject($project)->billableRate(1)->create(); + $this->enableQueryLog(); + + // Act + $this->billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember); + + // Assert + $this->assertQueryCount(1); + $this->assertDatabaseCount(TimeEntry::class, 1); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $timeEntry->getKey(), + 'billable_rate' => 123, + ]); + } + + public function test_update_time_entries_billable_rate_for_project_member_ignores_time_entries_of_other_member(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $otherUser = User::factory()->create(); + $otherMember = Member::factory()->forUser($otherUser)->forOrganization($user->organization)->create(); + $project = Project::factory()->forOrganization($user->organization)->create(); + $projectMember = ProjectMember::factory()->forMember($user->member)->forProject($project)->create([ + 'billable_rate' => 123, + ]); + $otherProjectMember = ProjectMember::factory()->forMember($otherMember)->forProject($project)->create([ + 'billable_rate' => 321, + ]); + $timeEntry = TimeEntry::factory()->forMember($otherMember)->forProject($project)->billableRate(1)->create(); + $this->enableQueryLog(); + + // Act + $this->billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember); + + // Assert + $this->assertQueryCount(1); + $this->assertDatabaseCount(TimeEntry::class, 1); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $timeEntry->getKey(), + 'billable_rate' => 1, + ]); + } + + /* + * Function: updateTimeEntriesBillableRateForProject + */ + + public function test_update_time_entries_billable_rate_for_project_updates_time_entries_of_project(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $project = Project::factory()->forOrganization($user->organization)->create([ + 'billable_rate' => 321, + ]); + $timeEntry = TimeEntry::factory()->forMember($user->member)->forProject($project)->billableRate(1)->create(); + $this->enableQueryLog(); + + // Act + $this->billableRateService->updateTimeEntriesBillableRateForProject($project); + + // Assert + $this->assertQueryCount(1); + $this->assertDatabaseCount(TimeEntry::class, 1); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $timeEntry->getKey(), + 'billable_rate' => 321, + ]); + } + + public function test_update_time_entries_billable_rate_for_project_updates_time_entries_of_project_all_other_billable_rates_null(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $project = Project::factory()->forOrganization($user->organization)->create([ + 'billable_rate' => 321, + ]); + $projectMember = ProjectMember::factory()->forMember($user->member)->forProject($project)->create([ + 'billable_rate' => null, + ]); + $timeEntry = TimeEntry::factory()->forMember($user->member)->forProject($project)->billableRate(1)->create(); + $this->enableQueryLog(); + + // Act + $this->billableRateService->updateTimeEntriesBillableRateForProject($project); + + // Assert + $this->assertQueryCount(1); + $this->assertDatabaseCount(TimeEntry::class, 1); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $timeEntry->getKey(), + 'billable_rate' => 321, + ]); + } + + public function test_update_time_entries_billable_rate_for_project_ignores_time_entries_that_are_not_billable(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $project = Project::factory()->forOrganization($user->organization)->create([ + 'billable_rate' => 321, + ]); + $timeEntry = TimeEntry::factory()->forMember($user->member)->forProject($project)->notBillable()->create(); + $this->enableQueryLog(); + + // Act + $this->billableRateService->updateTimeEntriesBillableRateForProject($project); + + // Assert + $this->assertQueryCount(1); + $this->assertDatabaseCount(TimeEntry::class, 1); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $timeEntry->getKey(), + 'billable_rate' => null, + ]); + } + + public function test_update_time_entries_billable_rate_for_project_ignores_time_entries_that_have_project_member_with_billable_rate(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $project = Project::factory()->forOrganization($user->organization)->create([ + 'billable_rate' => 321, + ]); + $projectMember = ProjectMember::factory()->forMember($user->member)->forProject($project)->create([ + 'billable_rate' => 123, + ]); + $timeEntry = TimeEntry::factory()->forMember($user->member)->forProject($project)->billableRate(1)->create(); + $this->enableQueryLog(); + + // Act + $this->billableRateService->updateTimeEntriesBillableRateForProject($project); + + // Assert + $this->assertQueryCount(1); + $this->assertDatabaseCount(TimeEntry::class, 1); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $timeEntry->getKey(), + 'billable_rate' => 1, + ]); + } + + public function test_update_time_entries_billable_rate_for_project_ignores_time_entries_of_that_project_but_are_incorrectly_attached_to_other_organization(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $userInOtherOrga = $this->createUserWithPermission(); + $project = Project::factory()->forOrganization($user->organization)->create([ + 'billable_rate' => 321, + ]); + $timeEntry = TimeEntry::factory()->forMember($user->member)->forProject($project)->billableRate(1)->create(); + $brokenTimeEntryInOtherOrganizationButSameProject = TimeEntry::factory()->forMember($userInOtherOrga->member)->forProject($project)->billableRate(1)->create(); + $this->enableQueryLog(); + + // Act + $this->billableRateService->updateTimeEntriesBillableRateForProject($project); + + // Assert + $this->assertQueryCount(1); + $this->assertDatabaseCount(TimeEntry::class, 2); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $timeEntry->getKey(), + 'billable_rate' => 321, + ]); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $brokenTimeEntryInOtherOrganizationButSameProject->getKey(), + 'billable_rate' => 1, + ]); + } + + /* + * Function: updateTimeEntriesBillableRateForMember + */ + + public function test_update_time_entries_billable_rate_for_member_updates_time_entries_of_member(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $member = $user->member; + $member->billable_rate = 567; + $member->save(); + $timeEntry = TimeEntry::factory()->forMember($member)->billableRate(1)->create(); + $this->enableQueryLog(); + + // Act + $this->billableRateService->updateTimeEntriesBillableRateForMember($member); + + // Assert + $this->assertQueryCount(1); + $this->assertDatabaseCount(TimeEntry::class, 1); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $timeEntry->getKey(), + 'billable_rate' => 567, + ]); + } + + public function test_update_time_entries_billable_rate_for_member_updates_time_entries_of_member_all_other_billable_rates_null(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $member = $user->member; + $member->billable_rate = 110; + $member->save(); + $project = Project::factory()->forOrganization($user->organization)->create([ + 'billable_rate' => null, + ]); + $projectMember = ProjectMember::factory()->forMember($member)->forProject($project)->create([ + 'billable_rate' => null, + ]); + $timeEntry = TimeEntry::factory()->forMember($member)->forProject($project)->billableRate(1)->create(); + $this->enableQueryLog(); + + // Act + $this->billableRateService->updateTimeEntriesBillableRateForMember($member); + + // Assert + $queryLog = DB::getQueryLog(); + $this->assertCount(1, $queryLog); + $this->assertDatabaseCount(TimeEntry::class, 1); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $timeEntry->getKey(), + 'billable_rate' => 110, + ]); + } + + public function test_update_time_entries_billable_rate_for_member_ignores_time_entries_that_have_project_member_with_billable_rate(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $member = $user->member; + $member->billable_rate = 110; + $member->save(); + $project = Project::factory()->forOrganization($user->organization)->create([ + 'billable_rate' => null, + ]); + $projectMember = ProjectMember::factory()->forMember($member)->forProject($project)->create([ + 'billable_rate' => 123, + ]); + $timeEntry = TimeEntry::factory()->forMember($member)->forProject($project)->billableRate(1)->create(); + $this->enableQueryLog(); + + // Act + $this->billableRateService->updateTimeEntriesBillableRateForMember($member); + + // Assert + $queryLog = DB::getQueryLog(); + $this->assertCount(1, $queryLog); + $this->assertDatabaseCount(TimeEntry::class, 1); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $timeEntry->getKey(), + 'billable_rate' => 1, + ]); + } + + public function test_update_time_entries_billable_rate_for_member_ignores_time_entries_that_have_project_with_billable_rate(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $member = $user->member; + $member->billable_rate = 110; + $member->save(); + $project = Project::factory()->forOrganization($user->organization)->create([ + 'billable_rate' => 123, + ]); + $timeEntry = TimeEntry::factory()->forMember($member)->forProject($project)->billableRate(1)->create(); + $this->enableQueryLog(); + + // Act + $this->billableRateService->updateTimeEntriesBillableRateForMember($member); + + // Assert + $queryLog = DB::getQueryLog(); + $this->assertCount(1, $queryLog); + $this->assertDatabaseCount(TimeEntry::class, 1); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $timeEntry->getKey(), + 'billable_rate' => 1, + ]); + } + + /* + * Function: updateTimeEntriesBillableRateForOrganization + */ + + public function test_update_time_entries_billable_rate_for_organization_updates_time_entries_of_organization(): void + { + // Arrange + $user = $this->createUserWithPermission(); + + $organization = $user->organization; + $organization->billable_rate = 110; + $organization->save(); + $timeEntry = TimeEntry::factory()->forMember($user->member)->billableRate(1)->create(); + $this->enableQueryLog(); + + // Act + $this->billableRateService->updateTimeEntriesBillableRateForOrganization($user->organization); + + // Assert + $this->assertQueryCount(1); + $this->assertDatabaseCount(TimeEntry::class, 1); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $timeEntry->getKey(), + 'billable_rate' => 110, + ]); + } + + public function test_update_time_entries_billable_rate_for_organization_updates_time_entries_of_organization_all_other_billable_rates_null(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $member = $user->member; + $organization = $user->organization; + $organization->billable_rate = 110; + $organization->save(); + $project = Project::factory()->forOrganization($organization)->create([ + 'billable_rate' => null, + ]); + $projectMember = ProjectMember::factory()->forProject($project)->forMember($member)->create([ + 'billable_rate' => null, + ]); + + $timeEntry = TimeEntry::factory()->forMember($user->member)->forProject($project)->billableRate(1)->create(); + $this->enableQueryLog(); + + // Act + $this->billableRateService->updateTimeEntriesBillableRateForOrganization($user->organization); + + // Assert + $this->assertQueryCount(1); + $this->assertDatabaseCount(TimeEntry::class, 1); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $timeEntry->getKey(), + 'billable_rate' => 110, + ]); + } + + public function test_update_time_entries_billable_rate_for_organization_ignores_time_entries_that_are_not_billable(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $organization = $user->organization; + $organization->billable_rate = 110; + $organization->save(); + $timeEntry = TimeEntry::factory()->forMember($user->member)->notBillable()->create(); + $this->enableQueryLog(); + + // Act + $this->billableRateService->updateTimeEntriesBillableRateForOrganization($organization); + + // Assert + $this->assertQueryCount(1); + $this->assertDatabaseCount(TimeEntry::class, 1); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $timeEntry->getKey(), + 'billable_rate' => null, + ]); + } + + public function test_update_time_entries_billable_rate_for_organization_ignores_time_entries_of_organization(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $organization = $user->organization; + $organization->billable_rate = 110; + $organization->save(); + $otherUser = $this->createUserWithPermission(); + $timeEntry = TimeEntry::factory()->forMember($otherUser->member)->billableRate(1)->create(); + $this->enableQueryLog(); + + // Act + $this->billableRateService->updateTimeEntriesBillableRateForOrganization($organization); + + // Assert + $this->assertQueryCount(1); + $this->assertDatabaseCount(TimeEntry::class, 1); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $timeEntry->getKey(), + 'billable_rate' => 1, + ]); + } + + public function test_update_time_entries_billable_rate_for_organization_ignores_time_entries_with_member_with_billable_rate(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $member = $user->member; + $member->billable_rate = 120; + $member->save(); + $organization = $user->organization; + $organization->billable_rate = 110; + $organization->save(); + + $timeEntry = TimeEntry::factory()->forMember($member)->billableRate(1)->create(); + $this->enableQueryLog(); + + // Act + $this->billableRateService->updateTimeEntriesBillableRateForOrganization($organization); + + // Assert + $this->assertQueryCount(1); + $this->assertDatabaseCount(TimeEntry::class, 1); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $timeEntry->getKey(), + 'billable_rate' => 1, + ]); + } + + public function test_update_time_entries_billable_rate_for_organization_ignores_time_entries_with_project_with_billable_rate(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $member = $user->member; + $organization = $user->organization; + $organization->billable_rate = 110; + $organization->save(); + $project = Project::factory()->forOrganization($organization)->create([ + 'billable_rate' => 120, + ]); + + $timeEntry = TimeEntry::factory()->forMember($member)->forProject($project)->billableRate(1)->create(); + $this->enableQueryLog(); + + // Act + $this->billableRateService->updateTimeEntriesBillableRateForOrganization($organization); + + // Assert + $this->assertQueryCount(1); + $this->assertDatabaseCount(TimeEntry::class, 1); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $timeEntry->getKey(), + 'billable_rate' => 1, + ]); + } + + public function test_update_time_entries_billable_rate_for_organization_ignores_time_entries_with_project_member_with_billable_rate(): void + { + $user = $this->createUserWithPermission(); + $member = $user->member; + $organization = $user->organization; + $organization->billable_rate = 110; + $organization->save(); + $project = Project::factory()->forOrganization($organization)->create([ + 'billable_rate' => null, + ]); + $projectMember = ProjectMember::factory()->forProject($project)->forMember($member)->create([ + 'billable_rate' => 120, + ]); + + $timeEntry = TimeEntry::factory()->forMember($member)->forProject($project)->billableRate(1)->create(); + $this->enableQueryLog(); + + // Act + $this->billableRateService->updateTimeEntriesBillableRateForOrganization($organization); + + // Assert + $this->assertQueryCount(1); + $this->assertDatabaseCount(TimeEntry::class, 1); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $timeEntry->getKey(), + 'billable_rate' => 1, + ]); + } + + /* + * Function: getBillableRateForTimeEntryWithGivenRelations + */ + public function test_billable_rate_is_null_if_time_entry_is_not_billable(): void { // Arrange @@ -277,6 +788,7 @@ class BillableRateServiceTest extends TestCase $timeEntry = TimeEntry::factory()->forProject($project)->forMember($member)->forOrganization($organization)->create([ 'billable' => false, ]); + $this->enableQueryLog(); // Act $billableRate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations( @@ -288,6 +800,7 @@ class BillableRateServiceTest extends TestCase ); // Assert + $this->assertQueryCount(0); $this->assertSame(null, $billableRate); } @@ -310,6 +823,7 @@ class BillableRateServiceTest extends TestCase $timeEntry = TimeEntry::factory()->forProject($project)->forMember($member)->forOrganization($organization)->create([ 'billable' => true, ]); + $this->enableQueryLog(); // Act $billableRate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations( @@ -321,6 +835,7 @@ class BillableRateServiceTest extends TestCase ); // Assert + $this->assertQueryCount(0); $this->assertSame(4004, $billableRate); } @@ -343,6 +858,7 @@ class BillableRateServiceTest extends TestCase $timeEntry = TimeEntry::factory()->forProject($project)->forMember($member)->forOrganization($organization)->create([ 'billable' => true, ]); + $this->enableQueryLog(); // Act $billableRate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations( @@ -354,6 +870,7 @@ class BillableRateServiceTest extends TestCase ); // Assert + $this->assertQueryCount(0); $this->assertSame(3003, $billableRate); } @@ -373,6 +890,7 @@ class BillableRateServiceTest extends TestCase $timeEntry = TimeEntry::factory()->forProject($project)->forMember($member)->forOrganization($organization)->create([ 'billable' => true, ]); + $this->enableQueryLog(); // Act $billableRate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations( @@ -384,6 +902,7 @@ class BillableRateServiceTest extends TestCase ); // Assert + $this->assertQueryCount(0); $this->assertSame(3003, $billableRate); } @@ -406,6 +925,7 @@ class BillableRateServiceTest extends TestCase $timeEntry = TimeEntry::factory()->forProject($project)->forMember($member)->forOrganization($organization)->create([ 'billable' => true, ]); + $this->enableQueryLog(); // Act $billableRate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations( @@ -417,6 +937,7 @@ class BillableRateServiceTest extends TestCase ); // Assert + $this->assertQueryCount(0); $this->assertSame(2002, $billableRate); } @@ -433,6 +954,7 @@ class BillableRateServiceTest extends TestCase $timeEntry = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([ 'billable' => true, ]); + $this->enableQueryLog(); // Act $billableRate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations( @@ -444,6 +966,7 @@ class BillableRateServiceTest extends TestCase ); // Assert + $this->assertQueryCount(0); $this->assertSame(2002, $billableRate); } @@ -466,6 +989,7 @@ class BillableRateServiceTest extends TestCase $timeEntry = TimeEntry::factory()->forProject($project)->forMember($member)->forOrganization($organization)->create([ 'billable' => true, ]); + $this->enableQueryLog(); // Act $billableRate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations( @@ -477,6 +1001,7 @@ class BillableRateServiceTest extends TestCase ); // Assert + $this->assertQueryCount(0); $this->assertSame(1001, $billableRate); } @@ -493,6 +1018,7 @@ class BillableRateServiceTest extends TestCase $timeEntry = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([ 'billable' => true, ]); + $this->enableQueryLog(); // Act $billableRate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations( @@ -504,6 +1030,7 @@ class BillableRateServiceTest extends TestCase ); // Assert + $this->assertQueryCount(0); $this->assertSame(1001, $billableRate); } @@ -526,6 +1053,7 @@ class BillableRateServiceTest extends TestCase $timeEntry = TimeEntry::factory()->forProject($project)->forMember($member)->forOrganization($organization)->create([ 'billable' => true, ]); + $this->enableQueryLog(); // Act $billableRate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations( @@ -537,6 +1065,7 @@ class BillableRateServiceTest extends TestCase ); // Assert + $this->assertQueryCount(0); $this->assertSame(null, $billableRate); } }