mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Added user and organization deletion system; Added coverage annotations
This commit is contained in:
committed by
Constantin Graf
parent
8857befc6c
commit
86f5ea47bb
@@ -6,8 +6,8 @@ APP_URL=https://solidtime.test
|
|||||||
|
|
||||||
SUPER_ADMINS=admin@example.com
|
SUPER_ADMINS=admin@example.com
|
||||||
|
|
||||||
LOG_CHANNEL=stack
|
LOG_CHANNEL=single
|
||||||
LOG_DEPRECATIONS_CHANNEL=null
|
LOG_DEPRECATIONS_CHANNEL=deprecation
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
DB_CONNECTION=pgsql
|
DB_CONNECTION=pgsql
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,3 +33,4 @@ yarn-error.log
|
|||||||
/k8s
|
/k8s
|
||||||
/_ide_helper.php
|
/_ide_helper.php
|
||||||
/.phpstorm.meta.php
|
/.phpstorm.meta.php
|
||||||
|
/.rnd
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Actions\Jetstream;
|
namespace App\Actions\Jetstream;
|
||||||
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
|
use App\Service\DeletionService;
|
||||||
use Laravel\Jetstream\Contracts\DeletesTeams;
|
use Laravel\Jetstream\Contracts\DeletesTeams;
|
||||||
|
|
||||||
class DeleteOrganization implements DeletesTeams
|
class DeleteOrganization implements DeletesTeams
|
||||||
@@ -12,8 +13,8 @@ class DeleteOrganization implements DeletesTeams
|
|||||||
/**
|
/**
|
||||||
* Delete the given team.
|
* Delete the given team.
|
||||||
*/
|
*/
|
||||||
public function delete(Organization $team): void
|
public function delete(Organization $organization): void
|
||||||
{
|
{
|
||||||
$team->purge();
|
app(DeletionService::class)->deleteOrganization($organization);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,51 +4,25 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Actions\Jetstream;
|
namespace App\Actions\Jetstream;
|
||||||
|
|
||||||
use App\Models\Organization;
|
use App\Exceptions\Api\ApiException;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\DB;
|
use App\Service\DeletionService;
|
||||||
use Laravel\Jetstream\Contracts\DeletesTeams;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Laravel\Jetstream\Contracts\DeletesUsers;
|
use Laravel\Jetstream\Contracts\DeletesUsers;
|
||||||
|
|
||||||
class DeleteUser implements DeletesUsers
|
class DeleteUser implements DeletesUsers
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* The team deleter implementation.
|
|
||||||
*
|
|
||||||
* @var \Laravel\Jetstream\Contracts\DeletesTeams
|
|
||||||
*/
|
|
||||||
protected $deletesTeams;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new action instance.
|
|
||||||
*/
|
|
||||||
public function __construct(DeletesTeams $deletesTeams)
|
|
||||||
{
|
|
||||||
$this->deletesTeams = $deletesTeams;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete the given user.
|
* Delete the given user.
|
||||||
*/
|
*/
|
||||||
public function delete(User $user): void
|
public function delete(User $user): void
|
||||||
{
|
{
|
||||||
DB::transaction(function () use ($user) {
|
try {
|
||||||
$this->deleteTeams($user);
|
app(DeletionService::class)->deleteUser($user);
|
||||||
$user->deleteProfilePhoto();
|
} catch (ApiException $exception) {
|
||||||
$user->tokens->each->delete();
|
throw ValidationException::withMessages([
|
||||||
$user->delete();
|
'password' => $exception->getTranslatedMessage(),
|
||||||
});
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete the teams and team associations attached to the user.
|
|
||||||
*/
|
|
||||||
protected function deleteTeams(User $user): void
|
|
||||||
{
|
|
||||||
$user->teams()->detach();
|
|
||||||
|
|
||||||
$user->ownedTeams->each(function (Organization $team) {
|
|
||||||
$this->deletesTeams->delete($team);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
app/Actions/Jetstream/ValidateOrganizationDeletion.php
Normal file
28
app/Actions/Jetstream/ValidateOrganizationDeletion.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Actions\Jetstream;
|
||||||
|
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Service\PermissionStore;
|
||||||
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
|
||||||
|
class ValidateOrganizationDeletion
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Validate that the team can be deleted by the given user.
|
||||||
|
*
|
||||||
|
* @param User $user Authenticated user
|
||||||
|
* @param Organization $organization Organization to be deleted
|
||||||
|
*
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*/
|
||||||
|
public function validate(User $user, Organization $organization): void
|
||||||
|
{
|
||||||
|
if (! app(PermissionStore::class)->userHas($organization, $user, 'organizations:delete')) {
|
||||||
|
throw new AuthorizationException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/Console/Commands/Admin/DeleteOrganizationCommand.php
Normal file
59
app/Console/Commands/Admin/DeleteOrganizationCommand.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands\Admin;
|
||||||
|
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Service\DeletionService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class DeleteOrganizationCommand extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'admin:delete-organization
|
||||||
|
{ organization : The ID of the organization to delete }';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Delete a organization.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(DeletionService $deletionService): int
|
||||||
|
{
|
||||||
|
$organizationId = $this->argument('organization');
|
||||||
|
|
||||||
|
if (! Str::isUuid($organizationId)) {
|
||||||
|
$this->error('Organization ID must be a valid UUID.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Organization|null $organization */
|
||||||
|
$organization = Organization::find($organizationId);
|
||||||
|
if ($organization === null) {
|
||||||
|
$this->error('Organization with ID '.$organizationId.' not found.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Deleting organization with ID '.$organization->getKey());
|
||||||
|
|
||||||
|
$deletionService->deleteOrganization($organization);
|
||||||
|
|
||||||
|
$this->info('Organization with ID '.$organization->getKey().' has been deleted.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ use Illuminate\Encryption\Encrypter;
|
|||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use phpseclib3\Crypt\RSA;
|
use phpseclib3\Crypt\RSA;
|
||||||
|
|
||||||
class SelfHostGenerateKeys extends Command
|
class SelfHostGenerateKeysCommand extends Command
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The name and signature of the console command.
|
* The name and signature of the console command.
|
||||||
20
app/Events/BeforeOrganizationDeletion.php
Normal file
20
app/Events/BeforeOrganizationDeletion.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Models\Organization;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
|
||||||
|
class BeforeOrganizationDeletion
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
|
||||||
|
public Organization $organization;
|
||||||
|
|
||||||
|
public function __construct(Organization $organization)
|
||||||
|
{
|
||||||
|
$this->organization = $organization;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,11 @@ abstract class ApiException extends Exception
|
|||||||
{
|
{
|
||||||
public const string KEY = 'api_exception';
|
public const string KEY = 'api_exception';
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct(static::KEY);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the exception into an HTTP response.
|
* Render the exception into an HTTP response.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions\Api;
|
||||||
|
|
||||||
|
class CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers extends ApiException
|
||||||
|
{
|
||||||
|
public const string KEY = 'can_not_delete_user_who_is_owner_of_organization_with_multiple_members';
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ class EntityStillInUseApiException extends ApiException
|
|||||||
|
|
||||||
public function __construct(string $modelToDelete, string $modelInUse)
|
public function __construct(string $modelToDelete, string $modelInUse)
|
||||||
{
|
{
|
||||||
parent::__construct('', 0, null);
|
parent::__construct();
|
||||||
$this->modelToDelete = $modelToDelete;
|
$this->modelToDelete = $modelToDelete;
|
||||||
$this->modelInUse = $modelInUse;
|
$this->modelInUse = $modelInUse;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,7 +169,6 @@ class OrganizationResource extends Resource
|
|||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
Tables\Actions\BulkActionGroup::make([
|
Tables\Actions\BulkActionGroup::make([
|
||||||
Tables\Actions\DeleteBulkAction::make(),
|
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\OrganizationResource\Actions;
|
||||||
|
|
||||||
|
use App\Exceptions\Api\ApiException;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Service\DeletionService;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class DeleteOrganization extends DeleteAction
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
// TODO: check why setting the icon is necessary
|
||||||
|
$this->icon('heroicon-m-trash');
|
||||||
|
$this->action(function (): void {
|
||||||
|
$result = $this->process(function (Organization $record): bool {
|
||||||
|
try {
|
||||||
|
$deletionService = app(DeletionService::class);
|
||||||
|
$deletionService->deleteOrganization($record);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (ApiException $exception) {
|
||||||
|
$this->failureNotificationTitle($exception->getTranslatedMessage());
|
||||||
|
report($exception);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$this->failureNotificationTitle(__('exceptions.unknown_error_in_admin_panel'));
|
||||||
|
report($exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (! $result) {
|
||||||
|
$this->failure();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->success();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Filament\Resources\OrganizationResource\Pages;
|
namespace App\Filament\Resources\OrganizationResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\OrganizationResource;
|
use App\Filament\Resources\OrganizationResource;
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
class EditOrganization extends EditRecord
|
class EditOrganization extends EditRecord
|
||||||
@@ -15,7 +14,7 @@ class EditOrganization extends EditRecord
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\DeleteAction::make(),
|
OrganizationResource\Actions\DeleteOrganization::make(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Filament\Resources\OrganizationResource\Pages;
|
namespace App\Filament\Resources\OrganizationResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\OrganizationResource;
|
use App\Filament\Resources\OrganizationResource;
|
||||||
use Filament\Actions\DeleteAction;
|
|
||||||
use Filament\Actions\EditAction;
|
use Filament\Actions\EditAction;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
@@ -18,8 +17,6 @@ class ViewOrganization extends ViewRecord
|
|||||||
return [
|
return [
|
||||||
EditAction::make('edit')
|
EditAction::make('edit')
|
||||||
->icon('heroicon-s-pencil'),
|
->icon('heroicon-s-pencil'),
|
||||||
DeleteAction::make('delete')
|
|
||||||
->icon('heroicon-s-trash'),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
46
app/Filament/Resources/UserResource/Actions/DeleteUser.php
Normal file
46
app/Filament/Resources/UserResource/Actions/DeleteUser.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\UserResource\Actions;
|
||||||
|
|
||||||
|
use App\Exceptions\Api\ApiException;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Service\DeletionService;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class DeleteUser extends DeleteAction
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->icon('heroicon-m-trash');
|
||||||
|
$this->action(function (): void {
|
||||||
|
$result = $this->process(function (User $record): bool {
|
||||||
|
try {
|
||||||
|
$deletionService = app(DeletionService::class);
|
||||||
|
$deletionService->deleteUser($record);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (ApiException $exception) {
|
||||||
|
$this->failureNotificationTitle($exception->getTranslatedMessage());
|
||||||
|
report($exception);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$this->failureNotificationTitle(__('exceptions.unknown_error_in_admin_panel'));
|
||||||
|
report($exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (! $result) {
|
||||||
|
$this->failure();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->success();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Filament\Resources\UserResource\Pages;
|
namespace App\Filament\Resources\UserResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\UserResource;
|
use App\Filament\Resources\UserResource;
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
use STS\FilamentImpersonate\Pages\Actions\Impersonate;
|
use STS\FilamentImpersonate\Pages\Actions\Impersonate;
|
||||||
|
|
||||||
@@ -17,7 +16,7 @@ class EditUser extends EditRecord
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Impersonate::make()->record($this->getRecord()),
|
Impersonate::make()->record($this->getRecord()),
|
||||||
Actions\DeleteAction::make(),
|
UserResource\Actions\DeleteUser::make(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Filament\Resources\UserResource\Pages;
|
namespace App\Filament\Resources\UserResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\UserResource;
|
use App\Filament\Resources\UserResource;
|
||||||
use Filament\Actions\DeleteAction;
|
|
||||||
use Filament\Actions\EditAction;
|
use Filament\Actions\EditAction;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
@@ -18,8 +17,6 @@ class ViewUser extends ViewRecord
|
|||||||
return [
|
return [
|
||||||
EditAction::make('edit')
|
EditAction::make('edit')
|
||||||
->icon('heroicon-s-pencil'),
|
->icon('heroicon-s-pencil'),
|
||||||
DeleteAction::make('delete')
|
|
||||||
->icon('heroicon-s-trash'),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Models\Member;
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
|
||||||
use App\Service\PermissionStore;
|
use App\Service\PermissionStore;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
class Controller extends \App\Http\Controllers\Controller
|
class Controller extends \App\Http\Controllers\Controller
|
||||||
{
|
{
|
||||||
@@ -48,34 +44,4 @@ class Controller extends \App\Http\Controllers\Controller
|
|||||||
{
|
{
|
||||||
return $this->permissionStore->has($organization, $permission);
|
return $this->permissionStore->has($organization, $permission);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws AuthorizationException
|
|
||||||
*/
|
|
||||||
protected function user(): User
|
|
||||||
{
|
|
||||||
/** @var User|null $user */
|
|
||||||
$user = Auth::user();
|
|
||||||
if ($user === null) {
|
|
||||||
Log::error('This function should only be called in authenticated context');
|
|
||||||
throw new AuthorizationException();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws AuthorizationException
|
|
||||||
*/
|
|
||||||
protected function member(Organization $organization): Member
|
|
||||||
{
|
|
||||||
$user = $this->user();
|
|
||||||
$member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first();
|
|
||||||
if ($member === null) {
|
|
||||||
Log::error('This function should only be called in authenticated context after checking the user is a member of the organization');
|
|
||||||
throw new AuthorizationException();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $member;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,63 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||||
use Illuminate\Routing\Controller as BaseController;
|
use Illuminate\Routing\Controller as BaseController;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class Controller extends BaseController
|
class Controller extends BaseController
|
||||||
{
|
{
|
||||||
use AuthorizesRequests, ValidatesRequests;
|
use AuthorizesRequests;
|
||||||
|
use ValidatesRequests;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*/
|
||||||
|
protected function user(): User
|
||||||
|
{
|
||||||
|
/** @var User|null $user */
|
||||||
|
$user = Auth::user();
|
||||||
|
if ($user === null) {
|
||||||
|
Log::error('This function should only be called in authenticated context');
|
||||||
|
throw new AuthorizationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*/
|
||||||
|
protected function member(Organization $organization): Member
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
/** @var Member|null $member */
|
||||||
|
$member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first();
|
||||||
|
if ($member === null) {
|
||||||
|
Log::error('This function should only be called in authenticated context after checking the user is a member of the organization');
|
||||||
|
throw new AuthorizationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $member;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*/
|
||||||
|
protected function currentOrganization(): Organization
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
$organization = $user->currentTeam;
|
||||||
|
if ($organization === null) {
|
||||||
|
$organization = $user->organizations()->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $organization;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,21 +4,21 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Web;
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
use App\Models\Organization;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Service\DashboardService;
|
use App\Service\DashboardService;
|
||||||
use App\Service\PermissionStore;
|
use App\Service\PermissionStore;
|
||||||
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
class DashboardController extends Controller
|
class DashboardController extends Controller
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*/
|
||||||
public function dashboard(DashboardService $dashboardService, PermissionStore $permissionStore): Response
|
public function dashboard(DashboardService $dashboardService, PermissionStore $permissionStore): Response
|
||||||
{
|
{
|
||||||
/** @var User $user */
|
$user = $this->user();
|
||||||
$user = auth()->user();
|
$organization = $this->currentOrganization();
|
||||||
/** @var Organization $organization */
|
|
||||||
$organization = $user->currentTeam;
|
|
||||||
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60);
|
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60);
|
||||||
$weeklyHistory = $dashboardService->getWeeklyHistory($user, $organization);
|
$weeklyHistory = $dashboardService->getWeeklyHistory($user, $organization);
|
||||||
$totalWeeklyTime = $dashboardService->totalWeeklyTime($user, $organization);
|
$totalWeeklyTime = $dashboardService->totalWeeklyTime($user, $organization);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Builder;
|
|||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
@@ -36,11 +37,12 @@ use Laravel\Passport\HasApiTokens;
|
|||||||
* @property bool $is_placeholder
|
* @property bool $is_placeholder
|
||||||
* @property Weekday $week_start
|
* @property Weekday $week_start
|
||||||
* @property string|null $profile_photo_path
|
* @property string|null $profile_photo_path
|
||||||
* @property-read Organization $currentTeam
|
* @property-read Organization|null $currentOrganization
|
||||||
|
* @property-read Organization|null $currentTeam
|
||||||
* @property-read string $profile_photo_url
|
* @property-read string $profile_photo_url
|
||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
* @property string $current_team_id
|
* @property string|null $current_team_id
|
||||||
* @property Collection<int, Organization> $organizations
|
* @property Collection<int, Organization> $organizations
|
||||||
* @property Collection<int, TimeEntry> $timeEntries
|
* @property Collection<int, TimeEntry> $timeEntries
|
||||||
* @property Member $membership
|
* @property Member $membership
|
||||||
@@ -154,6 +156,14 @@ class User extends Authenticatable implements FilamentUser, MustVerifyEmail
|
|||||||
return $this->hasMany(TimeEntry::class);
|
return $this->hasMany(TimeEntry::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Organization, User>
|
||||||
|
*/
|
||||||
|
public function currentOrganization(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organization::class, 'current_team_id');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return HasMany<ProjectMember>
|
* @return HasMany<ProjectMember>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use App\Actions\Jetstream\InviteOrganizationMember;
|
|||||||
use App\Actions\Jetstream\RemoveOrganizationMember;
|
use App\Actions\Jetstream\RemoveOrganizationMember;
|
||||||
use App\Actions\Jetstream\UpdateMemberRole;
|
use App\Actions\Jetstream\UpdateMemberRole;
|
||||||
use App\Actions\Jetstream\UpdateOrganization;
|
use App\Actions\Jetstream\UpdateOrganization;
|
||||||
|
use App\Actions\Jetstream\ValidateOrganizationDeletion;
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
@@ -26,6 +27,7 @@ use Illuminate\Support\ServiceProvider;
|
|||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Laravel\Fortify\Fortify;
|
use Laravel\Fortify\Fortify;
|
||||||
use Laravel\Jetstream\Actions\UpdateTeamMemberRole;
|
use Laravel\Jetstream\Actions\UpdateTeamMemberRole;
|
||||||
|
use Laravel\Jetstream\Actions\ValidateTeamDeletion;
|
||||||
use Laravel\Jetstream\Jetstream;
|
use Laravel\Jetstream\Jetstream;
|
||||||
|
|
||||||
class JetstreamServiceProvider extends ServiceProvider
|
class JetstreamServiceProvider extends ServiceProvider
|
||||||
@@ -56,6 +58,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
Jetstream::useMembershipModel(Member::class);
|
Jetstream::useMembershipModel(Member::class);
|
||||||
Jetstream::useTeamInvitationModel(OrganizationInvitation::class);
|
Jetstream::useTeamInvitationModel(OrganizationInvitation::class);
|
||||||
app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class);
|
app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class);
|
||||||
|
app()->singleton(ValidateTeamDeletion::class, ValidateOrganizationDeletion::class);
|
||||||
Fortify::registerView(function () {
|
Fortify::registerView(function () {
|
||||||
return Inertia::render('Auth/Register', [
|
return Inertia::render('Auth/Register', [
|
||||||
'terms_url' => config('auth.terms_url'),
|
'terms_url' => config('auth.terms_url'),
|
||||||
@@ -105,6 +108,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'clients:delete',
|
'clients:delete',
|
||||||
'organizations:view',
|
'organizations:view',
|
||||||
'organizations:update',
|
'organizations:update',
|
||||||
|
'organizations:delete',
|
||||||
'import',
|
'import',
|
||||||
'invitations:view',
|
'invitations:view',
|
||||||
'invitations:create',
|
'invitations:create',
|
||||||
|
|||||||
162
app/Service/DeletionService.php
Normal file
162
app/Service/DeletionService.php
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
|
use App\Events\BeforeOrganizationDeletion;
|
||||||
|
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\OrganizationInvitation;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectMember;
|
||||||
|
use App\Models\Tag;
|
||||||
|
use App\Models\Task;
|
||||||
|
use App\Models\TimeEntry;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class DeletionService
|
||||||
|
{
|
||||||
|
private UserService $userService;
|
||||||
|
|
||||||
|
public function __construct(UserService $userService)
|
||||||
|
{
|
||||||
|
$this->userService = $userService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteOrganization(Organization $organization, bool $inTransaction = true): void
|
||||||
|
{
|
||||||
|
if ($inTransaction) {
|
||||||
|
DB::transaction(function () use ($organization) {
|
||||||
|
$this->deleteOrganization($organization, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::debug('Start deleting organization', [
|
||||||
|
'organization_id' => $organization->getKey(),
|
||||||
|
'name' => $organization->name,
|
||||||
|
'owner_id' => $organization->user_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
BeforeOrganizationDeletion::dispatch($organization);
|
||||||
|
|
||||||
|
// Delete all organization invitations
|
||||||
|
OrganizationInvitation::query()->whereBelongsTo($organization, 'organization')->delete();
|
||||||
|
|
||||||
|
// Delete all time entries
|
||||||
|
TimeEntry::query()->whereBelongsTo($organization, 'organization')->delete();
|
||||||
|
|
||||||
|
// Delete all tags
|
||||||
|
Tag::query()->whereBelongsTo($organization, 'organization')->delete();
|
||||||
|
|
||||||
|
// Delete all tasks
|
||||||
|
Task::query()->whereBelongsTo($organization, 'organization')->delete();
|
||||||
|
|
||||||
|
// Delete all project members
|
||||||
|
ProjectMember::query()->whereBelongsToOrganization($organization)->delete();
|
||||||
|
|
||||||
|
// Delete all projects
|
||||||
|
Project::query()->whereBelongsTo($organization, 'organization')->delete();
|
||||||
|
|
||||||
|
// Delete all clients
|
||||||
|
Client::query()->whereBelongsTo($organization, 'organization')->delete();
|
||||||
|
|
||||||
|
// Reset the current organization
|
||||||
|
$organization->owner()
|
||||||
|
->where('current_team_id', $organization->getKey())
|
||||||
|
->update(['current_team_id' => null]);
|
||||||
|
|
||||||
|
$organization->users()
|
||||||
|
->where('current_team_id', $organization->getKey())
|
||||||
|
->update(['current_team_id' => null]);
|
||||||
|
|
||||||
|
// Delete all members
|
||||||
|
$users = $organization->users()
|
||||||
|
->with([
|
||||||
|
'currentOrganization',
|
||||||
|
])
|
||||||
|
->get();
|
||||||
|
$organization->users()->sync([]);
|
||||||
|
|
||||||
|
// Make sure all users have at least one organization
|
||||||
|
foreach ($users as $user) {
|
||||||
|
if ($user->is_placeholder) {
|
||||||
|
$user->delete();
|
||||||
|
} else {
|
||||||
|
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
|
||||||
|
$this->userService->makeSureUserHasCurrentOrganization($user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete organization
|
||||||
|
$organization->delete();
|
||||||
|
|
||||||
|
Log::debug('Finished deleting organization', [
|
||||||
|
'organization_id' => $organization->getKey(),
|
||||||
|
'name' => $organization->name,
|
||||||
|
'owner_id' => $organization->user_id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers
|
||||||
|
*/
|
||||||
|
public function deleteUser(User $user, bool $inTransaction = true): void
|
||||||
|
{
|
||||||
|
if ($inTransaction) {
|
||||||
|
DB::transaction(function () use ($user) {
|
||||||
|
$this->deleteUser($user, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::debug('Start deleting user', [
|
||||||
|
'id' => $user->getKey(),
|
||||||
|
'name' => $user->name,
|
||||||
|
'email' => $user->email,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$members = Member::query()->whereBelongsTo($user, 'user')
|
||||||
|
->with([
|
||||||
|
'organization',
|
||||||
|
'user',
|
||||||
|
])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($members as $member) {
|
||||||
|
if ($member->role === Role::Owner->value && $member->organization->users()->count() > 1) {
|
||||||
|
throw new CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Member $member */
|
||||||
|
foreach ($members as $member) {
|
||||||
|
if ($member->role === Role::Owner->value) {
|
||||||
|
// Note: The member needs to be deleted first, otherwise the organization delete function will recreate a new personal organization for the user
|
||||||
|
$member->delete();
|
||||||
|
$this->deleteOrganization($member->organization, false);
|
||||||
|
} else {
|
||||||
|
$this->userService->makeMemberToPlaceholder($member);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Since the deletion of the profile photo is not reversible via a database rollback this needs to be done last
|
||||||
|
$user->deleteProfilePhoto();
|
||||||
|
|
||||||
|
$user->delete();
|
||||||
|
|
||||||
|
Log::debug('Finished deleting user', [
|
||||||
|
'id' => $user->getKey(),
|
||||||
|
'name' => $user->name,
|
||||||
|
'email' => $user->email,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,11 @@ class PermissionStore
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $this->userHas($organization, $user, $permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function userHas(Organization $organization, User $user, string $permission): bool
|
||||||
|
{
|
||||||
if (! isset($this->permissionCache[$user->getKey().'|'.$organization->getKey()])) {
|
if (! isset($this->permissionCache[$user->getKey().'|'.$organization->getKey()])) {
|
||||||
if (! $user->belongsToTeam($organization)) {
|
if (! $user->belongsToTeam($organization)) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ class UserService
|
|||||||
throw new \InvalidArgumentException('User is not a member of the organization');
|
throw new \InvalidArgumentException('User is not a member of the organization');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->assignOrganizationEntitiesToDifferentMember($organization, $fromUser, $toUser, $toMember);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assignOrganizationEntitiesToDifferentMember(Organization $organization, User $fromUser, User $toUser, Member $toMember): void
|
||||||
|
{
|
||||||
// Time entries
|
// Time entries
|
||||||
TimeEntry::query()
|
TimeEntry::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
@@ -47,6 +52,53 @@ class UserService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function makeMemberToPlaceholder(Member $member): void
|
||||||
|
{
|
||||||
|
$user = $member->user;
|
||||||
|
$placeholderUser = $user->replicate();
|
||||||
|
$placeholderUser->is_placeholder = true;
|
||||||
|
$placeholderUser->save();
|
||||||
|
|
||||||
|
$member->user()->associate($placeholderUser);
|
||||||
|
$member->role = Role::Placeholder->value;
|
||||||
|
$member->save();
|
||||||
|
|
||||||
|
$this->assignOrganizationEntitiesToDifferentMember($member->organization, $user, $placeholderUser, $member);
|
||||||
|
$this->makeSureUserHasAtLeastOneOrganization($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function makeSureUserHasAtLeastOneOrganization(User $user): void
|
||||||
|
{
|
||||||
|
if ($user->organizations()->count() > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new organization
|
||||||
|
$organization = new Organization();
|
||||||
|
$organization->name = $user->name."'s Organization";
|
||||||
|
$organization->personal_team = true;
|
||||||
|
$organization->user_id = $user->id;
|
||||||
|
$organization->save();
|
||||||
|
|
||||||
|
// Attach the user to the organization
|
||||||
|
$organization->users()->attach($user, ['role' => Role::Owner->value]);
|
||||||
|
|
||||||
|
// Set the organization as the user's current organization
|
||||||
|
$user->currentOrganization()->associate($organization);
|
||||||
|
$user->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function makeSureUserHasCurrentOrganization(User $user): void
|
||||||
|
{
|
||||||
|
if ($user->currentOrganization !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$organization = $user->organizations()->first();
|
||||||
|
$user->currentOrganization()->associate($organization);
|
||||||
|
$user->save();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change the ownership of an organization to a new user.
|
* Change the ownership of an organization to a new user.
|
||||||
* The previous owner will be demoted to an admin.
|
* The previous owner will be demoted to an admin.
|
||||||
|
|||||||
@@ -93,6 +93,9 @@
|
|||||||
"test:coverage:report": [
|
"test:coverage:report": [
|
||||||
"@php vendor/bin/phpunit --coverage-html=coverage"
|
"@php vendor/bin/phpunit --coverage-html=coverage"
|
||||||
],
|
],
|
||||||
|
"coverage-report": [
|
||||||
|
"@test:coverage:report"
|
||||||
|
],
|
||||||
"fix": [
|
"fix": [
|
||||||
"@php pint"
|
"@php pint"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -144,6 +144,11 @@ return [
|
|||||||
'emergency' => [
|
'emergency' => [
|
||||||
'path' => storage_path('logs/laravel.log'),
|
'path' => storage_path('logs/laravel.log'),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'deprecation' => [
|
||||||
|
'driver' => 'single',
|
||||||
|
'path' => storage_path('logs/deprecation.log'),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class OrganizationFactory extends Factory
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => $this->faker->unique()->company(),
|
'name' => $this->faker->unique()->company(),
|
||||||
'currency' => $this->faker->currencyCode,
|
'currency' => $this->faker->currencyCode(),
|
||||||
'billable_rate' => $this->faker->numberBetween(50, 1000) * 100,
|
'billable_rate' => $this->faker->numberBetween(50, 1000) * 100,
|
||||||
'user_id' => User::factory(),
|
'user_id' => User::factory(),
|
||||||
'personal_team' => true,
|
'personal_team' => true,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Enums\Weekday;
|
|||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,6 +87,20 @@ class UserFactory extends Factory
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function withProfilePicture(): static
|
||||||
|
{
|
||||||
|
$profilePhoto = $this->faker->image(null, 500, 500);
|
||||||
|
/** @see \Illuminate\Http\FileHelpers::hashName */
|
||||||
|
$path = 'profile-photos/'.Str::random(40).'.png';
|
||||||
|
Storage::disk(config('jetstream.profile_photo_disk', 'public'))->put($path, $profilePhoto);
|
||||||
|
|
||||||
|
return $this->state(function (array $attributes) use ($path): array {
|
||||||
|
return [
|
||||||
|
'profile_photo_path' => $path,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Indicate that the user should have a personal team.
|
* Indicate that the user should have a personal team.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -20,14 +20,14 @@ return new class extends Migration
|
|||||||
$table->foreign('project_id')
|
$table->foreign('project_id')
|
||||||
->references('id')
|
->references('id')
|
||||||
->on('projects')
|
->on('projects')
|
||||||
->onDelete('restrict')
|
->restrictOnDelete()
|
||||||
->onUpdate('cascade');
|
->cascadeOnUpdate();
|
||||||
$table->uuid('user_id');
|
$table->uuid('user_id');
|
||||||
$table->foreign('user_id')
|
$table->foreign('user_id')
|
||||||
->references('id')
|
->references('id')
|
||||||
->on('users')
|
->on('users')
|
||||||
->onDelete('restrict')
|
->restrictOnDelete()
|
||||||
->onUpdate('cascade');
|
->cascadeOnUpdate();
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
$table->unique(['project_id', 'user_id']);
|
$table->unique(['project_id', 'user_id']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('time_entries', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['member_id']);
|
||||||
|
$table->foreign('member_id')
|
||||||
|
->references('id')
|
||||||
|
->on('members')
|
||||||
|
->restrictOnDelete()
|
||||||
|
->cascadeOnUpdate();
|
||||||
|
$table->dropForeign(['client_id']);
|
||||||
|
$table->foreign('client_id')
|
||||||
|
->references('id')
|
||||||
|
->on('clients')
|
||||||
|
->restrictOnDelete()
|
||||||
|
->cascadeOnUpdate();
|
||||||
|
});
|
||||||
|
Schema::table('project_members', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['member_id']);
|
||||||
|
$table->foreign('member_id')
|
||||||
|
->references('id')
|
||||||
|
->on('members')
|
||||||
|
->restrictOnDelete()
|
||||||
|
->cascadeOnUpdate();
|
||||||
|
});
|
||||||
|
Schema::table('organization_invitations', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['organization_id']);
|
||||||
|
$table->foreign('organization_id')
|
||||||
|
->references('id')
|
||||||
|
->on('organizations')
|
||||||
|
->restrictOnDelete()
|
||||||
|
->cascadeOnUpdate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('time_entries', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['member_id']);
|
||||||
|
$table->foreign('member_id')
|
||||||
|
->references('id')
|
||||||
|
->on('members')
|
||||||
|
->cascadeOnDelete()
|
||||||
|
->cascadeOnUpdate();
|
||||||
|
$table->dropForeign(['client_id']);
|
||||||
|
$table->foreign('client_id')
|
||||||
|
->references('id')
|
||||||
|
->on('clients')
|
||||||
|
->cascadeOnDelete()
|
||||||
|
->cascadeOnUpdate();
|
||||||
|
});
|
||||||
|
Schema::table('project_members', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['member_id']);
|
||||||
|
$table->foreign('member_id')
|
||||||
|
->references('id')
|
||||||
|
->on('members')
|
||||||
|
->cascadeOnDelete()
|
||||||
|
->cascadeOnUpdate();
|
||||||
|
});
|
||||||
|
Schema::table('organization_invitations', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['organization_id']);
|
||||||
|
$table->foreign('organization_id')
|
||||||
|
->references('id')
|
||||||
|
->on('organizations')
|
||||||
|
->cascadeOnDelete()
|
||||||
|
->cascadeOnUpdate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
1137
database/schema/pgsql-schema.sql
Normal file
1137
database/schema/pgsql-schema.sql
Normal file
File diff suppressed because it is too large
Load Diff
@@ -83,10 +83,10 @@ class DatabaseSeeder extends Seeder
|
|||||||
->count(5)
|
->count(5)
|
||||||
->forMember($userWithMultipleOrganizationsAcmeMember)
|
->forMember($userWithMultipleOrganizationsAcmeMember)
|
||||||
->create();
|
->create();
|
||||||
$client = Client::factory()->forOrganization($organizationAcme)->create([
|
$acmeClient = Client::factory()->forOrganization($organizationAcme)->create([
|
||||||
'name' => 'Big Company',
|
'name' => 'Big Company',
|
||||||
]);
|
]);
|
||||||
$bigCompanyProject = Project::factory()->forOrganization($organizationAcme)->forClient($client)->create([
|
$bigCompanyProject = Project::factory()->forOrganization($organizationAcme)->forClient($acmeClient)->create([
|
||||||
'name' => 'Big Company Project',
|
'name' => 'Big Company Project',
|
||||||
]);
|
]);
|
||||||
ProjectMember::factory()->forProject($bigCompanyProject)->forMember($userAcmeEmployeeMember)->create();
|
ProjectMember::factory()->forProject($bigCompanyProject)->forMember($userAcmeEmployeeMember)->create();
|
||||||
@@ -105,11 +105,11 @@ class DatabaseSeeder extends Seeder
|
|||||||
'name' => 'Internal Project',
|
'name' => 'Internal Project',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$organization2Owner = User::factory()->create([
|
$rivalOwner = User::factory()->create([
|
||||||
'name' => 'Other Owner',
|
'name' => 'Other Owner',
|
||||||
'email' => 'owner@rival-company.test',
|
'email' => 'owner@rival-company.test',
|
||||||
]);
|
]);
|
||||||
$organizationRival = Organization::factory()->withOwner($organization2Owner)->create([
|
$organizationRival = Organization::factory()->withOwner($rivalOwner)->create([
|
||||||
'name' => 'Rival Corp',
|
'name' => 'Rival Corp',
|
||||||
'personal_team' => true,
|
'personal_team' => true,
|
||||||
'currency' => 'USD',
|
'currency' => 'USD',
|
||||||
@@ -120,9 +120,12 @@ class DatabaseSeeder extends Seeder
|
|||||||
]);
|
]);
|
||||||
$userRivalManagerMember = Member::factory()->forUser($userRivalManager)->forOrganization($organizationRival)->role(Role::Admin)->create();
|
$userRivalManagerMember = Member::factory()->forUser($userRivalManager)->forOrganization($organizationRival)->role(Role::Admin)->create();
|
||||||
$userWithMultipleOrganizationsRivalMember = Member::factory()->forUser($userWithMultipleOrganizations)->forOrganization($organizationRival)->role(Role::Employee)->create();
|
$userWithMultipleOrganizationsRivalMember = Member::factory()->forUser($userWithMultipleOrganizations)->forOrganization($organizationRival)->role(Role::Employee)->create();
|
||||||
$otherCompanyProject = Project::factory()->forOrganization($organizationRival)->forClient($client)->create([
|
$rivalClient = Client::factory()->forOrganization($organizationRival)->create([
|
||||||
'name' => 'Scale Company',
|
'name' => 'Scale Company',
|
||||||
]);
|
]);
|
||||||
|
$otherCompanyProject = Project::factory()->forOrganization($organizationRival)->forClient($rivalClient)->create([
|
||||||
|
'name' => 'Scale Company - Project ABC',
|
||||||
|
]);
|
||||||
ProjectMember::factory()->forProject($otherCompanyProject)->forMember($userRivalManagerMember)->create();
|
ProjectMember::factory()->forProject($otherCompanyProject)->forMember($userRivalManagerMember)->create();
|
||||||
ProjectMember::factory()->forProject($otherCompanyProject)->forMember($userWithMultipleOrganizationsRivalMember)->create();
|
ProjectMember::factory()->forProject($otherCompanyProject)->forMember($userWithMultipleOrganizationsRivalMember)->create();
|
||||||
TimeEntry::factory()
|
TimeEntry::factory()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
||||||
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
||||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||||
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
|
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
|
||||||
@@ -19,5 +20,7 @@ return [
|
|||||||
UserIsAlreadyMemberOfProjectApiException::KEY => 'User is already a member of the project',
|
UserIsAlreadyMemberOfProjectApiException::KEY => 'User is already a member of the project',
|
||||||
EntityStillInUseApiException::KEY => 'The :modelToDelete is still used by a :modelInUse and can not be deleted.',
|
EntityStillInUseApiException::KEY => 'The :modelToDelete is still used by a :modelInUse and can not be deleted.',
|
||||||
CanNotRemoveOwnerFromOrganization::KEY => 'Can not remove owner from organization',
|
CanNotRemoveOwnerFromOrganization::KEY => 'Can not remove owner from organization',
|
||||||
|
CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers::KEY => 'Can not delete user who is owner of organization with multiple members. Please delete the organization first.',
|
||||||
],
|
],
|
||||||
|
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
@@ -42,4 +45,24 @@ class DeleteAccountTest extends TestCase
|
|||||||
// Assert
|
// Assert
|
||||||
$this->assertNotNull($user->fresh());
|
$this->assertNotNull($user->fresh());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_user_account_can_not_be_deleted_if_attached_to_a_organization_with_multiple_users(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$organization = Organization::factory()->withOwner($user)->create();
|
||||||
|
$userMember = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Owner)->create();
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
$otherMember = Member::factory()->forOrganization($organization)->forUser($otherUser)->role(Role::Admin)->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->delete('/user', [
|
||||||
|
'password' => 'password',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertInvalid(['password']);
|
||||||
|
$this->assertNotNull($user->fresh());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
@@ -13,30 +15,70 @@ class DeleteTeamTest extends TestCase
|
|||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
public function test_teams_can_be_deleted(): void
|
public function test_teams_can_be_deleted_and_users_of_the_organization_that_have_no_organization_get_a_new_one(): void
|
||||||
{
|
{
|
||||||
$this->actingAs($user = User::factory()->withPersonalOrganization()->create());
|
// Arrange
|
||||||
|
$user = User::factory()->withPersonalOrganization()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
$user->ownedTeams()->save($team = Organization::factory()->make([
|
$organization = Organization::factory()->withOwner($user)->create([
|
||||||
'personal_team' => false,
|
'personal_team' => false,
|
||||||
]));
|
]);
|
||||||
|
Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Owner)->create();
|
||||||
|
|
||||||
$team->users()->attach(
|
$otherUser = User::factory()->create();
|
||||||
$otherUser = User::factory()->create(), ['role' => 'test-role']
|
$organization->users()->attach(
|
||||||
|
$otherUser, ['role' => 'test-role']
|
||||||
);
|
);
|
||||||
|
|
||||||
$response = $this->delete('/teams/'.$team->getKey());
|
// Act
|
||||||
|
$response = $this->withoutExceptionHandling()->delete('/teams/'.$organization->getKey());
|
||||||
|
|
||||||
$this->assertNull($team->fresh());
|
// Assert
|
||||||
$this->assertCount(0, $otherUser->fresh()->teams);
|
$this->assertNull($organization->fresh());
|
||||||
|
$this->assertCount(1, $otherUser->fresh()->teams);
|
||||||
|
$this->assertFalse($otherUser->fresh()->teams->first()->is($organization));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_personal_teams_cant_be_deleted(): void
|
public function test_personal_teams_can_be_deleted_but_user_gets_an_new_one_if_this_is_the_only_one_left(): void
|
||||||
{
|
{
|
||||||
$this->actingAs($user = User::factory()->withPersonalOrganization()->create());
|
// Arrange
|
||||||
|
$user = User::factory()->withPersonalOrganization()->create();
|
||||||
|
$organization = $user->currentTeam;
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
$response = $this->delete('/teams/'.$user->currentTeam->getKey());
|
// Act
|
||||||
|
$response = $this->delete('/teams/'.$organization->getKey());
|
||||||
|
|
||||||
$this->assertNotNull($user->currentTeam->fresh());
|
// Assert
|
||||||
|
$user->refresh();
|
||||||
|
$this->assertDatabaseMissing(Organization::class, [
|
||||||
|
'id' => $organization->getKey(),
|
||||||
|
]);
|
||||||
|
$this->assertTrue($user->currentTeam->isNot($organization));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_organization_can_not_be_deleted_if_user_is_not_owner(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = User::factory()->withPersonalOrganization()->create();
|
||||||
|
$organization = Organization::factory()->withOwner($user)->create([
|
||||||
|
'personal_team' => false,
|
||||||
|
]);
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
$organization->users()->attach(
|
||||||
|
$otherUser, ['role' => Role::Admin->value]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->delete('/teams/'.$organization->getKey());
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertForbidden();
|
||||||
|
$this->assertDatabaseHas(Organization::class, [
|
||||||
|
'id' => $organization->getKey(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit\Console\Commands\Admin;
|
||||||
|
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Service\DeletionService;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Mockery\MockInterface;
|
||||||
|
use Tests\TestCaseWithDatabase;
|
||||||
|
|
||||||
|
class DeleteOrganizationCommandTest extends TestCaseWithDatabase
|
||||||
|
{
|
||||||
|
public function test_it_calls_the_deletion_service_with_the_organization(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$this->mock(DeletionService::class, function (MockInterface $mock) use ($organization): void {
|
||||||
|
$mock->shouldReceive('deleteOrganization')
|
||||||
|
->withArgs(fn (Organization $organizationArg) => $organizationArg->is($organization))
|
||||||
|
->once();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$this->artisan('admin:delete-organization', ['organization' => $organization->getKey()])
|
||||||
|
->expectsOutput("Deleting organization with ID {$organization->getKey()}")
|
||||||
|
->expectsOutput("Organization with ID {$organization->getKey()} has been deleted.")
|
||||||
|
->assertExitCode(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_fails_if_organization_does_not_exist(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$organizationId = Str::uuid()->toString();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$this->artisan('admin:delete-organization', ['organization' => $organizationId])
|
||||||
|
->expectsOutput('Organization with ID '.$organizationId.' not found.')
|
||||||
|
->assertExitCode(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_fails_if_organization_id_is_not_a_valid_uuid(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$organizationId = 'invalid-uuid';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$this->artisan('admin:delete-organization', ['organization' => $organizationId])
|
||||||
|
->expectsOutput('Organization ID must be a valid UUID.')
|
||||||
|
->assertExitCode(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit\Console\Commands\SelfHost;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class SelfHostGenerateKeysCommandTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_generates_app_key_and_passport_keys_per_default_in_env_format(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:generate-keys');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertSame(Command::SUCCESS, $exitCode);
|
||||||
|
$output = Artisan::output();
|
||||||
|
$this->assertStringContainsString('APP_KEY="base64:', $output);
|
||||||
|
$this->assertStringContainsString('PASSPORT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----', $output);
|
||||||
|
$this->assertStringContainsString('PASSPORT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----', $output);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_generates_app_key_and_passport_keys_in_yaml_format_if_requested(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:generate-keys --format=yaml');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertSame(Command::SUCCESS, $exitCode);
|
||||||
|
$output = Artisan::output();
|
||||||
|
$this->assertStringContainsString('APP_KEY: "base64:', $output);
|
||||||
|
$this->assertStringContainsString("PASSPORT_PRIVATE_KEY: |\n -----BEGIN PRIVATE KEY-----", $output);
|
||||||
|
$this->assertStringContainsString("PASSPORT_PUBLIC_KEY: |\n -----BEGIN PUBLIC KEY-----", $output);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,13 +5,10 @@ declare(strict_types=1);
|
|||||||
namespace Tests\Unit\Filament;
|
namespace Tests\Unit\Filament;
|
||||||
|
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Tests\TestCaseWithDatabase;
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
abstract class FilamentTestCase extends TestCase
|
abstract class FilamentTestCase extends TestCaseWithDatabase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ namespace Tests\Unit\Filament;
|
|||||||
use App\Filament\Resources\OrganizationResource;
|
use App\Filament\Resources\OrganizationResource;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Service\DeletionService;
|
||||||
use Illuminate\Support\Facades\Config;
|
use Illuminate\Support\Facades\Config;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
use Mockery\MockInterface;
|
||||||
|
|
||||||
class OrganizationResourceTest extends FilamentTestCase
|
class OrganizationResourceTest extends FilamentTestCase
|
||||||
{
|
{
|
||||||
@@ -50,4 +52,23 @@ class OrganizationResourceTest extends FilamentTestCase
|
|||||||
// Assert
|
// Assert
|
||||||
$response->assertSuccessful();
|
$response->assertSuccessful();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_can_delete_a_organization(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = $this->createUserWithPermission();
|
||||||
|
$this->mock(DeletionService::class, function (MockInterface $mock) use ($user): void {
|
||||||
|
$mock->shouldReceive('deleteOrganization')
|
||||||
|
->withArgs(fn (Organization $organizationArg) => $organizationArg->is($user->organization))
|
||||||
|
->once();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = Livewire::test(OrganizationResource\Pages\EditOrganization::class, ['record' => $user->organization->getKey()])
|
||||||
|
->callAction('delete')
|
||||||
|
->assertHasNoActionErrors();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertSuccessful();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tests\Unit\Filament;
|
namespace Tests\Unit\Filament;
|
||||||
|
|
||||||
|
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
||||||
use App\Filament\Resources\UserResource;
|
use App\Filament\Resources\UserResource;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Service\DeletionService;
|
||||||
use Illuminate\Support\Facades\Config;
|
use Illuminate\Support\Facades\Config;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
use Mockery\MockInterface;
|
||||||
|
|
||||||
class UserResourceTest extends FilamentTestCase
|
class UserResourceTest extends FilamentTestCase
|
||||||
{
|
{
|
||||||
@@ -46,4 +49,42 @@ class UserResourceTest extends FilamentTestCase
|
|||||||
// Assert
|
// Assert
|
||||||
$response->assertSuccessful();
|
$response->assertSuccessful();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_can_delete_a_user(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = $this->createUserWithPermission();
|
||||||
|
$this->mock(DeletionService::class, function (MockInterface $mock) use ($user): void {
|
||||||
|
$mock->shouldReceive('deleteUser')
|
||||||
|
->withArgs(fn (User $userArg) => $userArg->is($user->user))
|
||||||
|
->once();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = Livewire::test(UserResource\Pages\EditUser::class, ['record' => $user->user->getKey()])
|
||||||
|
->callAction('delete');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertHasNoActionErrors();
|
||||||
|
$response->assertSuccessful();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_delete_user_shows_error_notification_on_failure(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = $this->createUserWithPermission();
|
||||||
|
$this->mock(DeletionService::class, function (MockInterface $mock) use ($user): void {
|
||||||
|
$mock->shouldReceive('deleteUser')
|
||||||
|
->withArgs(fn (User $userArg) => $userArg->is($user->user))
|
||||||
|
->andThrow(new CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = Livewire::test(UserResource\Pages\EditUser::class, ['record' => $user->user->getKey()])
|
||||||
|
->callAction('delete');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertNotified(__('exceptions.api.can_not_delete_user_who_is_owner_of_organization_with_multiple_members'));
|
||||||
|
$response->assertSuccessful();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ namespace Tests\Unit\Model;
|
|||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
|
|
||||||
|
#[CoversClass(Client::class)]
|
||||||
|
#[UsesClass(Client::class)]
|
||||||
class ClientModelTest extends ModelTestAbstract
|
class ClientModelTest extends ModelTestAbstract
|
||||||
{
|
{
|
||||||
public function test_it_belongs_to_a_organization(): void
|
public function test_it_belongs_to_a_organization(): void
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ use App\Models\Member;
|
|||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\ProjectMember;
|
use App\Models\ProjectMember;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
|
|
||||||
|
#[CoversClass(ProjectMember::class)]
|
||||||
|
#[UsesClass(ProjectMember::class)]
|
||||||
class ProjectMemberModelTest extends ModelTestAbstract
|
class ProjectMemberModelTest extends ModelTestAbstract
|
||||||
{
|
{
|
||||||
public function test_it_belongs_to_a_project(): void
|
public function test_it_belongs_to_a_project(): void
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ use App\Models\Organization;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\ProjectMember;
|
use App\Models\ProjectMember;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
|
|
||||||
|
#[CoversClass(Project::class)]
|
||||||
|
#[UsesClass(Project::class)]
|
||||||
class ProjectModelTest extends ModelTestAbstract
|
class ProjectModelTest extends ModelTestAbstract
|
||||||
{
|
{
|
||||||
public function test_it_belongs_to_a_organization(): void
|
public function test_it_belongs_to_a_organization(): void
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ namespace Tests\Unit\Model;
|
|||||||
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
|
|
||||||
|
#[CoversClass(Tag::class)]
|
||||||
|
#[UsesClass(Tag::class)]
|
||||||
class TagModelTest extends ModelTestAbstract
|
class TagModelTest extends ModelTestAbstract
|
||||||
{
|
{
|
||||||
public function test_it_belongs_to_a_organization(): void
|
public function test_it_belongs_to_a_organization(): void
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ use App\Models\Project;
|
|||||||
use App\Models\ProjectMember;
|
use App\Models\ProjectMember;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
|
|
||||||
|
#[CoversClass(Task::class)]
|
||||||
|
#[UsesClass(Task::class)]
|
||||||
class TaskModelTest extends ModelTestAbstract
|
class TaskModelTest extends ModelTestAbstract
|
||||||
{
|
{
|
||||||
public function test_it_belongs_to_a_organization(): void
|
public function test_it_belongs_to_a_organization(): void
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ use App\Models\Task;
|
|||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
|
|
||||||
|
#[CoversClass(TimeEntry::class)]
|
||||||
|
#[UsesClass(TimeEntry::class)]
|
||||||
class TimeEntryModelTest extends ModelTestAbstract
|
class TimeEntryModelTest extends ModelTestAbstract
|
||||||
{
|
{
|
||||||
public function test_it_belongs_to_a_user(): void
|
public function test_it_belongs_to_a_user(): void
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ namespace Tests\Unit\Rules;
|
|||||||
|
|
||||||
use App\Rules\ColorRule;
|
use App\Rules\ColorRule;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
#[CoversClass(ColorRule::class)]
|
||||||
|
#[UsesClass(ColorRule::class)]
|
||||||
class ColorRuleTest extends TestCase
|
class ColorRuleTest extends TestCase
|
||||||
{
|
{
|
||||||
public function test_validation_passes_if_value_is_valid_color(): void
|
public function test_validation_passes_if_value_is_valid_color(): void
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ namespace Tests\Unit\Rules;
|
|||||||
|
|
||||||
use App\Rules\CurrencyRule;
|
use App\Rules\CurrencyRule;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
#[CoversClass(CurrencyRule::class)]
|
||||||
|
#[UsesClass(CurrencyRule::class)]
|
||||||
class CurrencyRuleTest extends TestCase
|
class CurrencyRuleTest extends TestCase
|
||||||
{
|
{
|
||||||
public function test_validation_passes_if_value_is_valid_currency_code(): void
|
public function test_validation_passes_if_value_is_valid_currency_code(): void
|
||||||
|
|||||||
@@ -12,8 +12,12 @@ use App\Models\TimeEntry;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Service\BillableRateService;
|
use App\Service\BillableRateService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
#[CoversClass(BillableRateService::class)]
|
||||||
|
#[UsesClass(BillableRateService::class)]
|
||||||
class BillableRateServiceTest extends TestCase
|
class BillableRateServiceTest extends TestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|||||||
@@ -15,8 +15,12 @@ use App\Models\User;
|
|||||||
use App\Service\DashboardService;
|
use App\Service\DashboardService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
#[CoversClass(DashboardService::class)]
|
||||||
|
#[UsesClass(DashboardService::class)]
|
||||||
class DashboardServiceTest extends TestCase
|
class DashboardServiceTest extends TestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|||||||
346
tests/Unit/Service/DeletionServiceTest.php
Normal file
346
tests/Unit/Service/DeletionServiceTest.php
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit\Service;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
|
use App\Events\BeforeOrganizationDeletion;
|
||||||
|
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectMember;
|
||||||
|
use App\Models\Tag;
|
||||||
|
use App\Models\Task;
|
||||||
|
use App\Models\TimeEntry;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Service\DeletionService;
|
||||||
|
use Illuminate\Database\QueryException;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
|
use Tests\TestCaseWithDatabase;
|
||||||
|
use TiMacDonald\Log\LogEntry;
|
||||||
|
|
||||||
|
#[CoversClass(DeletionService::class)]
|
||||||
|
#[UsesClass(DeletionService::class)]
|
||||||
|
class DeletionServiceTest extends TestCaseWithDatabase
|
||||||
|
{
|
||||||
|
private DeletionService $deletionService;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
Event::fake([
|
||||||
|
BeforeOrganizationDeletion::class,
|
||||||
|
]);
|
||||||
|
$this->deletionService = app(DeletionService::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an organization with all relations.
|
||||||
|
* It is important that every relation has at least two entries, to test for possible lazy loading issues.
|
||||||
|
*
|
||||||
|
* @return object{
|
||||||
|
* organization: Organization,
|
||||||
|
* clients: Collection<Client>,
|
||||||
|
* projects: Collection<Project>,
|
||||||
|
* projectMembers: Collection<ProjectMember>,
|
||||||
|
* tags: Collection<Tag>,
|
||||||
|
* members: Collection<Member>,
|
||||||
|
* tasks: Collection<Task>,
|
||||||
|
* timeEntries: Collection<TimeEntry>,
|
||||||
|
* owner: User
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function createOrganizationWithAllRelations(): object
|
||||||
|
{
|
||||||
|
$userOwner = User::factory()->create();
|
||||||
|
$userEmployee = User::factory()->withProfilePicture()->create();
|
||||||
|
$userPlaceholder = User::factory()->placeholder()->create();
|
||||||
|
|
||||||
|
$organization = Organization::factory()->withOwner($userOwner)->create();
|
||||||
|
|
||||||
|
// Create a personal organization for the employee
|
||||||
|
$personalOrganizationOfEmployee = Organization::factory()->withOwner($userEmployee)->create();
|
||||||
|
$personalOrganizationMember = Member::factory()->forUser($userEmployee)->forOrganization($personalOrganizationOfEmployee)->create();
|
||||||
|
|
||||||
|
// Set the current organizations for the users
|
||||||
|
$userOwner->update(['current_team_id' => $organization->id]);
|
||||||
|
$userEmployee->update(['current_team_id' => $personalOrganizationOfEmployee->id]);
|
||||||
|
$userPlaceholder->update(['current_team_id' => null]);
|
||||||
|
|
||||||
|
$memberOwner = Member::factory()->forUser($userOwner)->forOrganization($organization)->role(Role::Owner)->create();
|
||||||
|
$memberEmployee = Member::factory()->forUser($userEmployee)->forOrganization($organization)->role(Role::Employee)->create();
|
||||||
|
$memberPlaceholder = Member::factory()->forUser($userPlaceholder)->forOrganization($organization)->role(Role::Placeholder)->create();
|
||||||
|
$members = collect([$memberOwner, $memberEmployee, $memberPlaceholder]);
|
||||||
|
|
||||||
|
$clients = Client::factory()->forOrganization($organization)->createMany(2);
|
||||||
|
|
||||||
|
$projectWithClient = Project::factory()->forClient($clients->get(0))->forOrganization($organization)->create();
|
||||||
|
$projectWithoutClient = Project::factory()->forOrganization($organization)->create();
|
||||||
|
$projects = collect([$projectWithClient, $projectWithoutClient]);
|
||||||
|
|
||||||
|
$projectMemberOwner = ProjectMember::factory()->forMember($memberOwner)->forProject($projectWithClient)->create();
|
||||||
|
$projectMemberEmployee = ProjectMember::factory()->forMember($memberEmployee)->forProject($projectWithClient)->create();
|
||||||
|
$projectMembers = collect([$projectMemberOwner, $projectMemberEmployee]);
|
||||||
|
|
||||||
|
$tags = Tag::factory()->forOrganization($organization)->createMany(2);
|
||||||
|
|
||||||
|
$task1 = Task::factory()->forProject($projectWithClient)->forOrganization($organization)->create();
|
||||||
|
$task2 = Task::factory()->forProject($projectWithoutClient)->forOrganization($organization)->create();
|
||||||
|
$tasks = collect([$task1, $task2]);
|
||||||
|
|
||||||
|
$timeEntries = TimeEntry::factory()->forOrganization($organization)->forMember($memberOwner)->createMany(2);
|
||||||
|
$timeEntriesWithTask = TimeEntry::factory()->forTask($task1)->forOrganization($organization)->forMember($memberEmployee)->createMany(2);
|
||||||
|
$timeEntriesWithProject = TimeEntry::factory()->forProject($projectWithClient)->forOrganization($organization)->forMember($memberPlaceholder)->createMany(2);
|
||||||
|
$timeEntries = $timeEntries->merge($timeEntriesWithTask)->merge($timeEntriesWithProject);
|
||||||
|
|
||||||
|
return (object) [
|
||||||
|
'organization' => $organization,
|
||||||
|
'clients' => $clients,
|
||||||
|
'projects' => $projects,
|
||||||
|
'projectMembers' => $projectMembers,
|
||||||
|
'tags' => $tags,
|
||||||
|
'members' => $members,
|
||||||
|
'tasks' => $tasks,
|
||||||
|
'timeEntries' => $timeEntries,
|
||||||
|
'owner' => $userOwner,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertOrganizationDeleted(Organization $organization): void
|
||||||
|
{
|
||||||
|
Event::assertDispatched(function (BeforeOrganizationDeletion $event) use ($organization) {
|
||||||
|
return $event->organization->is($organization);
|
||||||
|
}, 1);
|
||||||
|
$this->assertSame(0, Organization::query()->where('id', $organization->id)->count());
|
||||||
|
$this->assertSame(0, Client::query()->whereBelongsTo($organization, 'organization')->count());
|
||||||
|
$this->assertSame(0, Project::query()->whereBelongsTo($organization, 'organization')->count());
|
||||||
|
$this->assertSame(0, ProjectMember::query()->whereBelongsToOrganization($organization)->count());
|
||||||
|
$this->assertSame(0, Tag::query()->whereBelongsTo($organization, 'organization')->count());
|
||||||
|
$this->assertSame(0, Member::query()->whereBelongsTo($organization, 'organization')->count());
|
||||||
|
$this->assertSame(0, Task::query()->whereBelongsTo($organization, 'organization')->count());
|
||||||
|
$this->assertSame(0, TimeEntry::query()->whereBelongsTo($organization, 'organization')->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertOrganizationNothingDeleted(Organization $organization, bool $specialCase = false): void
|
||||||
|
{
|
||||||
|
$this->assertSame(1, Organization::query()->where('id', $organization->id)->count());
|
||||||
|
$this->assertSame(2, Client::query()->whereBelongsTo($organization, 'organization')->count());
|
||||||
|
$this->assertSame(2, Project::query()->whereBelongsTo($organization, 'organization')->count());
|
||||||
|
$this->assertSame(2, ProjectMember::query()->whereBelongsToOrganization($organization)->count());
|
||||||
|
$this->assertSame(2, Tag::query()->whereBelongsTo($organization, 'organization')->count());
|
||||||
|
$this->assertSame(3, Member::query()->whereBelongsTo($organization, 'organization')->count());
|
||||||
|
$this->assertSame(2, Task::query()->whereBelongsTo($organization, 'organization')->count());
|
||||||
|
$this->assertSame($specialCase ? 7 : 6, TimeEntry::query()->whereBelongsTo($organization, 'organization')->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_delete_organization_deletes_all_resources_of_the_organization_but_does_not_delete_other_resources(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$organization = $this->createOrganizationWithAllRelations();
|
||||||
|
$otherOrganization = $this->createOrganizationWithAllRelations();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$this->deletionService->deleteOrganization($organization->organization);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertOrganizationDeleted($organization->organization);
|
||||||
|
$this->assertOrganizationNothingDeleted($otherOrganization->organization);
|
||||||
|
Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug'
|
||||||
|
&& $log->message === 'Start deleting organization'
|
||||||
|
&& $log->context['organization_id'] === $organization->organization->getKey(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug'
|
||||||
|
&& $log->message === 'Finished deleting organization'
|
||||||
|
&& $log->context['organization_id'] === $organization->organization->getKey(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_delete_organization_rolls_back_on_error_if_transaction_is_active(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$organization = $this->createOrganizationWithAllRelations();
|
||||||
|
$otherOrganization = $this->createOrganizationWithAllRelations();
|
||||||
|
$brokenTimeEntry = TimeEntry::factory()->forOrganization($otherOrganization->organization)->forProject($organization->projects->get(0))->create();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
$this->deletionService->deleteOrganization($organization->organization);
|
||||||
|
$this->fail();
|
||||||
|
} catch (QueryException) {
|
||||||
|
$this->assertTrue(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Event::assertNotDispatched(function (BeforeOrganizationDeletion $event) use ($otherOrganization): bool {
|
||||||
|
return $event->organization->is($otherOrganization->organization);
|
||||||
|
});
|
||||||
|
Event::assertDispatched(function (BeforeOrganizationDeletion $event) use ($organization): bool {
|
||||||
|
return $event->organization->is($organization->organization);
|
||||||
|
}, 1);
|
||||||
|
$this->assertOrganizationNothingDeleted($organization->organization);
|
||||||
|
$this->assertOrganizationNothingDeleted($otherOrganization->organization, true);
|
||||||
|
Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug'
|
||||||
|
&& $log->message === 'Start deleting organization'
|
||||||
|
&& $log->context['organization_id'] === $organization->organization->getKey(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
Log::assertNotLogged(fn (LogEntry $log) => $log->level === 'debug'
|
||||||
|
&& $log->message === 'Finished deleting organization'
|
||||||
|
&& $log->context['organization_id'] === $organization->organization->getKey()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_delete_user_fails_if_user_is_owner_of_an_organization_with_multiple_members(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$organization = $this->createOrganizationWithAllRelations();
|
||||||
|
$memberOwner = $organization->owner;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
$this->deletionService->deleteUser($memberOwner);
|
||||||
|
$this->fail();
|
||||||
|
} catch (CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers $exception) {
|
||||||
|
// Assert
|
||||||
|
$this->assertTrue(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_delete_user_rolls_back_on_error_if_transaction_is_active(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$memberOwner = Member::factory()->forUser($user)->forOrganization($organization)->role(Role::Owner)->create();
|
||||||
|
$otherOrganization = Organization::factory()->create();
|
||||||
|
|
||||||
|
$brokenTimeEntry = TimeEntry::factory()->forOrganization($otherOrganization)->forMember($memberOwner)->create();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
$this->deletionService->deleteUser($user);
|
||||||
|
$this->fail();
|
||||||
|
} catch (QueryException) {
|
||||||
|
$this->assertTrue(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertDatabaseHas(User::class, [
|
||||||
|
'id' => $user->getKey(),
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas(Organization::class, [
|
||||||
|
'id' => $organization->getKey(),
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas(Member::class, [
|
||||||
|
'id' => $memberOwner->getKey(),
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas(TimeEntry::class, [
|
||||||
|
'id' => $brokenTimeEntry->getKey(),
|
||||||
|
]);
|
||||||
|
Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug'
|
||||||
|
&& $log->message === 'Start deleting user'
|
||||||
|
&& $log->context['id'] === $user->getKey(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
Log::assertNotLogged(fn (LogEntry $log) => $log->level === 'debug'
|
||||||
|
&& $log->message === 'Finished deleting user'
|
||||||
|
&& $log->context['id'] === $user->getKey()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_delete_user_deletes_all_resources_of_the_user_but_does_not_delete_other_resources(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = User::factory()->withProfilePicture()->withPersonalOrganization()->create();
|
||||||
|
$otherUser = User::factory()->withProfilePicture()->withPersonalOrganization()->create();
|
||||||
|
Storage::disk('public')->assertExists($user->profile_photo_path);
|
||||||
|
Storage::disk('public')->assertExists($otherUser->profile_photo_path);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$this->deletionService->deleteUser($user);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertDatabaseMissing(User::class, [
|
||||||
|
'id' => $user->getKey(),
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas(User::class, [
|
||||||
|
'id' => $otherUser->getKey(),
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseMissing(Organization::class, [
|
||||||
|
'id' => $user->current_team_id,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas(Organization::class, [
|
||||||
|
'id' => $otherUser->current_team_id,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas(Member::class, [
|
||||||
|
'user_id' => $otherUser->getKey(),
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseMissing(Member::class, [
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
]);
|
||||||
|
Storage::disk('public')->assertMissing($user->profile_photo_path);
|
||||||
|
Storage::disk('public')->assertExists($otherUser->profile_photo_path);
|
||||||
|
Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug'
|
||||||
|
&& $log->message === 'Start deleting user'
|
||||||
|
&& $log->context['id'] === $user->getKey(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug'
|
||||||
|
&& $log->message === 'Finished deleting user'
|
||||||
|
&& $log->context['id'] === $user->getKey(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_delete_user_deletes_owned_organizations_that_have_only_one_member_and_makes_makes_the_user_placeholder_in_not_owned_organizations(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$organizationOwned = Organization::factory()->withOwner($user)->create();
|
||||||
|
$organizationNotOwned = Organization::factory()->create();
|
||||||
|
$memberOwned = Member::factory()->forUser($user)->forOrganization($organizationOwned)->role(Role::Owner)->create();
|
||||||
|
$memberNotOwned = Member::factory()->forUser($user)->forOrganization($organizationNotOwned)->role(Role::Employee)->create();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$this->deletionService->deleteUser($user);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertDatabaseMissing(User::class, [
|
||||||
|
'id' => $user->getKey(),
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseMissing(Organization::class, [
|
||||||
|
'id' => $organizationOwned->getKey(),
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas(Organization::class, [
|
||||||
|
'id' => $organizationNotOwned->getKey(),
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseMissing(Member::class, [
|
||||||
|
'id' => $memberOwned->getKey(),
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas(Member::class, [
|
||||||
|
'id' => $memberNotOwned->getKey(),
|
||||||
|
'organization_id' => $organizationNotOwned->getKey(),
|
||||||
|
'role' => Role::Placeholder->value,
|
||||||
|
]);
|
||||||
|
Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug'
|
||||||
|
&& $log->message === 'Start deleting user'
|
||||||
|
&& $log->context['id'] === $user->getKey(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug'
|
||||||
|
&& $log->message === 'Finished deleting user'
|
||||||
|
&& $log->context['id'] === $user->getKey(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,12 @@ use App\Models\Project;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Service\Import\ImportDatabaseHelper;
|
use App\Service\Import\ImportDatabaseHelper;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
#[CoversClass(ImportDatabaseHelper::class)]
|
||||||
|
#[UsesClass(ImportDatabaseHelper::class)]
|
||||||
class ImportDatabaseHelperTest extends TestCase
|
class ImportDatabaseHelperTest extends TestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|||||||
@@ -5,11 +5,17 @@ declare(strict_types=1);
|
|||||||
namespace Tests\Unit\Service\Import;
|
namespace Tests\Unit\Service\Import;
|
||||||
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
|
use App\Service\Import\Importers\ImporterProvider;
|
||||||
use App\Service\Import\ImportService;
|
use App\Service\Import\ImportService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
#[CoversClass(ImportService::class)]
|
||||||
|
#[CoversClass(ImporterProvider::class)]
|
||||||
|
#[UsesClass(ImportService::class)]
|
||||||
class ImportServiceTest extends TestCase
|
class ImportServiceTest extends TestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|||||||
@@ -2,12 +2,20 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Tests\Unit\Service\Import\Importer;
|
namespace Tests\Unit\Service\Import\Importers;
|
||||||
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Service\Import\Importers\ClockifyProjectsImporter;
|
use App\Service\Import\Importers\ClockifyProjectsImporter;
|
||||||
|
use App\Service\Import\Importers\DefaultImporter;
|
||||||
|
use App\Service\Import\Importers\ImportException;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
|
|
||||||
|
#[CoversClass(ClockifyProjectsImporter::class)]
|
||||||
|
#[CoversClass(ImportException::class)]
|
||||||
|
#[CoversClass(DefaultImporter::class)]
|
||||||
|
#[UsesClass(ClockifyProjectsImporter::class)]
|
||||||
class ClockifyProjectsImporterTest extends ImporterTestAbstract
|
class ClockifyProjectsImporterTest extends ImporterTestAbstract
|
||||||
{
|
{
|
||||||
public function test_import_of_test_file_succeeds(): void
|
public function test_import_of_test_file_succeeds(): void
|
||||||
@@ -2,13 +2,21 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Tests\Unit\Service\Import\Importer;
|
namespace Tests\Unit\Service\Import\Importers;
|
||||||
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
use App\Service\Import\Importers\ClockifyTimeEntriesImporter;
|
use App\Service\Import\Importers\ClockifyTimeEntriesImporter;
|
||||||
|
use App\Service\Import\Importers\DefaultImporter;
|
||||||
|
use App\Service\Import\Importers\ImportException;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
|
|
||||||
|
#[CoversClass(ClockifyTimeEntriesImporter::class)]
|
||||||
|
#[CoversClass(ImportException::class)]
|
||||||
|
#[CoversClass(DefaultImporter::class)]
|
||||||
|
#[UsesClass(ClockifyTimeEntriesImporter::class)]
|
||||||
class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
|
class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
|
||||||
{
|
{
|
||||||
public function test_import_of_test_file_succeeds(): void
|
public function test_import_of_test_file_succeeds(): void
|
||||||
46
tests/Unit/Service/Import/Importers/ImporterProviderTest.php
Normal file
46
tests/Unit/Service/Import/Importers/ImporterProviderTest.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit\Service\Import\Importers;
|
||||||
|
|
||||||
|
use App\Service\Import\Importers\ClockifyProjectsImporter;
|
||||||
|
use App\Service\Import\Importers\ImporterProvider;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
#[CoversClass(ImporterProvider::class)]
|
||||||
|
#[UsesClass(ImporterProvider::class)]
|
||||||
|
class ImporterProviderTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_register_importer_can_register_a_new_importer_for_example_in_an_extension(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$provider = new ImporterProvider();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$provider->registerImporter('some_provider_importer', ClockifyProjectsImporter::class);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$importer = $provider->getImporter('some_provider_importer');
|
||||||
|
$this->assertSame(ClockifyProjectsImporter::class, $importer::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_get_importer_keys_return_the_keys_of_the_available_importers(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$provider = new ImporterProvider();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$keys = $provider->getImporterKeys();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertSame([
|
||||||
|
'toggl_time_entries',
|
||||||
|
'toggl_data_importer',
|
||||||
|
'clockify_time_entries',
|
||||||
|
'clockify_projects',
|
||||||
|
], $keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Tests\Unit\Service\Import\Importer;
|
namespace Tests\Unit\Service\Import\Importers;
|
||||||
|
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
@@ -2,17 +2,24 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Tests\Unit\Service\Import\Importer;
|
namespace Tests\Unit\Service\Import\Importers;
|
||||||
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
|
use App\Service\Import\Importers\DefaultImporter;
|
||||||
use App\Service\Import\Importers\ImportException;
|
use App\Service\Import\Importers\ImportException;
|
||||||
use App\Service\Import\Importers\TogglDataImporter;
|
use App\Service\Import\Importers\TogglDataImporter;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||||
use ZipArchive;
|
use ZipArchive;
|
||||||
|
|
||||||
|
#[CoversClass(TogglDataImporter::class)]
|
||||||
|
#[CoversClass(ImportException::class)]
|
||||||
|
#[CoversClass(DefaultImporter::class)]
|
||||||
|
#[UsesClass(TogglDataImporter::class)]
|
||||||
class TogglDataImporterTest extends ImporterTestAbstract
|
class TogglDataImporterTest extends ImporterTestAbstract
|
||||||
{
|
{
|
||||||
private function createTestZip(string $folder): string
|
private function createTestZip(string $folder): string
|
||||||
@@ -2,13 +2,21 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Tests\Unit\Service\Import\Importer;
|
namespace Tests\Unit\Service\Import\Importers;
|
||||||
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
|
use App\Service\Import\Importers\DefaultImporter;
|
||||||
|
use App\Service\Import\Importers\ImportException;
|
||||||
use App\Service\Import\Importers\TogglTimeEntriesImporter;
|
use App\Service\Import\Importers\TogglTimeEntriesImporter;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
|
|
||||||
|
#[CoversClass(TogglTimeEntriesImporter::class)]
|
||||||
|
#[CoversClass(ImportException::class)]
|
||||||
|
#[CoversClass(DefaultImporter::class)]
|
||||||
|
#[UsesClass(TogglTimeEntriesImporter::class)]
|
||||||
class TogglTimeEntriesImporterTest extends ImporterTestAbstract
|
class TogglTimeEntriesImporterTest extends ImporterTestAbstract
|
||||||
{
|
{
|
||||||
public function test_import_of_test_file_succeeds(): void
|
public function test_import_of_test_file_succeeds(): void
|
||||||
@@ -10,8 +10,12 @@ use App\Models\User;
|
|||||||
use App\Service\PermissionStore;
|
use App\Service\PermissionStore;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Laravel\Jetstream\Jetstream;
|
use Laravel\Jetstream\Jetstream;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
#[CoversClass(PermissionStore::class)]
|
||||||
|
#[UsesClass(PermissionStore::class)]
|
||||||
class PermissionStoreTest extends TestCase
|
class PermissionStoreTest extends TestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ use App\Models\Project;
|
|||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
use App\Service\TimeEntryAggregationService;
|
use App\Service\TimeEntryAggregationService;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
use Tests\TestCaseWithDatabase;
|
use Tests\TestCaseWithDatabase;
|
||||||
|
|
||||||
|
#[CoversClass(TimeEntryAggregationService::class)]
|
||||||
|
#[UsesClass(TimeEntryAggregationService::class)]
|
||||||
class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
||||||
{
|
{
|
||||||
private TimeEntryAggregationService $service;
|
private TimeEntryAggregationService $service;
|
||||||
|
|||||||
@@ -8,9 +8,13 @@ use App\Models\User;
|
|||||||
use App\Service\TimezoneService;
|
use App\Service\TimezoneService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
use TiMacDonald\Log\LogEntry;
|
use TiMacDonald\Log\LogEntry;
|
||||||
|
|
||||||
|
#[CoversClass(TimezoneService::class)]
|
||||||
|
#[UsesClass(TimezoneService::class)]
|
||||||
class TimezoneServiceTest extends TestCase
|
class TimezoneServiceTest extends TestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|||||||
@@ -13,12 +13,24 @@ use App\Models\TimeEntry;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Service\UserService;
|
use App\Service\UserService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
#[CoversClass(UserService::class)]
|
||||||
|
#[UsesClass(UserService::class)]
|
||||||
class UserServiceTest extends TestCase
|
class UserServiceTest extends TestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private UserService $userService;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->userService = app(UserService::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_assign_organization_entities_to_different_user(): void
|
public function test_assign_organization_entities_to_different_user(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -36,9 +48,7 @@ class UserServiceTest extends TestCase
|
|||||||
ProjectMember::factory()->forProject($project)->forMember($fromUserMember)->create();
|
ProjectMember::factory()->forProject($project)->forMember($fromUserMember)->create();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
/** @var UserService $userService */
|
$this->userService->assignOrganizationEntitiesToDifferentUser($organization, $fromUser, $toUser);
|
||||||
$userService = app(UserService::class);
|
|
||||||
$userService->assignOrganizationEntitiesToDifferentUser($organization, $fromUser, $toUser);
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$this->assertSame(3, TimeEntry::query()->whereBelongsTo($toUser, 'user')->count());
|
$this->assertSame(3, TimeEntry::query()->whereBelongsTo($toUser, 'user')->count());
|
||||||
@@ -49,6 +59,22 @@ class UserServiceTest extends TestCase
|
|||||||
$this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUser, 'user')->count());
|
$this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUser, 'user')->count());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_assign_organization_entities_to_different_user_fails_if_new_user_is_not_member_of_organization(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$fromUser = User::factory()->create();
|
||||||
|
$toUser = User::factory()->create();
|
||||||
|
$fromUserMember = Member::factory()->forOrganization($organization)->forUser($fromUser)->create();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
$this->userService->assignOrganizationEntitiesToDifferentUser($organization, $fromUser, $toUser);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
$this->assertSame('User is not a member of the organization', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function test_change_ownership_changes_ownership_of_organization_to_new_user(): void
|
public function test_change_ownership_changes_ownership_of_organization_to_new_user(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -63,13 +89,116 @@ class UserServiceTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
/** @var UserService $userService */
|
$this->userService->changeOwnership($organization, $newOwner);
|
||||||
$userService = app(UserService::class);
|
|
||||||
$userService->changeOwnership($organization, $newOwner);
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$this->assertSame($newOwner->getKey(), $organization->refresh()->user_id);
|
$this->assertSame($newOwner->getKey(), $organization->refresh()->user_id);
|
||||||
$this->assertSame(Role::Owner->value, Member::whereBelongsTo($newOwner)->whereBelongsTo($organization)->firstOrFail()->role);
|
$this->assertSame(Role::Owner->value, Member::whereBelongsTo($newOwner)->whereBelongsTo($organization)->firstOrFail()->role);
|
||||||
$this->assertSame(Role::Admin->value, Member::whereBelongsTo($oldOwner)->whereBelongsTo($organization)->firstOrFail()->role);
|
$this->assertSame(Role::Admin->value, Member::whereBelongsTo($oldOwner)->whereBelongsTo($organization)->firstOrFail()->role);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_change_ownership_fails_if_new_user_is_not_member_of_organization(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$newOwner = User::factory()->create();
|
||||||
|
$oldOwner = User::factory()->create();
|
||||||
|
$organization->users()->attach($oldOwner->getKey(), [
|
||||||
|
'role' => Role::Owner->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
$this->userService->changeOwnership($organization, $newOwner);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
$this->assertSame('User is not a member of the organization', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_make_member_to_placeholder_creates_new_user_based_on_member_and_changes_member_to_placeholder(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$member = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create();
|
||||||
|
$timeEntry = TimeEntry::factory()->forOrganization($organization)->forMember($member)->create();
|
||||||
|
$project = Project::factory()->forOrganization($organization)->create();
|
||||||
|
$projectMember = ProjectMember::factory()->forProject($project)->forMember($member)->create();
|
||||||
|
// Note: create other user, organization, member, time entry and project member to check that they are not changed
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
$otherOrganization = Organization::factory()->create();
|
||||||
|
$otherMember = Member::factory()->forOrganization($otherOrganization)->forUser($otherUser)->role(Role::Employee)->create();
|
||||||
|
$otherTimeEntry = TimeEntry::factory()->forOrganization($otherOrganization)->forMember($otherMember)->create();
|
||||||
|
$otherProject = Project::factory()->forOrganization($otherOrganization)->create();
|
||||||
|
$otherProjectMember = ProjectMember::factory()->forProject($otherProject)->forMember($otherMember)->create();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$this->userService->makeMemberToPlaceholder($member);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$member->refresh();
|
||||||
|
$timeEntry->refresh();
|
||||||
|
$projectMember->refresh();
|
||||||
|
$placeholderUser = $member->user;
|
||||||
|
$this->assertTrue($placeholderUser->is_placeholder);
|
||||||
|
$this->assertSame(Role::Placeholder->value, $member->role);
|
||||||
|
$this->assertSame($organization->getKey(), $member->organization_id);
|
||||||
|
$this->assertSame($placeholderUser->getKey(), $projectMember->user_id);
|
||||||
|
$this->assertSame($member->getKey(), $projectMember->member_id);
|
||||||
|
$this->assertSame($placeholderUser->getKey(), $timeEntry->user_id);
|
||||||
|
$this->assertSame($member->getKey(), $timeEntry->member_id);
|
||||||
|
$this->assertSame(1, $user->organizations()->count());
|
||||||
|
// Note: check that other user did not change
|
||||||
|
$otherMember->refresh();
|
||||||
|
$otherTimeEntry->refresh();
|
||||||
|
$otherProjectMember->refresh();
|
||||||
|
$otherUser->refresh();
|
||||||
|
$this->assertFalse($otherUser->is_placeholder);
|
||||||
|
$this->assertSame(Role::Employee->value, $otherMember->role);
|
||||||
|
$this->assertSame($otherOrganization->getKey(), $otherMember->organization_id);
|
||||||
|
$this->assertSame($otherUser->getKey(), $otherProjectMember->user_id);
|
||||||
|
$this->assertSame($otherMember->getKey(), $otherProjectMember->member_id);
|
||||||
|
$this->assertSame($otherUser->getKey(), $otherTimeEntry->user_id);
|
||||||
|
$this->assertSame($otherMember->getKey(), $otherTimeEntry->member_id);
|
||||||
|
$this->assertSame(1, $otherUser->organizations()->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_make_sure_user_has_current_organization_sets_current_organization_for_user_if_null(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$otherOrganization = Organization::factory()->create();
|
||||||
|
Member::factory()->forUser($user)->forOrganization($organization)->create();
|
||||||
|
$user->current_team_id = null;
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$this->userService->makeSureUserHasCurrentOrganization($user);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertSame($organization->getKey(), $user->refresh()->currentOrganization->getKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function make_sure_user_has_at_least_one_organization_creates_organization_for_user_if_there_are_not_member_of_one(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$user->refresh();
|
||||||
|
$this->assertSame(1, $user->organizations()->count());
|
||||||
|
$newOrganization = $user->organizations()->first();
|
||||||
|
$this->assertNotSame($organization->getKey(), $newOrganization->getKey());
|
||||||
|
$this->assertSame($user->name."'s Organization", $newOrganization->name);
|
||||||
|
$this->assertTrue($newOrganization->personal_team);
|
||||||
|
$this->assertSame($user->getKey(), $newOrganization->user_id);
|
||||||
|
$newMember = Member::whereBelongsTo($user)->whereBelongsTo($newOrganization)->firstOrFail();
|
||||||
|
$this->assertSame(Role::Owner->value, $newMember->role);
|
||||||
|
$this->assertSame($newOrganization->getKey(), $user->currentOrganization->getKey());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user