diff --git a/app/Http/Controllers/Api/V1/ClientController.php b/app/Http/Controllers/Api/V1/ClientController.php new file mode 100644 index 00000000..aca24eb5 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ClientController.php @@ -0,0 +1,88 @@ +organization_id !== $organization->getKey()) { + throw new AuthorizationException('Tag does not belong to organization'); + } + } + + /** + * Get clients + * + * @throws AuthorizationException + */ + public function index(Organization $organization): ClientCollection + { + $this->checkPermission($organization, 'clients:view'); + + $clients = Client::query() + ->whereBelongsTo($organization, 'organization') + ->orderBy('created_at', 'desc') + ->get(); + + return new ClientCollection($clients); + } + + /** + * Create client + * + * @throws AuthorizationException + */ + public function store(Organization $organization, TagStoreRequest $request): ClientResource + { + $this->checkPermission($organization, 'clients:create'); + + $client = new Client(); + $client->name = $request->input('name'); + $client->organization()->associate($organization); + $client->save(); + + return new ClientResource($client); + } + + /** + * Update client + * + * @throws AuthorizationException + */ + public function update(Organization $organization, Client $client, TagUpdateRequest $request): ClientResource + { + $this->checkPermission($organization, 'clients:update', $client); + + $client->name = $request->input('name'); + $client->save(); + + return new ClientResource($client); + } + + /** + * Delete client + * + * @throws AuthorizationException + */ + public function destroy(Organization $organization, Client $client): JsonResponse + { + $this->checkPermission($organization, 'clients:delete', $client); + + $client->delete(); + + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/Api/V1/OrganizationController.php b/app/Http/Controllers/Api/V1/OrganizationController.php new file mode 100644 index 00000000..8d6a1e5d --- /dev/null +++ b/app/Http/Controllers/Api/V1/OrganizationController.php @@ -0,0 +1,40 @@ +checkPermission($organization, 'organizations:view'); + + return new OrganizationResource($organization); + } + + /** + * Update organization + * + * @throws AuthorizationException + */ + public function update(Organization $organization, OrganizationUpdateRequest $request): OrganizationResource + { + $this->checkPermission($organization, 'organizations:update'); + + $organization->name = $request->input('name'); + $organization->save(); + + return new OrganizationResource($organization); + } +} diff --git a/app/Http/Controllers/Api/V1/ProjectController.php b/app/Http/Controllers/Api/V1/ProjectController.php index 3190ae6e..5ab9ceeb 100644 --- a/app/Http/Controllers/Api/V1/ProjectController.php +++ b/app/Http/Controllers/Api/V1/ProjectController.php @@ -64,6 +64,7 @@ public function store(Organization $organization, ProjectStoreRequest $request): $project = new Project(); $project->name = $request->input('name'); $project->color = $request->input('color'); + $project->client_id = $request->input('client_id'); $project->organization()->associate($organization); $project->save(); diff --git a/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php b/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php new file mode 100644 index 00000000..6bc6f6c3 --- /dev/null +++ b/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php @@ -0,0 +1,31 @@ +> + */ + public function rules(): array + { + return [ + 'name' => [ + 'required', + 'string', + 'max:255', + ], + ]; + } +} diff --git a/app/Http/Requests/V1/Project/ProjectStoreRequest.php b/app/Http/Requests/V1/Project/ProjectStoreRequest.php index 8cb3486b..d608e0c4 100644 --- a/app/Http/Requests/V1/Project/ProjectStoreRequest.php +++ b/app/Http/Requests/V1/Project/ProjectStoreRequest.php @@ -4,9 +4,16 @@ namespace App\Http\Requests\V1\Project; +use App\Models\Client; +use App\Models\Organization; use Illuminate\Contracts\Validation\ValidationRule; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Http\FormRequest; +use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; +/** + * @property Organization $organization Organization from model binding + */ class ProjectStoreRequest extends FormRequest { /** @@ -28,6 +35,13 @@ public function rules(): array 'string', 'max:255', ], + 'client_id' => [ + 'nullable', + new ExistsEloquent(Client::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }), + ], ]; } } diff --git a/app/Http/Requests/V1/Project/ProjectUpdateRequest.php b/app/Http/Requests/V1/Project/ProjectUpdateRequest.php index c62be6e1..69b6661e 100644 --- a/app/Http/Requests/V1/Project/ProjectUpdateRequest.php +++ b/app/Http/Requests/V1/Project/ProjectUpdateRequest.php @@ -4,9 +4,16 @@ namespace App\Http\Requests\V1\Project; +use App\Models\Client; +use App\Models\Organization; use Illuminate\Contracts\Validation\ValidationRule; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Http\FormRequest; +use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; +/** + * @property Organization $organization Organization from model binding + */ class ProjectUpdateRequest extends FormRequest { /** @@ -27,6 +34,13 @@ public function rules(): array 'string', 'max:255', ], + 'client_id' => [ + 'nullable', + new ExistsEloquent(Client::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }), + ], ]; } } diff --git a/app/Http/Resources/V1/Client/ClientCollection.php b/app/Http/Resources/V1/Client/ClientCollection.php new file mode 100644 index 00000000..9c5de3cb --- /dev/null +++ b/app/Http/Resources/V1/Client/ClientCollection.php @@ -0,0 +1,17 @@ + + */ + public function toArray(Request $request): array + { + return [ + /** @var string $id ID */ + 'id' => $this->resource->id, + /** @var string $name Name */ + 'name' => $this->resource->name, + /** @var string $created_at When the tag was created */ + 'created_at' => $this->formatDateTime($this->resource->created_at), + /** @var string $updated_at When the tag was last updated */ + 'updated_at' => $this->formatDateTime($this->resource->updated_at), + ]; + } +} diff --git a/app/Http/Resources/V1/Organization/OrganizationResource.php b/app/Http/Resources/V1/Organization/OrganizationResource.php new file mode 100644 index 00000000..a43f85b1 --- /dev/null +++ b/app/Http/Resources/V1/Organization/OrganizationResource.php @@ -0,0 +1,32 @@ + + */ + public function toArray(Request $request): array + { + return [ + /** @var string $id ID */ + 'id' => $this->resource->id, + /** @var string $name Name */ + 'name' => $this->resource->name, + /** @var string $color Personal organizations automatically created after registration */ + 'is_personal' => $this->resource->personal_team, + ]; + } +} diff --git a/app/Models/Client.php b/app/Models/Client.php index e2046218..988f5f28 100644 --- a/app/Models/Client.php +++ b/app/Models/Client.php @@ -9,13 +9,15 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Carbon; /** * @property string $id * @property string $name * @property string $organization_id - * @property string $created_at - * @property string $updated_at + * @property Carbon|null $created_at + * @property Carbon|null $updated_at * @property-read Organization $organization * * @method static ClientFactory factory() @@ -41,4 +43,12 @@ public function organization(): BelongsTo { return $this->belongsTo(Organization::class, 'organization_id'); } + + /** + * @return HasMany + */ + public function projects(): HasMany + { + return $this->hasMany(Project::class, 'client_id'); + } } diff --git a/app/Models/Organization.php b/app/Models/Organization.php index 24146da3..c44a485f 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -15,6 +15,8 @@ /** * @property string $id + * @property string $name + * @property bool $personal_team * @property User $owner * * @method HasMany teamInvitations() @@ -31,6 +33,7 @@ class Organization extends JetstreamTeam * @var array */ protected $casts = [ + 'name' => 'string', 'personal_team' => 'boolean', ]; diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index fe6b1edb..e8e5bfb0 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -68,6 +68,12 @@ protected function configurePermissions(): void 'tags:create', 'tags:update', 'tags:delete', + 'clients:view', + 'clients:create', + 'clients:update', + 'clients:delete', + 'organizations:view', + 'organizations:update', ])->description('Administrator users can perform any action.'); Jetstream::role('manager', 'Manager', [ @@ -87,6 +93,7 @@ protected function configurePermissions(): void 'tags:create', 'tags:update', 'tags:delete', + 'organizations:view', ])->description('Editor users have the ability to read, create, and update.'); Jetstream::role('employee', 'Employee', [ @@ -96,6 +103,7 @@ protected function configurePermissions(): void 'time-entries:create:own', 'time-entries:update:own', 'time-entries:delete:own', + 'organizations:view', ])->description('Editor users have the ability to read, create, and update.'); } } diff --git a/database/factories/ProjectFactory.php b/database/factories/ProjectFactory.php index 709a2320..c05a79ff 100644 --- a/database/factories/ProjectFactory.php +++ b/database/factories/ProjectFactory.php @@ -31,16 +31,25 @@ public function definition(): array public function forOrganization(Organization $organization): self { - return $this->state(function (array $attributes) use ($organization) { + return $this->state(function (array $attributes) use ($organization): array { return [ 'organization_id' => $organization->getKey(), ]; }); } + public function withClient(): self + { + return $this->state(function (array $attributes): array { + return [ + 'client_id' => Client::factory(), + ]; + }); + } + public function forClient(?Client $client): self { - return $this->state(function (array $attributes) use ($client) { + return $this->state(function (array $attributes) use ($client): array { return [ 'client_id' => $client?->getKey(), ]; diff --git a/routes/api.php b/routes/api.php index 05892c15..cccee762 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use App\Http\Controllers\Api\V1\ClientController; +use App\Http\Controllers\Api\V1\OrganizationController; use App\Http\Controllers\Api\V1\ProjectController; use App\Http\Controllers\Api\V1\TagController; use App\Http\Controllers\Api\V1\TimeEntryController; @@ -20,29 +22,43 @@ */ Route::middleware('auth:api')->prefix('v1')->name('v1.')->group(static function () { + // Organization routes + Route::name('organizations.')->group(static function () { + Route::get('/organizations/{organization}', [OrganizationController::class, 'show'])->name('show'); + Route::put('/organizations/{organization}', [OrganizationController::class, 'update'])->name('update'); + }); + // Project routes Route::name('projects.')->group(static function () { - Route::get('/organization/{organization}/projects', [ProjectController::class, 'index'])->name('index'); - Route::get('/organization/{organization}/projects/{project}', [ProjectController::class, 'show'])->name('show'); - Route::post('/organization/{organization}/projects', [ProjectController::class, 'store'])->name('store'); - Route::put('/organization/{organization}/projects/{project}', [ProjectController::class, 'update'])->name('update'); - Route::delete('/organization/{organization}/projects/{project}', [ProjectController::class, 'destroy'])->name('destroy'); + Route::get('/organizations/{organization}/projects', [ProjectController::class, 'index'])->name('index'); + Route::get('/organizations/{organization}/projects/{project}', [ProjectController::class, 'show'])->name('show'); + Route::post('/organizations/{organization}/projects', [ProjectController::class, 'store'])->name('store'); + Route::put('/organizations/{organization}/projects/{project}', [ProjectController::class, 'update'])->name('update'); + Route::delete('/organizations/{organization}/projects/{project}', [ProjectController::class, 'destroy'])->name('destroy'); }); // Time entry routes Route::name('time-entries.')->group(static function () { - Route::get('/organization/{organization}/time-entries', [TimeEntryController::class, 'index'])->name('index'); - Route::post('/organization/{organization}/time-entries', [TimeEntryController::class, 'store'])->name('store'); - Route::put('/organization/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'update'])->name('update'); - Route::delete('/organization/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'destroy'])->name('destroy'); + Route::get('/organizations/{organization}/time-entries', [TimeEntryController::class, 'index'])->name('index'); + Route::post('/organizations/{organization}/time-entries', [TimeEntryController::class, 'store'])->name('store'); + Route::put('/organizations/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'update'])->name('update'); + Route::delete('/organizations/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'destroy'])->name('destroy'); }); // Tag routes Route::name('tags.')->group(static function () { - Route::get('/organization/{organization}/tags', [TagController::class, 'index'])->name('index'); - Route::post('/organization/{organization}/tags', [TagController::class, 'store'])->name('store'); - Route::put('/organization/{organization}/tags/{tag}', [TagController::class, 'update'])->name('update'); - Route::delete('/organization/{organization}/tags/{tag}', [TagController::class, 'destroy'])->name('destroy'); + Route::get('/organizations/{organization}/tags', [TagController::class, 'index'])->name('index'); + Route::post('/organizations/{organization}/tags', [TagController::class, 'store'])->name('store'); + Route::put('/organizations/{organization}/tags/{tag}', [TagController::class, 'update'])->name('update'); + Route::delete('/organizations/{organization}/tags/{tag}', [TagController::class, 'destroy'])->name('destroy'); + }); + + // Client routes + Route::name('clients.')->group(static function () { + Route::get('/organizations/{organization}/clients', [ClientController::class, 'index'])->name('index'); + Route::post('/organizations/{organization}/clients', [ClientController::class, 'store'])->name('store'); + Route::put('/organizations/{organization}/clients/{client}', [ClientController::class, 'update'])->name('update'); + Route::delete('/organizations/{organization}/clients/{client}', [ClientController::class, 'destroy'])->name('destroy'); }); }); diff --git a/tests/Unit/Endpoint/Api/V1/ClientEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ClientEndpointTest.php new file mode 100644 index 00000000..c236dfbd --- /dev/null +++ b/tests/Unit/Endpoint/Api/V1/ClientEndpointTest.php @@ -0,0 +1,218 @@ +createUserWithPermission([ + ]); + $clients = Client::factory()->forOrganization($data->organization)->createMany(4); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.clients.index', [$data->organization->getKey()])); + + // Assert + $response->assertStatus(403); + } + + public function test_index_endpoint_returns_list_of_all_clients_of_organization_ordered_by_created_at_desc_per_default(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'clients:view', + ]); + $clients = Client::factory()->forOrganization($data->organization)->createMany(4); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.clients.index', [$data->organization->getKey()])); + + // Assert + $response->assertStatus(200); + $response->assertJsonCount(4, 'data'); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->count('data', 4) + ->where('data.0.id', $clients->sortByDesc('created_at')->get(0)->getKey()) + ->where('data.1.id', $clients->sortByDesc('created_at')->get(1)->getKey()) + ->where('data.2.id', $clients->sortByDesc('created_at')->get(2)->getKey()) + ->where('data.3.id', $clients->sortByDesc('created_at')->get(3)->getKey()) + ); + } + + public function test_store_endpoint_fails_if_user_has_no_permission_to_create_clients(): void + { + // Arrange + $data = $this->createUserWithPermission([ + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.clients.store', [$data->organization->getKey()]), [ + 'name' => 'Test Client', + ]); + + // Assert + $response->assertStatus(403); + } + + public function test_store_endpoint_creates_new_client(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'clients:create', + ]); + $clientFake = Client::factory()->make(); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.clients.store', [$data->organization->getKey()]), [ + 'name' => $clientFake->name, + ]); + + // Assert + $response->assertStatus(201); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->where('data.name', $clientFake->name) + ); + } + + public function test_update_endpoint_fails_if_user_has_no_permission_to_update_clients(): void + { + // Arrange + $data = $this->createUserWithPermission([ + ]); + $client = Client::factory()->forOrganization($data->organization)->create(); + $clientFake = Client::factory()->make(); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.clients.update', [$data->organization->getKey(), $client->getKey()]), [ + 'name' => $clientFake->name, + ]); + + // Assert + $response->assertStatus(403); + } + + public function test_update_endpoint_fails_if_user_is_not_part_of_client_organization(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'clients:update', + ]); + $otherOrganization = Organization::factory()->create(); + $client = Client::factory()->forOrganization($otherOrganization)->create(); + $clientFake = Client::factory()->make(); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.clients.update', [$data->organization->getKey(), $client->getKey()]), [ + 'name' => $clientFake->name, + ]); + + // Assert + $response->assertStatus(403); + $this->assertDatabaseHas(Client::class, [ + 'id' => $client->getKey(), + 'name' => $client->name, + 'organization_id' => $otherOrganization->getKey(), + ]); + } + + public function test_update_endpoint_updates_client(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'clients:update', + ]); + $client = Client::factory()->forOrganization($data->organization)->create(); + $clientFake = Client::factory()->make(); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.clients.update', [$data->organization->getKey(), $client->getKey()]), [ + 'name' => $clientFake->name, + ]); + + // Assert + $response->assertStatus(200); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->where('data.name', $clientFake->name) + ); + $this->assertDatabaseHas(Client::class, [ + 'name' => $clientFake->name, + 'organization_id' => $data->organization->getKey(), + ]); + } + + public function test_destroy_endpoint_fails_if_user_has_no_permission_to_delete_clients(): void + { + // Arrange + $data = $this->createUserWithPermission([ + ]); + $client = Client::factory()->forOrganization($data->organization)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->deleteJson(route('api.v1.clients.destroy', [$data->organization->getKey(), $client->getKey()])); + + // Assert + $response->assertStatus(403); + } + + public function test_destroy_endpoint_fails_if_user_is_not_part_of_client_organization(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'clients:delete', + ]); + $otherOrganization = Organization::factory()->create(); + $client = Client::factory()->forOrganization($otherOrganization)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->deleteJson(route('api.v1.clients.destroy', [$data->organization->getKey(), $client->getKey()])); + + // Assert + $response->assertStatus(403); + $this->assertDatabaseHas(Client::class, [ + 'id' => $client->getKey(), + 'name' => $client->name, + 'organization_id' => $otherOrganization->getKey(), + ]); + } + + public function test_destroy_endpoint_deletes_client(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'clients:delete', + ]); + $client = Client::factory()->forOrganization($data->organization)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->deleteJson(route('api.v1.clients.destroy', [$data->organization->getKey(), $client->getKey()])); + + // Assert + $response->assertStatus(204); + $response->assertNoContent(); + $this->assertDatabaseMissing(Client::class, [ + 'id' => $client->getKey(), + ]); + } +} diff --git a/tests/Unit/Endpoint/Api/V1/OrganizationEndpointTest.php b/tests/Unit/Endpoint/Api/V1/OrganizationEndpointTest.php new file mode 100644 index 00000000..086f44e4 --- /dev/null +++ b/tests/Unit/Endpoint/Api/V1/OrganizationEndpointTest.php @@ -0,0 +1,79 @@ +createUserWithPermission([ + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.organizations.show', [$data->organization->getKey()])); + + // Assert + $response->assertStatus(403); + } + + public function test_show_endpoint_returns_organization(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'organizations:view', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.organizations.show', [$data->organization->getKey()])); + + // Assert + $response->assertStatus(200); + $response->assertJsonPath('data.id', $data->organization->getKey()); + } + + public function test_update_endpoint_fails_if_user_has_no_permission_to_update_organizations(): void + { + // Arrange + $data = $this->createUserWithPermission([ + ]); + $organizationFake = Organization::factory()->make(); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [ + 'name' => $organizationFake->name, + ]); + + // Assert + $response->assertStatus(403); + } + + public function test_update_endpoint_updates_project(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'organizations:update', + ]); + $organizationFake = Organization::factory()->make(); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [ + 'name' => $organizationFake->name, + ]); + + // Assert + $response->assertStatus(200); + $this->assertDatabaseHas(Organization::class, [ + 'name' => $organizationFake->name, + ]); + } +} diff --git a/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php index f7758ad6..e0c8657d 100644 --- a/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php @@ -4,6 +4,7 @@ namespace Tests\Unit\Endpoint\Api\V1; +use App\Models\Client; use App\Models\Organization; use App\Models\Project; use Laravel\Passport\Passport; @@ -16,6 +17,7 @@ public function test_index_endpoint_fails_if_user_has_no_permission_to_view_proj $data = $this->createUserWithPermission([ ]); $projects = Project::factory()->forOrganization($data->organization)->createMany(4); + $projectsWithClients = Project::factory()->forOrganization($data->organization)->withClient()->createMany(4); Passport::actingAs($data->user); // Act @@ -133,6 +135,33 @@ public function test_store_endpoint_creates_new_project(): void ]); } + public function test_store_endpoint_creates_new_project_with_client(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'projects:create', + ]); + $client = Client::factory()->forOrganization($data->organization)->create(); + $project = Project::factory()->forOrganization($data->organization)->make(); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [ + 'name' => $project->name, + 'color' => $project->color, + 'client_id' => $client->getKey(), + ]); + + // Assert + $response->assertStatus(201); + $this->assertDatabaseHas(Project::class, [ + 'name' => $project->name, + 'color' => $project->color, + 'organization_id' => $project->organization_id, + 'client_id' => $client->getKey(), + ]); + } + public function test_update_endpoint_fails_if_user_is_not_part_of_project_organization(): void { // Arrange diff --git a/tests/Unit/Model/ClientModelTest.php b/tests/Unit/Model/ClientModelTest.php index d1c8ca3e..210a1d00 100644 --- a/tests/Unit/Model/ClientModelTest.php +++ b/tests/Unit/Model/ClientModelTest.php @@ -6,6 +6,7 @@ use App\Models\Client; use App\Models\Organization; +use App\Models\Project; class ClientModelTest extends ModelTestAbstract { @@ -23,4 +24,22 @@ public function test_it_belongs_to_a_organization(): void $this->assertNotNull($organizationRel); $this->assertTrue($organizationRel->is($organization)); } + + public function test_it_has_many_projects(): void + { + // Arrange + $client = Client::factory()->create(); + $otherClient = Client::factory()->create(); + $projects = Project::factory()->forClient($client)->createMany(4); + $projectsOtherClient = Project::factory()->forClient($otherClient)->createMany(4); + + // Act + $client->refresh(); + $projectsRel = $client->projects; + + // Assert + $this->assertNotNull($projectsRel); + $this->assertCount(4, $projectsRel); + $this->assertTrue($projectsRel->first()->is($projects->first())); + } }