diff --git a/app/Http/Controllers/Api/V1/ClientController.php b/app/Http/Controllers/Api/V1/ClientController.php index cc23e920..e150a33e 100644 --- a/app/Http/Controllers/Api/V1/ClientController.php +++ b/app/Http/Controllers/Api/V1/ClientController.php @@ -5,8 +5,8 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1; use App\Exceptions\Api\EntityStillInUseApiException; -use App\Http\Requests\V1\Tag\TagStoreRequest; -use App\Http\Requests\V1\Tag\TagUpdateRequest; +use App\Http\Requests\V1\Client\ClientStoreRequest; +use App\Http\Requests\V1\Client\ClientUpdateRequest; use App\Http\Resources\V1\Client\ClientCollection; use App\Http\Resources\V1\Client\ClientResource; use App\Models\Client; @@ -52,7 +52,7 @@ class ClientController extends Controller * * @operationId createClient */ - public function store(Organization $organization, TagStoreRequest $request): ClientResource + public function store(Organization $organization, ClientStoreRequest $request): ClientResource { $this->checkPermission($organization, 'clients:create'); @@ -71,7 +71,7 @@ class ClientController extends Controller * * @operationId updateClient */ - public function update(Organization $organization, Client $client, TagUpdateRequest $request): ClientResource + public function update(Organization $organization, Client $client, ClientUpdateRequest $request): ClientResource { $this->checkPermission($organization, 'clients:update', $client); diff --git a/app/Http/Requests/V1/Client/ClientStoreRequest.php b/app/Http/Requests/V1/Client/ClientStoreRequest.php new file mode 100644 index 00000000..611d427f --- /dev/null +++ b/app/Http/Requests/V1/Client/ClientStoreRequest.php @@ -0,0 +1,39 @@ +> + */ + public function rules(): array + { + return [ + 'name' => [ + 'required', + 'string', + 'min:1', + 'max:255', + (new UniqueEloquent(Client::class, 'name', function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }))->withCustomTranslation('validation.client_name_already_exists'), + ], + ]; + } +} diff --git a/app/Http/Requests/V1/Client/ClientUpdateRequest.php b/app/Http/Requests/V1/Client/ClientUpdateRequest.php new file mode 100644 index 00000000..a9b9f5c6 --- /dev/null +++ b/app/Http/Requests/V1/Client/ClientUpdateRequest.php @@ -0,0 +1,40 @@ +> + */ + public function rules(): array + { + return [ + 'name' => [ + 'required', + 'string', + 'min:1', + 'max:255', + (new UniqueEloquent(Client::class, 'name', function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }))->ignore($this->client->getKey())->withCustomTranslation('validation.client_name_already_exists'), + ], + ]; + } +} diff --git a/app/Http/Requests/V1/Project/ProjectStoreRequest.php b/app/Http/Requests/V1/Project/ProjectStoreRequest.php index 0e852fc0..b74479d4 100644 --- a/app/Http/Requests/V1/Project/ProjectStoreRequest.php +++ b/app/Http/Requests/V1/Project/ProjectStoreRequest.php @@ -6,11 +6,13 @@ namespace App\Http\Requests\V1\Project; use App\Models\Client; use App\Models\Organization; +use App\Models\Project; use App\Rules\ColorRule; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Http\FormRequest; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; +use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; /** * @property Organization $organization Organization from model binding @@ -26,11 +28,14 @@ class ProjectStoreRequest extends FormRequest { return [ 'name' => [ - // TODO: unique 'required', 'string', 'min:1', 'max:255', + (new UniqueEloquent(Project::class, 'name', function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }))->withCustomTranslation('validation.project_name_already_exists'), ], 'color' => [ 'required', diff --git a/app/Http/Requests/V1/Project/ProjectUpdateRequest.php b/app/Http/Requests/V1/Project/ProjectUpdateRequest.php index 89ebdc4c..c3692e00 100644 --- a/app/Http/Requests/V1/Project/ProjectUpdateRequest.php +++ b/app/Http/Requests/V1/Project/ProjectUpdateRequest.php @@ -6,14 +6,17 @@ namespace App\Http\Requests\V1\Project; use App\Models\Client; use App\Models\Organization; +use App\Models\Project; use App\Rules\ColorRule; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Http\FormRequest; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; +use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; /** * @property Organization $organization Organization from model binding + * @property Project $project Project from model binding */ class ProjectUpdateRequest extends FormRequest { @@ -26,10 +29,13 @@ class ProjectUpdateRequest extends FormRequest { return [ 'name' => [ - // TODO: unique 'required', 'string', 'max:255', + (new UniqueEloquent(Project::class, 'name', function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }))->ignore($this->project->getKey())->withCustomTranslation('validation.project_name_already_exists'), ], 'color' => [ 'required', diff --git a/app/Http/Requests/V1/Tag/TagStoreRequest.php b/app/Http/Requests/V1/Tag/TagStoreRequest.php index 07373f3e..d4d1dda0 100644 --- a/app/Http/Requests/V1/Tag/TagStoreRequest.php +++ b/app/Http/Requests/V1/Tag/TagStoreRequest.php @@ -4,9 +4,16 @@ declare(strict_types=1); namespace App\Http\Requests\V1\Tag; +use App\Models\Organization; +use App\Models\Tag; use Illuminate\Contracts\Validation\ValidationRule; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Http\FormRequest; +use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; +/** + * @property Organization $organization Organization from model binding + */ class TagStoreRequest extends FormRequest { /** @@ -18,11 +25,14 @@ class TagStoreRequest extends FormRequest { return [ 'name' => [ - // TODO: unique 'required', 'string', 'min:1', 'max:255', + (new UniqueEloquent(Tag::class, 'name', function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }))->withCustomTranslation('validation.tag_name_already_exists'), ], ]; } diff --git a/app/Http/Requests/V1/Tag/TagUpdateRequest.php b/app/Http/Requests/V1/Tag/TagUpdateRequest.php index a54a31ee..52c939e8 100644 --- a/app/Http/Requests/V1/Tag/TagUpdateRequest.php +++ b/app/Http/Requests/V1/Tag/TagUpdateRequest.php @@ -4,9 +4,17 @@ declare(strict_types=1); namespace App\Http\Requests\V1\Tag; +use App\Models\Organization; +use App\Models\Tag; use Illuminate\Contracts\Validation\ValidationRule; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Http\FormRequest; +use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; +/** + * @property Organization $organization Organization from model binding + * @property Tag $tag Tag from model binding + */ class TagUpdateRequest extends FormRequest { /** @@ -18,11 +26,14 @@ class TagUpdateRequest extends FormRequest { return [ 'name' => [ - // TODO: unique 'required', 'string', 'min:1', 'max:255', + (new UniqueEloquent(Tag::class, 'name', function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }))->ignore($this->tag->getKey())->withCustomTranslation('validation.tag_name_already_exists'), ], ]; } diff --git a/app/Http/Requests/V1/Task/TaskStoreRequest.php b/app/Http/Requests/V1/Task/TaskStoreRequest.php index 4b96db01..e35c496c 100644 --- a/app/Http/Requests/V1/Task/TaskStoreRequest.php +++ b/app/Http/Requests/V1/Task/TaskStoreRequest.php @@ -6,10 +6,12 @@ namespace App\Http\Requests\V1\Task; use App\Models\Organization; use App\Models\Project; +use App\Models\Task; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Http\FormRequest; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; +use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; /** * @property Organization $organization Organization from model binding @@ -25,11 +27,14 @@ class TaskStoreRequest extends FormRequest { return [ 'name' => [ - // TODO: unique 'required', 'string', 'min:1', 'max:255', + (new UniqueEloquent(Task::class, 'name', function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->where('project_id', '=', $this->input('project_id')); + }))->withCustomTranslation('validation.task_name_already_exists'), ], 'project_id' => [ 'required', diff --git a/app/Http/Requests/V1/Task/TaskUpdateRequest.php b/app/Http/Requests/V1/Task/TaskUpdateRequest.php index bd6a4a75..3bc918c5 100644 --- a/app/Http/Requests/V1/Task/TaskUpdateRequest.php +++ b/app/Http/Requests/V1/Task/TaskUpdateRequest.php @@ -5,11 +5,15 @@ declare(strict_types=1); namespace App\Http\Requests\V1\Task; use App\Models\Organization; +use App\Models\Task; use Illuminate\Contracts\Validation\ValidationRule; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Http\FormRequest; +use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; /** * @property Organization $organization Organization from model binding + * @property Task $task Task from model binding */ class TaskUpdateRequest extends FormRequest { @@ -22,11 +26,14 @@ class TaskUpdateRequest extends FormRequest { return [ 'name' => [ - // TODO: unique 'required', 'string', 'min:1', 'max:255', + (new UniqueEloquent(Task::class, 'name', function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->where('project_id', '=', $this->task->project_id); + }))->ignore($this->task->getKey())->withCustomTranslation('validation.task_name_already_exists'), ], ]; } diff --git a/lang/en/validation.php b/lang/en/validation.php index 0673a3a0..d2aad0bb 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -202,6 +202,10 @@ return [ 'currency' => 'The :attribute field must be a valid currency code (ISO 4217).', 'organization' => 'The :attribute does not exist.', 'task_belongs_to_project' => 'The :attribute is not part of the given project.', + 'project_name_already_exists' => 'A project with the same name already exists in the organization.', + 'tag_name_already_exists' => 'A tag with the same name already exists in the organization.', + 'client_name_already_exists' => 'A client with the same name already exists in the organization.', + 'task_name_already_exists' => 'A task with the same name already exists in the project.', 'entities' => [ 'organization' => 'organization', diff --git a/tests/Unit/Endpoint/Api/V1/ClientEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ClientEndpointTest.php index b6e4e2da..89bdb611 100644 --- a/tests/Unit/Endpoint/Api/V1/ClientEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ClientEndpointTest.php @@ -18,8 +18,7 @@ class ClientEndpointTest extends ApiEndpointTestAbstract public function test_index_endpoint_fails_if_user_has_no_permission_to_view_clients(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); $clients = Client::factory()->forOrganization($data->organization)->createMany(4); Passport::actingAs($data->user); @@ -61,8 +60,7 @@ class ClientEndpointTest extends ApiEndpointTestAbstract public function test_store_endpoint_fails_if_user_has_no_permission_to_create_clients(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); Passport::actingAs($data->user); // Act @@ -74,6 +72,48 @@ class ClientEndpointTest extends ApiEndpointTestAbstract $response->assertForbidden(); } + public function test_store_endpoint_fails_if_client_with_same_name_already_exists(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'clients:create', + ]); + $client = Client::factory()->forOrganization($data->organization)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.clients.store', [$data->organization->getKey()]), [ + 'name' => $client->name, + ]); + + // Assert + $response->assertStatus(422); + $response->assertJsonValidationErrors([ + 'name' => 'A client with the same name already exists in the organization.', + ]); + $this->assertDatabaseCount(Client::class, 1); + } + + public function test_store_endpoint_fails_if_client_with_same_name_exists_in_different_organization(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'clients:create', + ]); + $otherOrganization = Organization::factory()->create(); + $client = Client::factory()->forOrganization($otherOrganization)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.clients.store', [$data->organization->getKey()]), [ + 'name' => $client->name, + ]); + + // Assert + $response->assertStatus(201); + $this->assertDatabaseCount(Client::class, 2); + } + public function test_store_endpoint_creates_new_client(): void { // Arrange @@ -99,8 +139,7 @@ class ClientEndpointTest extends ApiEndpointTestAbstract public function test_update_endpoint_fails_if_user_has_no_permission_to_update_clients(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); $client = Client::factory()->forOrganization($data->organization)->create(); $clientFake = Client::factory()->make(); Passport::actingAs($data->user); @@ -139,6 +178,58 @@ class ClientEndpointTest extends ApiEndpointTestAbstract ]); } + public function test_update_endpoint_fails_if_client_if_client_with_same_name_already_exists(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'clients:update', + ]); + $client = Client::factory()->forOrganization($data->organization)->create(); + $clientFake = Client::factory()->forOrganization($data->organization)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.clients.update', [$data->organization->getKey(), $client->getKey()]), [ + 'name' => $clientFake->name, + ]); + + // Assert + $response->assertStatus(422); + $response->assertJsonValidationErrors([ + 'name' => 'A client with the same name already exists in the organization.', + ]); + $this->assertDatabaseHas(Client::class, [ + 'id' => $client->getKey(), + 'name' => $client->name, + 'organization_id' => $data->organization->getKey(), + ]); + } + + public function test_update_endpoint_updates_client_name_even_if_client_with_same_name_exists_in_different_organization(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'clients:update', + ]); + $client = Client::factory()->forOrganization($data->organization)->create(); + $otherOrganization = Organization::factory()->create(); + $clientSameName = Client::factory()->forOrganization($otherOrganization)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.clients.update', [$data->organization->getKey(), $client->getKey()]), [ + 'name' => $clientSameName->name, + ]); + + // Assert + $response->assertStatus(200); + $this->assertDatabaseHas(Client::class, [ + 'id' => $client->getKey(), + 'name' => $clientSameName->name, + 'organization_id' => $data->organization->getKey(), + ]); + } + public function test_update_endpoint_updates_client(): void { // Arrange @@ -169,8 +260,7 @@ class ClientEndpointTest extends ApiEndpointTestAbstract public function test_destroy_endpoint_fails_if_user_has_no_permission_to_delete_clients(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); $client = Client::factory()->forOrganization($data->organization)->create(); Passport::actingAs($data->user); diff --git a/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php index 22353d0f..b78cac24 100644 --- a/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php @@ -19,8 +19,7 @@ class ImportEndpointTest extends ApiEndpointTestAbstract public function test_index_fails_if_user_does_not_have_permission() { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); Passport::actingAs($data->user); @@ -62,8 +61,7 @@ class ImportEndpointTest extends ApiEndpointTestAbstract public function test_import_fails_if_user_does_not_have_permission() { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); Passport::actingAs($data->user); diff --git a/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php b/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php index 00209a94..a58b8cd9 100644 --- a/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php @@ -18,8 +18,7 @@ class InvitationEndpointTest extends ApiEndpointTestAbstract public function test_index_fails_if_user_has_no_permission_to_view_invitations(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); Passport::actingAs($data->user); // Act @@ -133,8 +132,7 @@ class InvitationEndpointTest extends ApiEndpointTestAbstract public function test_resend_fails_if_user_has_no_permission_to_resend_the_invitation(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); Passport::actingAs($data->user); $invitation = OrganizationInvitation::factory()->forOrganization($data->organization)->create(); @@ -192,8 +190,7 @@ class InvitationEndpointTest extends ApiEndpointTestAbstract public function test_delete_fails_if_user_has_no_permission_to_remove_invitations(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); Passport::actingAs($data->user); $invitation = OrganizationInvitation::factory()->forOrganization($data->organization)->create(); diff --git a/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php b/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php index 79c1e56d..071ed79d 100644 --- a/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php @@ -23,8 +23,7 @@ class MemberEndpointTest extends ApiEndpointTestAbstract public function test_index_fails_if_user_has_no_permission_to_view_members(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); Passport::actingAs($data->user); // Act @@ -52,8 +51,7 @@ class MemberEndpointTest extends ApiEndpointTestAbstract public function test_update_member_fails_if_user_has_no_permission_to_update_members(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); Passport::actingAs($data->user); // Act @@ -219,8 +217,7 @@ class MemberEndpointTest extends ApiEndpointTestAbstract public function test_destroy_member_fails_if_user_has_no_permission_to_delete_members(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); Passport::actingAs($data->user); // Act @@ -327,8 +324,7 @@ class MemberEndpointTest extends ApiEndpointTestAbstract public function test_invite_placeholder_fails_if_user_does_not_have_permission(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); $user = User::factory()->create([ 'is_placeholder' => true, ]); diff --git a/tests/Unit/Endpoint/Api/V1/OrganizationEndpointTest.php b/tests/Unit/Endpoint/Api/V1/OrganizationEndpointTest.php index c3741349..456551d2 100644 --- a/tests/Unit/Endpoint/Api/V1/OrganizationEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/OrganizationEndpointTest.php @@ -17,8 +17,7 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract public function test_show_endpoint_fails_if_user_has_no_permission_to_view_organizations(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); Passport::actingAs($data->user); // Act diff --git a/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php index 1b9119b8..db871692 100644 --- a/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php @@ -22,8 +22,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract public function test_index_endpoint_fails_if_user_has_no_permission_to_view_projects(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); $projects = Project::factory()->forOrganization($data->organization)->createMany(4); $projectsWithClients = Project::factory()->forOrganization($data->organization)->withClient()->createMany(4); Passport::actingAs($data->user); @@ -92,8 +91,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract public function test_show_endpoint_fails_if_user_has_no_permission_to_view_projects(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); $project = Project::factory()->forOrganization($data->organization)->create(); Passport::actingAs($data->user); @@ -124,8 +122,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract public function test_store_endpoint_fails_if_user_has_no_permission_to_create_projects(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); $projectFake = Project::factory()->forOrganization($data->organization)->make(); Passport::actingAs($data->user); @@ -166,6 +163,64 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract ]); } + public function test_store_endpoint_fails_if_name_is_already_used_in_organization(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'projects:create', + ]); + $name = 'Project Name'; + $project = Project::factory()->forOrganization($data->organization)->create([ + 'name' => $name, + ]); + $projectFake = Project::factory()->forOrganization($data->organization)->make(); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [ + 'name' => $name, + 'color' => $projectFake->color, + 'is_billable' => $projectFake->is_billable, + ]); + + // Assert + $response->assertStatus(422); + $response->assertJsonValidationErrors([ + 'name' => 'A project with the same name already exists in the organization.', + ]); + } + + public function test_store_endpoint_creates_project_if_name_is_used_in_other_organization(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'projects:create', + ]); + $name = 'Project Name'; + $otherOrganization = Organization::factory()->create(); + $project = Project::factory()->forOrganization($otherOrganization)->create([ + 'name' => $name, + ]); + $projectFake = Project::factory()->forOrganization($data->organization)->make(); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [ + 'name' => $name, + 'color' => $projectFake->color, + 'is_billable' => $projectFake->is_billable, + ]); + + // Assert + $response->assertStatus(201); + $this->assertDatabaseHas(Project::class, [ + 'name' => $name, + 'color' => $projectFake->color, + 'organization_id' => $data->organization->getKey(), + 'is_billable' => $projectFake->is_billable, + ]); + } + public function test_store_endpoint_creates_new_project_with_client(): void { // Arrange @@ -267,6 +322,68 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract $response->assertForbidden(); } + public function test_update_endpoint_fails_if_name_is_already_used_in_organization(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'projects:update', + ]); + $name = 'Project Name'; + $projectWithTheName = Project::factory()->forOrganization($data->organization)->create([ + 'name' => $name, + ]); + $project = Project::factory()->forOrganization($data->organization)->create(); + $projectFake = Project::factory()->forOrganization($data->organization)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [ + 'name' => $name, + 'color' => $projectFake->color, + 'is_billable' => $projectFake->is_billable, + ]); + + // Assert + $response->assertStatus(422); + $response->assertJsonValidationErrors([ + 'name' => 'A project with the same name already exists in the organization.', + ]); + } + + public function test_update_endpoint_updates_project_if_name_is_used_in_other_organization(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'projects:update', + ]); + $name = 'Project Name'; + $otherOrganization = Organization::factory()->create(); + $otherProject = Project::factory()->forOrganization($otherOrganization)->create([ + 'name' => $name, + ]); + $project = Project::factory()->forOrganization($data->organization)->create([ + 'name' => $name, + ]); + $projectFake = Project::factory()->forOrganization($data->organization)->make(); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [ + 'name' => $name, + 'color' => $projectFake->color, + 'is_billable' => $projectFake->is_billable, + ]); + + // Assert + $response->assertStatus(200); + $this->assertDatabaseHas(Project::class, [ + 'name' => $name, + 'color' => $projectFake->color, + 'organization_id' => $data->organization->getKey(), + 'is_billable' => $projectFake->is_billable, + ]); + } + public function test_update_endpoint_updates_project(): void { // Arrange @@ -377,8 +494,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract public function test_destroy_endpoint_fails_if_user_has_no_permission_to_delete_projects(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); $project = Project::factory()->forOrganization($data->organization)->create(); Passport::actingAs($data->user); diff --git a/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php index 704f28a7..4cc46516 100644 --- a/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php @@ -20,8 +20,7 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract public function test_index_endpoint_fails_if_user_has_no_permission_to_view_project_members(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); $project = Project::factory()->forOrganization($data->organization)->create(); $projectMembers = ProjectMember::factory()->forProject($project)->createMany(4); Passport::actingAs($data->user); @@ -83,8 +82,7 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract public function test_store_endpoint_fails_if_user_has_no_permission_to_add_members_to_project(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); $project = Project::factory()->forOrganization($data->organization)->create(); $projectMemberFake = ProjectMember::factory()->make(); $user = User::factory()->create(); @@ -268,8 +266,7 @@ class ProjectMemberEndpointTest 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(); $projectMember = ProjectMember::factory()->forProject($project)->create(); $projectMemberFake = ProjectMember::factory()->make(); @@ -367,8 +364,7 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract public function test_destroy_endpoint_fails_if_user_has_no_permission_to_delete_project_members(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); $project = Project::factory()->forOrganization($data->organization)->create(); $projectMember = ProjectMember::factory()->forProject($project)->create(); Passport::actingAs($data->user); diff --git a/tests/Unit/Endpoint/Api/V1/TagEndpointTest.php b/tests/Unit/Endpoint/Api/V1/TagEndpointTest.php index 30d20843..d3b35f2a 100644 --- a/tests/Unit/Endpoint/Api/V1/TagEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/TagEndpointTest.php @@ -18,8 +18,7 @@ class TagEndpointTest extends ApiEndpointTestAbstract public function test_index_endpoint_fails_if_user_has_no_permission_to_view_tags(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); $tags = Tag::factory()->forOrganization($data->organization)->createMany(4); Passport::actingAs($data->user); @@ -61,8 +60,7 @@ class TagEndpointTest extends ApiEndpointTestAbstract public function test_store_endpoint_fails_if_user_has_no_permission_to_create_tags(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); Passport::actingAs($data->user); // Act @@ -74,6 +72,55 @@ class TagEndpointTest extends ApiEndpointTestAbstract $response->assertForbidden(); } + public function test_store_endpoint_fails_if_name_is_already_taken(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'tags:create', + ]); + $name = 'Test Tag'; + $tagWithName = Tag::factory()->forOrganization($data->organization)->create([ + 'name' => $name, + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.tags.store', [$data->organization->getKey()]), [ + 'name' => $tagWithName->name, + ]); + + // Assert + $response->assertStatus(422); + $response->assertJsonValidationErrors([ + 'name' => 'A tag with the same name already exists in the organization.', + ]); + } + + public function test_store_endpoint_creates_tag_if_tag_name_is_only_used_in_other_organizations(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'tags:create', + ]); + $name = 'Test Tag'; + $tagWithName = Tag::factory()->create([ + 'name' => $name, + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.tags.store', [$data->organization->getKey()]), [ + 'name' => $tagWithName->name, + ]); + + // Assert + $response->assertStatus(201); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->where('data.name', $tagWithName->name) + ); + } + public function test_store_endpoint_creates_new_tag(): void { // Arrange @@ -99,8 +146,7 @@ class TagEndpointTest extends ApiEndpointTestAbstract public function test_update_endpoint_fails_if_user_has_no_permission_to_update_tags(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); $tag = Tag::factory()->forOrganization($data->organization)->create(); $tagFake = Tag::factory()->make(); Passport::actingAs($data->user); @@ -139,6 +185,57 @@ class TagEndpointTest extends ApiEndpointTestAbstract ]); } + public function test_update_endpoint_fails_if_name_is_already_taken(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'tags:update', + ]); + $tag = Tag::factory()->forOrganization($data->organization)->create(); + $tagWithName = Tag::factory()->forOrganization($data->organization)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.tags.update', [$data->organization->getKey(), $tag->getKey()]), [ + 'name' => $tagWithName->name, + ]); + + // Assert + $response->assertStatus(422); + $response->assertJsonValidationErrors([ + 'name' => 'A tag with the same name already exists in the organization.', + ]); + } + + public function test_update_endpoint_updates_tag_if_tag_name_is_only_used_in_other_organizations(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'tags:update', + ]); + $tag = Tag::factory()->forOrganization($data->organization)->create(); + $tagWithName = Tag::factory()->create([ + 'name' => 'Test Tag', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.tags.update', [$data->organization->getKey(), $tag->getKey()]), [ + 'name' => $tagWithName->name, + ]); + + // Assert + $response->assertStatus(200); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->where('data.name', $tagWithName->name) + ); + $this->assertDatabaseHas(Tag::class, [ + 'name' => $tagWithName->name, + 'organization_id' => $data->organization->getKey(), + ]); + } + public function test_update_endpoint_updates_tag(): void { // Arrange @@ -169,8 +266,7 @@ class TagEndpointTest extends ApiEndpointTestAbstract public function test_destroy_endpoint_fails_if_user_has_no_permission_to_delete_tags(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); $tag = Tag::factory()->forOrganization($data->organization)->create(); Passport::actingAs($data->user); diff --git a/tests/Unit/Endpoint/Api/V1/TaskEndpointTest.php b/tests/Unit/Endpoint/Api/V1/TaskEndpointTest.php index dd0e9e49..f26f0273 100644 --- a/tests/Unit/Endpoint/Api/V1/TaskEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/TaskEndpointTest.php @@ -33,8 +33,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract public function test_index_endpoint_fails_if_user_has_no_permission_to_view_tasks(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); Task::factory()->forOrganization($data->organization)->createMany(4); Passport::actingAs($data->user); @@ -48,8 +47,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract public function test_index_endpoint_validation_fails_if_project_id_is_not_pat(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); Task::factory()->forOrganization($data->organization)->createMany(4); Passport::actingAs($data->user); @@ -199,8 +197,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract public function test_store_endpoint_fails_if_user_has_no_permission_to_create_tasks() { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); $project = Project::factory()->forOrganization($data->organization)->create(); Passport::actingAs($data->user); @@ -217,6 +214,59 @@ class TaskEndpointTest extends ApiEndpointTestAbstract ]); } + public function test_store_endpoint_fails_if_task_with_same_name_already_exists_in_same_project(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'tasks:create', + ]); + $project = Project::factory()->forOrganization($data->organization)->create(); + $task = Task::factory()->forOrganization($data->organization)->forProject($project)->create([ + 'name' => 'Task 1', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.tasks.store', [$data->organization->getKey()]), [ + 'name' => $task->name, + 'project_id' => $project->getKey(), + ]); + + // Assert + $response->assertStatus(422); + $response->assertJsonValidationErrors([ + 'name' => 'A task with the same name already exists in the project.', + ]); + } + + public function test_store_endpoint_creates_new_task_even_if_task_with_same_name_already_exists_in_other_project(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'tasks:create', + ]); + $project = Project::factory()->forOrganization($data->organization)->create(); + $otherProject = Project::factory()->forOrganization($data->organization)->create(); + $task = Task::factory()->forOrganization($data->organization)->forProject($otherProject)->create([ + 'name' => 'Task 1', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.tasks.store', [$data->organization->getKey()]), [ + 'name' => $task->name, + 'project_id' => $project->getKey(), + ]); + + // Assert + $response->assertStatus(201); + $this->assertDatabaseHas(Task::class, [ + 'name' => $task->name, + 'project_id' => $project->getKey(), + 'organization_id' => $data->organization->getKey(), + ]); + } + public function test_store_endpoint_creates_new_task_if_user_has_permission_to_create_tasks() { // Arrange @@ -244,8 +294,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract public function test_update_endpoint_fails_if_user_has_no_permission(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); $task = Task::factory()->forOrganization($data->organization)->create(); Passport::actingAs($data->user); @@ -266,6 +315,64 @@ class TaskEndpointTest extends ApiEndpointTestAbstract ]); } + public function test_update_endpoint_fails_if_task_with_same_name_already_exists_in_same_project(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'tasks:update', + ]); + $project = Project::factory()->forOrganization($data->organization)->create(); + $name = 'Task 1'; + $task = Task::factory()->forProject($project)->forOrganization($data->organization)->create([ + 'name' => $name, + ]); + $otherTask = Task::factory()->forProject($project)->forOrganization($data->organization)->create([ + 'name' => $name, + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [ + 'name' => $name, + ]); + + // Assert + $response->assertStatus(422); + $response->assertJsonValidationErrors([ + 'name' => 'A task with the same name already exists in the project.', + ]); + } + + public function test_update_endpoint_updates_task_if_task_with_same_name_already_exists_in_other_project(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'tasks:update', + ]); + $project = Project::factory()->forOrganization($data->organization)->create(); + $otherProject = Project::factory()->forOrganization($data->organization)->create(); + $name = 'Task 1'; + $task = Task::factory()->forProject($project)->forOrganization($data->organization)->create([ + 'name' => $name, + ]); + $otherTask = Task::factory()->forProject($otherProject)->forOrganization($data->organization)->create([ + 'name' => $name, + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [ + 'name' => $name, + ]); + + // Assert + $response->assertStatus(200); + $this->assertDatabaseHas(Task::class, [ + 'id' => $task->getKey(), + 'name' => $name, + ]); + } + public function test_update_endpoint_updates_task_if_user_has_permission(): void { // Arrange @@ -331,8 +438,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract public function test_delete_endpoint_fails_if_user_has_no_permission_to_delete_tasks(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); $task = Task::factory()->forOrganization($data->organization)->create(); Passport::actingAs($data->user); diff --git a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php index b7545149..97fc4abd 100644 --- a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php @@ -28,8 +28,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract public function test_index_endpoint_fails_if_user_has_no_permission_to_view_time_entries(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); Passport::actingAs($data->user); // Act @@ -457,8 +456,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract public function test_aggregate_endpoint_fails_if_user_has_no_permission_to_view_time_entries(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); Passport::actingAs($data->user); // Act @@ -774,8 +772,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract public function test_store_endpoint_fails_if_user_has_no_permission_to_create_time_entries(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTags($data->organization)->make(); Passport::actingAs($data->user); @@ -1026,8 +1023,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract public function test_update_endpoint_fails_if_user_has_no_permission_to_update_own_time_entries(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create(); $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->make(); Passport::actingAs($data->user); @@ -1365,8 +1361,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract public function test_destroy_endpoint_fails_if_user_has_no_permission_to_delete_own_time_entries(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create(); Passport::actingAs($data->user); diff --git a/tests/Unit/Endpoint/Api/V1/UserTimeEntryEndpointTest.php b/tests/Unit/Endpoint/Api/V1/UserTimeEntryEndpointTest.php index 0a6f5253..b31332d0 100644 --- a/tests/Unit/Endpoint/Api/V1/UserTimeEntryEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/UserTimeEntryEndpointTest.php @@ -18,8 +18,7 @@ class UserTimeEntryEndpointTest extends ApiEndpointTestAbstract public function test_my_active_endpoint_returns_unauthorized_if_user_is_not_logged_in(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); // Act $response = $this->getJson(route('api.v1.users.time-entries.my-active')); @@ -31,8 +30,7 @@ class UserTimeEntryEndpointTest extends ApiEndpointTestAbstract public function test_my_active_endpoint_returns_current_time_entry_of_logged_in_user(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); $activeTimeEntry = TimeEntry::factory()->forMember($data->member)->active()->create(); $inactiveTimeEntry = TimeEntry::factory()->forMember($data->member)->create(); Passport::actingAs($data->user); @@ -48,8 +46,7 @@ class UserTimeEntryEndpointTest extends ApiEndpointTestAbstract public function test_my_active_endpoint_logs_a_warning_if_user_has_multiple_active_time_entries_and_return_the_latest_one(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); $activeTimeEntry1 = TimeEntry::factory()->forMember($data->member)->active()->start(Carbon::now()->subDay())->create(); $activeTimeEntry2 = TimeEntry::factory()->forMember($data->member)->active()->start(Carbon::now())->create(); Passport::actingAs($data->user); @@ -69,8 +66,7 @@ class UserTimeEntryEndpointTest extends ApiEndpointTestAbstract public function test_my_active_endpoint_returns_not_found_if_user_has_no_active_time_entry(): void { // Arrange - $data = $this->createUserWithPermission([ - ]); + $data = $this->createUserWithPermission(); $inactiveTimeEntry = TimeEntry::factory()->forMember($data->member)->create(); Passport::actingAs($data->user);