mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Added placeholder users; Better exception handling; Enhanced local setup
This commit is contained in:
@@ -37,7 +37,7 @@ MAIL_PORT=1025
|
|||||||
MAIL_USERNAME=null
|
MAIL_USERNAME=null
|
||||||
MAIL_PASSWORD=null
|
MAIL_PASSWORD=null
|
||||||
MAIL_ENCRYPTION=null
|
MAIL_ENCRYPTION=null
|
||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
AWS_ACCESS_KEY_ID=
|
AWS_ACCESS_KEY_ID=
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -26,3 +26,4 @@ yarn-error.log
|
|||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
/coverage
|
/coverage
|
||||||
|
/extensions/*
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ Add the following entry to your `/etc/hosts`
|
|||||||
```
|
```
|
||||||
127.0.0.1 solidtime.test
|
127.0.0.1 solidtime.test
|
||||||
127.0.0.1 playwright.solidtime.test
|
127.0.0.1 playwright.solidtime.test
|
||||||
|
127.0.0.1 mail.solidtime.test
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running E2E Tests
|
## Running E2E Tests
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ namespace App\Actions\Fortify;
|
|||||||
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||||
use Laravel\Jetstream\Jetstream;
|
use Laravel\Jetstream\Jetstream;
|
||||||
|
|
||||||
@@ -20,12 +23,27 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
* Create a newly registered user.
|
* Create a newly registered user.
|
||||||
*
|
*
|
||||||
* @param array<string, string> $input
|
* @param array<string, string> $input
|
||||||
|
*
|
||||||
|
* @throws ValidationException
|
||||||
*/
|
*/
|
||||||
public function create(array $input): User
|
public function create(array $input): User
|
||||||
{
|
{
|
||||||
Validator::make($input, [
|
Validator::make($input, [
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => [
|
||||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
|
'required',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
],
|
||||||
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
new UniqueEloquent(User::class, 'email', function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<User> $builder */
|
||||||
|
return $builder->where('is_placeholder', '=', false);
|
||||||
|
}),
|
||||||
|
],
|
||||||
'password' => $this->passwordRules(),
|
'password' => $this->passwordRules(),
|
||||||
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',
|
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',
|
||||||
])->validate();
|
])->validate();
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ use App\Models\Organization;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Contracts\Validation\Rule;
|
use Illuminate\Contracts\Validation\Rule;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||||
use Laravel\Jetstream\Contracts\AddsTeamMembers;
|
use Laravel\Jetstream\Contracts\AddsTeamMembers;
|
||||||
use Laravel\Jetstream\Events\AddingTeamMember;
|
use Laravel\Jetstream\Events\AddingTeamMember;
|
||||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
use Laravel\Jetstream\Events\TeamMemberAdded;
|
||||||
@@ -21,21 +23,24 @@ class AddOrganizationMember implements AddsTeamMembers
|
|||||||
/**
|
/**
|
||||||
* Add a new team member to the given team.
|
* Add a new team member to the given team.
|
||||||
*/
|
*/
|
||||||
public function add(User $user, Organization $organization, string $email, ?string $role = null): void
|
public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
|
||||||
{
|
{
|
||||||
Gate::forUser($user)->authorize('addTeamMember', $organization);
|
Gate::forUser($owner)->authorize('addTeamMember', $organization);
|
||||||
|
|
||||||
$this->validate($organization, $email, $role);
|
$this->validate($organization, $email, $role);
|
||||||
|
|
||||||
$newTeamMember = Jetstream::findUserByEmailOrFail($email);
|
$newOrganizationMember = User::query()
|
||||||
|
->where('email', $email)
|
||||||
|
->where('is_placeholder', '=', false)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
AddingTeamMember::dispatch($organization, $newTeamMember);
|
AddingTeamMember::dispatch($organization, $newOrganizationMember);
|
||||||
|
|
||||||
$organization->users()->attach(
|
$organization->users()->attach(
|
||||||
$newTeamMember, ['role' => $role]
|
$newOrganizationMember, ['role' => $role]
|
||||||
);
|
);
|
||||||
|
|
||||||
TeamMemberAdded::dispatch($organization, $newTeamMember);
|
TeamMemberAdded::dispatch($organization, $newOrganizationMember);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,9 +51,7 @@ class AddOrganizationMember implements AddsTeamMembers
|
|||||||
Validator::make([
|
Validator::make([
|
||||||
'email' => $email,
|
'email' => $email,
|
||||||
'role' => $role,
|
'role' => $role,
|
||||||
], $this->rules(), [
|
], $this->rules())->after(
|
||||||
'email.exists' => __('We were unable to find a registered user with this email address.'),
|
|
||||||
])->after(
|
|
||||||
$this->ensureUserIsNotAlreadyOnTeam($organization, $email)
|
$this->ensureUserIsNotAlreadyOnTeam($organization, $email)
|
||||||
)->validateWithBag('addTeamMember');
|
)->validateWithBag('addTeamMember');
|
||||||
}
|
}
|
||||||
@@ -61,7 +64,13 @@ class AddOrganizationMember implements AddsTeamMembers
|
|||||||
protected function rules(): array
|
protected function rules(): array
|
||||||
{
|
{
|
||||||
return array_filter([
|
return array_filter([
|
||||||
'email' => ['required', 'email', 'exists:users'],
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'email',
|
||||||
|
(new ExistsEloquent(User::class, 'email', function (Builder $builder) {
|
||||||
|
return $builder->where('is_placeholder', '=', false);
|
||||||
|
}))->withMessage(__('We were unable to find a registered user with this email address.')),
|
||||||
|
],
|
||||||
'role' => Jetstream::hasRoles()
|
'role' => Jetstream::hasRoles()
|
||||||
? ['required', 'string', new Role]
|
? ['required', 'string', new Role]
|
||||||
: null,
|
: null,
|
||||||
@@ -75,7 +84,7 @@ class AddOrganizationMember implements AddsTeamMembers
|
|||||||
{
|
{
|
||||||
return function ($validator) use ($team, $email) {
|
return function ($validator) use ($team, $email) {
|
||||||
$validator->errors()->addIf(
|
$validator->errors()->addIf(
|
||||||
$team->hasUserWithEmail($email),
|
$team->hasRealUserWithEmail($email),
|
||||||
'email',
|
'email',
|
||||||
__('This user already belongs to the team.')
|
__('This user already belongs to the team.')
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class InviteOrganizationMember implements InvitesTeamMembers
|
|||||||
|
|
||||||
InvitingTeamMember::dispatch($organization, $email, $role);
|
InvitingTeamMember::dispatch($organization, $email, $role);
|
||||||
|
|
||||||
|
/** @var TeamInvitation $invitation */
|
||||||
$invitation = $organization->teamInvitations()->create([
|
$invitation = $organization->teamInvitations()->create([
|
||||||
'email' => $email,
|
'email' => $email,
|
||||||
'role' => $role,
|
'role' => $role,
|
||||||
@@ -50,9 +51,7 @@ class InviteOrganizationMember implements InvitesTeamMembers
|
|||||||
Validator::make([
|
Validator::make([
|
||||||
'email' => $email,
|
'email' => $email,
|
||||||
'role' => $role,
|
'role' => $role,
|
||||||
], $this->rules($organization), [
|
], $this->rules($organization))->after(
|
||||||
'email.unique' => __('This user has already been invited to the team.'),
|
|
||||||
])->after(
|
|
||||||
$this->ensureUserIsNotAlreadyOnTeam($organization, $email)
|
$this->ensureUserIsNotAlreadyOnTeam($organization, $email)
|
||||||
)->validateWithBag('addTeamMember');
|
)->validateWithBag('addTeamMember');
|
||||||
}
|
}
|
||||||
@@ -68,10 +67,10 @@ class InviteOrganizationMember implements InvitesTeamMembers
|
|||||||
'email' => [
|
'email' => [
|
||||||
'required',
|
'required',
|
||||||
'email',
|
'email',
|
||||||
new UniqueEloquent(OrganizationInvitation::class, 'email', function (Builder $builder) use ($organization) {
|
(new UniqueEloquent(OrganizationInvitation::class, 'email', function (Builder $builder) use ($organization) {
|
||||||
/** @var Builder<OrganizationInvitation> $builder */
|
/** @var Builder<OrganizationInvitation> $builder */
|
||||||
return $builder->whereBelongsTo($organization, 'organization');
|
return $builder->whereBelongsTo($organization, 'organization');
|
||||||
}),
|
}))->withMessage(__('This user has already been invited to the team.')),
|
||||||
],
|
],
|
||||||
'role' => Jetstream::hasRoles()
|
'role' => Jetstream::hasRoles()
|
||||||
? ['required', 'string', new Role]
|
? ['required', 'string', new Role]
|
||||||
@@ -86,7 +85,7 @@ class InviteOrganizationMember implements InvitesTeamMembers
|
|||||||
{
|
{
|
||||||
return function ($validator) use ($organization, $email) {
|
return function ($validator) use ($organization, $email) {
|
||||||
$validator->errors()->addIf(
|
$validator->errors()->addIf(
|
||||||
$organization->hasUserWithEmail($email),
|
$organization->hasRealUserWithEmail($email),
|
||||||
'email',
|
'email',
|
||||||
__('This user already belongs to the team.')
|
__('This user already belongs to the team.')
|
||||||
);
|
);
|
||||||
|
|||||||
46
app/Exceptions/Api/ApiException.php
Normal file
46
app/Exceptions/Api/ApiException.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions\Api;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use LogicException;
|
||||||
|
|
||||||
|
abstract class ApiException extends Exception
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Render the exception into an HTTP response.
|
||||||
|
*/
|
||||||
|
public function render(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
return response()
|
||||||
|
->json([
|
||||||
|
'error' => true,
|
||||||
|
'key' => $this->getKey(),
|
||||||
|
'message' => $this->getTranslatedMessage(),
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the key for the exception.
|
||||||
|
*/
|
||||||
|
public function getKey(): string
|
||||||
|
{
|
||||||
|
if (defined(static::class.'::KEY')) {
|
||||||
|
return static::KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new LogicException('API exceptions need the KEY constant defined.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the translated message for the exception.
|
||||||
|
*/
|
||||||
|
public function getTranslatedMessage(): string
|
||||||
|
{
|
||||||
|
return __('exceptions.api.'.$this->getKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/Exceptions/Api/TimeEntryStillRunningApiException.php
Normal file
10
app/Exceptions/Api/TimeEntryStillRunningApiException.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions\Api;
|
||||||
|
|
||||||
|
class TimeEntryStillRunningApiException extends ApiException
|
||||||
|
{
|
||||||
|
const string KEY = 'time_entry_still_running';
|
||||||
|
}
|
||||||
10
app/Exceptions/Api/UserNotPlaceholderApiException.php
Normal file
10
app/Exceptions/Api/UserNotPlaceholderApiException.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions\Api;
|
||||||
|
|
||||||
|
class UserNotPlaceholderApiException extends ApiException
|
||||||
|
{
|
||||||
|
const string KEY = 'user_not_placeholder';
|
||||||
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Exceptions;
|
|
||||||
|
|
||||||
use Exception;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class ApiException extends Exception
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Render the exception into an HTTP response.
|
|
||||||
*/
|
|
||||||
public function render(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
return response()
|
|
||||||
->json([
|
|
||||||
'error' => true,
|
|
||||||
'message' => $this->getMessage(),
|
|
||||||
], 400);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Exceptions;
|
|
||||||
|
|
||||||
class TimeEntryStillRunning extends ApiException
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Exceptions\TimeEntryStillRunning;
|
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||||
use App\Http\Requests\V1\TimeEntry\TimeEntryIndexRequest;
|
use App\Http\Requests\V1\TimeEntry\TimeEntryIndexRequest;
|
||||||
use App\Http\Requests\V1\TimeEntry\TimeEntryStoreRequest;
|
use App\Http\Requests\V1\TimeEntry\TimeEntryStoreRequest;
|
||||||
use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateRequest;
|
use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateRequest;
|
||||||
@@ -102,7 +102,7 @@ class TimeEntryController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Create time entry
|
* Create time entry
|
||||||
*
|
*
|
||||||
* @throws AuthorizationException|TimeEntryStillRunning
|
* @throws AuthorizationException|TimeEntryStillRunningApiException
|
||||||
*/
|
*/
|
||||||
public function store(Organization $organization, TimeEntryStoreRequest $request): JsonResource
|
public function store(Organization $organization, TimeEntryStoreRequest $request): JsonResource
|
||||||
{
|
{
|
||||||
@@ -114,8 +114,7 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
if ($request->get('end') === null && TimeEntry::query()->where('user_id', $request->get('user_id'))->where('end', null)->exists()) {
|
if ($request->get('end') === null && TimeEntry::query()->where('user_id', $request->get('user_id'))->where('end', null)->exists()) {
|
||||||
// TODO: API documentation
|
// TODO: API documentation
|
||||||
// TODO: Create concept for api exceptions
|
throw new TimeEntryStillRunningApiException();
|
||||||
throw new TimeEntryStillRunning('User already has an active time entry');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$timeEntry = new TimeEntry();
|
$timeEntry = new TimeEntry();
|
||||||
|
|||||||
56
app/Http/Controllers/Api/V1/UserController.php
Normal file
56
app/Http/Controllers/Api/V1/UserController.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Exceptions\Api\UserNotPlaceholderApiException;
|
||||||
|
use App\Http\Requests\V1\User\UserIndexRequest;
|
||||||
|
use App\Http\Resources\V1\User\UserCollection;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
|
||||||
|
|
||||||
|
class UserController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* List all users in an organization
|
||||||
|
*
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*/
|
||||||
|
public function index(Organization $organization, UserIndexRequest $request): UserCollection
|
||||||
|
{
|
||||||
|
$this->checkPermission($organization, 'users:view');
|
||||||
|
|
||||||
|
$users = $organization->users()
|
||||||
|
->paginate();
|
||||||
|
|
||||||
|
return UserCollection::make($users);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invite a placeholder user to become a real user in the organization
|
||||||
|
*
|
||||||
|
* @throws AuthorizationException|UserNotPlaceholderApiException
|
||||||
|
*/
|
||||||
|
public function invitePlaceholder(Organization $organization, User $user, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->checkPermission($organization, 'users:invite-placeholder');
|
||||||
|
|
||||||
|
if (! $user->is_placeholder) {
|
||||||
|
throw new UserNotPlaceholderApiException();
|
||||||
|
}
|
||||||
|
|
||||||
|
app(InvitesTeamMembers::class)->invite(
|
||||||
|
$request->user(),
|
||||||
|
$organization,
|
||||||
|
$user->email,
|
||||||
|
'employee'
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($user);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Http/Requests/V1/User/UserIndexRequest.php
Normal file
26
app/Http/Requests/V1/User/UserIndexRequest.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\V1\User;
|
||||||
|
|
||||||
|
use App\Models\Organization;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property Organization $organization
|
||||||
|
*/
|
||||||
|
class UserIndexRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, array<string|ValidationRule>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/Http/Resources/V1/User/UserCollection.php
Normal file
17
app/Http/Resources/V1/User/UserCollection.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources\V1\User;
|
||||||
|
|
||||||
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
|
|
||||||
|
class UserCollection extends ResourceCollection
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The resource that this resource collects.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
public $collects = UserResource::class;
|
||||||
|
}
|
||||||
40
app/Http/Resources/V1/User/UserResource.php
Normal file
40
app/Http/Resources/V1/User/UserResource.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources\V1\User;
|
||||||
|
|
||||||
|
use App\Http\Resources\V1\BaseResource;
|
||||||
|
use App\Models\Membership;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property User $resource
|
||||||
|
*/
|
||||||
|
class UserResource extends BaseResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*
|
||||||
|
* @return array<string, string|bool|int|null|array<string>>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
/** @var Membership $membership */
|
||||||
|
$membership = $this->resource->getRelationValue('membership');
|
||||||
|
|
||||||
|
return [
|
||||||
|
/** @var string $id ID */
|
||||||
|
'id' => $this->resource->id,
|
||||||
|
/** @var string $name Name */
|
||||||
|
'name' => $this->resource->name,
|
||||||
|
/** @var string $email Email */
|
||||||
|
'email' => $this->resource->email,
|
||||||
|
/** @var string $role Role */
|
||||||
|
'role' => $membership->role,
|
||||||
|
/** @var bool $is_placeholder Placeholder user for imports, user might not really exist and does not know about this placeholder membership */
|
||||||
|
'is_placeholder' => $this->resource->is_placeholder,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Listeners/RemovePlaceholder.php
Normal file
30
app/Listeners/RemovePlaceholder.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Service\UserService;
|
||||||
|
use Laravel\Jetstream\Events\TeamMemberAdded;
|
||||||
|
|
||||||
|
class RemovePlaceholder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle the event.
|
||||||
|
*/
|
||||||
|
public function handle(TeamMemberAdded $event): void
|
||||||
|
{
|
||||||
|
/** @var UserService $userService */
|
||||||
|
$userService = app(UserService::class);
|
||||||
|
$placeholders = User::query()
|
||||||
|
->where('is_placeholder', '=', true)
|
||||||
|
->where('email', '=', $event->user->email)
|
||||||
|
->belongsToOrganization($event->team)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($placeholders as $placeholder) {
|
||||||
|
$userService->assignOrganizationEntitiesToDifferentUser($event->team, $placeholder, $event->user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ use Database\Factories\OrganizationFactory;
|
|||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Laravel\Jetstream\Events\TeamCreated;
|
use Laravel\Jetstream\Events\TeamCreated;
|
||||||
use Laravel\Jetstream\Events\TeamDeleted;
|
use Laravel\Jetstream\Events\TeamDeleted;
|
||||||
@@ -59,4 +60,30 @@ class Organization extends JetstreamTeam
|
|||||||
'updated' => TeamUpdated::class,
|
'updated' => TeamUpdated::class,
|
||||||
'deleted' => TeamDeleted::class,
|
'deleted' => TeamDeleted::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all the non-placeholder users of the organization including its owner.
|
||||||
|
*
|
||||||
|
* @return Collection<User>
|
||||||
|
*/
|
||||||
|
public function allRealUsers(): Collection
|
||||||
|
{
|
||||||
|
return $this->realUsers->merge([$this->owner]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasRealUserWithEmail(string $email): bool
|
||||||
|
{
|
||||||
|
return $this->allRealUsers()->contains(function (User $user) use ($email): bool {
|
||||||
|
return $user->email === $email;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsToMany<User>
|
||||||
|
*/
|
||||||
|
public function realUsers(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->users()
|
||||||
|
->where('is_placeholder', false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Database\Factories\UserFactory;
|
use Database\Factories\UserFactory;
|
||||||
use Filament\Panel;
|
use Filament\Panel;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
@@ -21,9 +23,16 @@ use Laravel\Passport\HasApiTokens;
|
|||||||
* @property string $id
|
* @property string $id
|
||||||
* @property string $name
|
* @property string $name
|
||||||
* @property string $email
|
* @property string $email
|
||||||
|
* @property string|null $email_verified_at
|
||||||
|
* @property string|null $password
|
||||||
|
* @property bool $is_placeholder
|
||||||
|
* @property Collection<Organization> $organizations
|
||||||
|
* @property Collection<TimeEntry> $timeEntries
|
||||||
*
|
*
|
||||||
* @method HasMany<Organization> ownedTeams()
|
* @method HasMany<Organization> ownedTeams()
|
||||||
* @method static UserFactory factory()
|
* @method static UserFactory factory()
|
||||||
|
* @method static Builder<User> query()
|
||||||
|
* @method Builder<User> belongsToOrganization(Organization $organization)
|
||||||
*/
|
*/
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
@@ -97,4 +106,27 @@ class User extends Authenticatable
|
|||||||
->withTimestamps()
|
->withTimestamps()
|
||||||
->as('membership');
|
->as('membership');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return HasMany<TimeEntry>
|
||||||
|
*/
|
||||||
|
public function timeEntries(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(TimeEntry::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Builder<User> $builder
|
||||||
|
* @return Builder<User>
|
||||||
|
*/
|
||||||
|
public function scopeBelongsToOrganization(Builder $builder, Organization $organization): Builder
|
||||||
|
{
|
||||||
|
return $builder->where(function (Builder $builder) use ($organization): Builder {
|
||||||
|
return $builder->whereHas('organizations', function (Builder $query) use ($organization): void {
|
||||||
|
$query->whereKey($organization->getKey());
|
||||||
|
})->orWhereHas('ownedTeams', function (Builder $query) use ($organization): void {
|
||||||
|
$query->whereKey($organization->getKey());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Listeners\RemovePlaceholder;
|
||||||
use Illuminate\Auth\Events\Registered;
|
use Illuminate\Auth\Events\Registered;
|
||||||
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
||||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Laravel\Jetstream\Events\TeamMemberAdded;
|
||||||
|
|
||||||
class EventServiceProvider extends ServiceProvider
|
class EventServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -20,6 +21,9 @@ class EventServiceProvider extends ServiceProvider
|
|||||||
Registered::class => [
|
Registered::class => [
|
||||||
SendEmailVerificationNotification::class,
|
SendEmailVerificationNotification::class,
|
||||||
],
|
],
|
||||||
|
TeamMemberAdded::class => [
|
||||||
|
RemovePlaceholder::class,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'organizations:view',
|
'organizations:view',
|
||||||
'organizations:update',
|
'organizations:update',
|
||||||
'import',
|
'import',
|
||||||
|
'users:invite-placeholder',
|
||||||
|
'users:view',
|
||||||
])->description('Administrator users can perform any action.');
|
])->description('Administrator users can perform any action.');
|
||||||
|
|
||||||
Jetstream::role('manager', 'Manager', [
|
Jetstream::role('manager', 'Manager', [
|
||||||
@@ -95,6 +97,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'tags:update',
|
'tags:update',
|
||||||
'tags:delete',
|
'tags:delete',
|
||||||
'organizations:view',
|
'organizations:view',
|
||||||
|
'users:view',
|
||||||
])->description('Editor users have the ability to read, create, and update.');
|
])->description('Editor users have the ability to read, create, and update.');
|
||||||
|
|
||||||
Jetstream::role('employee', 'Employee', [
|
Jetstream::role('employee', 'Employee', [
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ namespace App\Service\Import\Importers;
|
|||||||
|
|
||||||
class ImporterProvider
|
class ImporterProvider
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @var array<string, class-string<ImporterContract>>
|
||||||
|
*/
|
||||||
private array $importers = [
|
private array $importers = [
|
||||||
'toggl_time_entries' => TogglTimeEntriesImporter::class,
|
'toggl_time_entries' => TogglTimeEntriesImporter::class,
|
||||||
];
|
];
|
||||||
|
|||||||
23
app/Service/UserService.php
Normal file
23
app/Service/UserService.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\TimeEntry;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class UserService
|
||||||
|
{
|
||||||
|
public function assignOrganizationEntitiesToDifferentUser(Organization $organization, User $fromUser, User $toUser): void
|
||||||
|
{
|
||||||
|
// Time entries
|
||||||
|
dump(TimeEntry::query()
|
||||||
|
->whereBelongsTo($organization, 'organization')
|
||||||
|
->whereBelongsTo($fromUser, 'user')
|
||||||
|
->update([
|
||||||
|
'user_id' => $toUser->getKey(),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -155,7 +155,7 @@ return [
|
|||||||
|
|
||||||
Watchers\LogWatcher::class => [
|
Watchers\LogWatcher::class => [
|
||||||
'enabled' => env('TELESCOPE_LOG_WATCHER', true),
|
'enabled' => env('TELESCOPE_LOG_WATCHER', true),
|
||||||
'level' => 'error',
|
'level' => 'debug',
|
||||||
],
|
],
|
||||||
|
|
||||||
Watchers\MailWatcher::class => env('TELESCOPE_MAIL_WATCHER', true),
|
Watchers\MailWatcher::class => env('TELESCOPE_MAIL_WATCHER', true),
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ class OrganizationFactory extends Factory
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function withOwner(): self
|
public function withOwner(?User $owner = null): self
|
||||||
{
|
{
|
||||||
return $this->state(fn (array $attributes) => [
|
return $this->state(fn (array $attributes) => [
|
||||||
'user_id' => User::factory(),
|
'user_id' => $owner === null ? User::factory() : $owner,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,9 +31,19 @@ class UserFactory extends Factory
|
|||||||
'remember_token' => Str::random(10),
|
'remember_token' => Str::random(10),
|
||||||
'profile_photo_path' => null,
|
'profile_photo_path' => null,
|
||||||
'current_team_id' => null,
|
'current_team_id' => null,
|
||||||
|
'is_placeholder' => false,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function placeholder(bool $placeholder = true): static
|
||||||
|
{
|
||||||
|
return $this->state(function (array $attributes) use ($placeholder): array {
|
||||||
|
return [
|
||||||
|
'is_placeholder' => $placeholder,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Indicate that the model's email address should be unverified.
|
* Indicate that the model's email address should be unverified.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -22,31 +22,57 @@ class DatabaseSeeder extends Seeder
|
|||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$this->deleteAll();
|
$this->deleteAll();
|
||||||
$organization1 = Organization::factory()->create([
|
$userAcmeOwner = User::factory()->create([
|
||||||
|
'name' => 'ACME Admin',
|
||||||
|
'email' => 'owner@acme.test',
|
||||||
|
]);
|
||||||
|
$organizationAcme = Organization::factory()->withOwner($userAcmeOwner)->create([
|
||||||
'name' => 'ACME Corp',
|
'name' => 'ACME Corp',
|
||||||
]);
|
]);
|
||||||
$user1 = User::factory()->withPersonalOrganization()->create([
|
$userAcmeManager = User::factory()->withPersonalOrganization()->create([
|
||||||
'name' => 'Test User',
|
'name' => 'Test User',
|
||||||
'email' => 'test@example.com',
|
'email' => 'test@example.com',
|
||||||
]);
|
]);
|
||||||
$employee1 = User::factory()->withPersonalOrganization()->create([
|
$userAcmeAdmin = User::factory()->withPersonalOrganization()->create([
|
||||||
'name' => 'Test User',
|
|
||||||
'email' => 'employee@example.com',
|
|
||||||
]);
|
|
||||||
$userAcmeAdmin = User::factory()->create([
|
|
||||||
'name' => 'ACME Admin',
|
'name' => 'ACME Admin',
|
||||||
'email' => 'admin@acme.test',
|
'email' => 'admin@acme.test',
|
||||||
]);
|
]);
|
||||||
$user1->organizations()->attach($organization1, [
|
$userAcmeEmployee = User::factory()->withPersonalOrganization()->create([
|
||||||
|
'name' => 'Max Mustermann',
|
||||||
|
'email' => 'max.mustermann@acme.test',
|
||||||
|
]);
|
||||||
|
$userAcmePlaceholder = User::factory()->placeholder()->create([
|
||||||
|
'name' => 'Old Employee',
|
||||||
|
'email' => 'old.employee@acme.test',
|
||||||
|
'password' => null,
|
||||||
|
]);
|
||||||
|
$userAcmeManager->organizations()->attach($organizationAcme, [
|
||||||
'role' => 'manager',
|
'role' => 'manager',
|
||||||
]);
|
]);
|
||||||
$userAcmeAdmin->organizations()->attach($organization1, [
|
$userAcmeAdmin->organizations()->attach($organizationAcme, [
|
||||||
'role' => 'admin',
|
'role' => 'admin',
|
||||||
]);
|
]);
|
||||||
$timeEntriesEmployees = TimeEntry::factory()
|
$userAcmeEmployee->organizations()->attach($organizationAcme, [
|
||||||
|
'role' => 'employee',
|
||||||
|
]);
|
||||||
|
$userAcmePlaceholder->organizations()->attach($organizationAcme, [
|
||||||
|
'role' => 'employee',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$timeEntriesAcmeAdmin = TimeEntry::factory()
|
||||||
->count(10)
|
->count(10)
|
||||||
->forUser($employee1)
|
->forUser($userAcmeAdmin)
|
||||||
->forOrganization($organization1)
|
->forOrganization($organizationAcme)
|
||||||
|
->create();
|
||||||
|
$timeEntriesAcmePlaceholder = TimeEntry::factory()
|
||||||
|
->count(10)
|
||||||
|
->forUser($userAcmePlaceholder)
|
||||||
|
->forOrganization($organizationAcme)
|
||||||
|
->create();
|
||||||
|
$timeEntriesAcmePlaceholder = TimeEntry::factory()
|
||||||
|
->count(10)
|
||||||
|
->forUser($userAcmeEmployee)
|
||||||
|
->forOrganization($organizationAcme)
|
||||||
->create();
|
->create();
|
||||||
$client = Client::factory()->create([
|
$client = Client::factory()->create([
|
||||||
'name' => 'Big Company',
|
'name' => 'Big Company',
|
||||||
@@ -63,11 +89,11 @@ class DatabaseSeeder extends Seeder
|
|||||||
$organization2 = Organization::factory()->create([
|
$organization2 = Organization::factory()->create([
|
||||||
'name' => 'Rival Corp',
|
'name' => 'Rival Corp',
|
||||||
]);
|
]);
|
||||||
$user1 = User::factory()->withPersonalOrganization()->create([
|
$userAcmeManager = User::factory()->withPersonalOrganization()->create([
|
||||||
'name' => 'Other User',
|
'name' => 'Other User',
|
||||||
'email' => 'test@rival-company.test',
|
'email' => 'test@rival-company.test',
|
||||||
]);
|
]);
|
||||||
$user1->organizations()->attach($organization2, [
|
$userAcmeManager->organizations()->attach($organization2, [
|
||||||
'role' => 'admin',
|
'role' => 'admin',
|
||||||
]);
|
]);
|
||||||
$otherCompanyProject = Project::factory()->forClient($client)->create([
|
$otherCompanyProject = Project::factory()->forClient($client)->create([
|
||||||
|
|||||||
@@ -57,10 +57,43 @@ services:
|
|||||||
- '${DB_USERNAME}'
|
- '${DB_USERNAME}'
|
||||||
retries: 3
|
retries: 3
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
mailpit:
|
pgsql_test:
|
||||||
image: 'axllent/mailpit:latest'
|
image: 'postgres:15'
|
||||||
|
environment:
|
||||||
|
PGPASSWORD: '${DB_PASSWORD:-secret}'
|
||||||
|
POSTGRES_DB: '${DB_DATABASE}'
|
||||||
|
POSTGRES_USER: '${DB_USERNAME}'
|
||||||
|
POSTGRES_PASSWORD: '${DB_PASSWORD:-secret}'
|
||||||
|
volumes:
|
||||||
|
- 'sail-pgsql-test:/var/lib/postgresql/data'
|
||||||
|
- './vendor/laravel/sail/database/pgsql/create-testing-database.sql:/docker-entrypoint-initdb.d/10-create-testing-database.sql'
|
||||||
networks:
|
networks:
|
||||||
- sail
|
- sail
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- pg_isready
|
||||||
|
- '-q'
|
||||||
|
- '-d'
|
||||||
|
- '${DB_DATABASE}'
|
||||||
|
- '-U'
|
||||||
|
- '${DB_USERNAME}'
|
||||||
|
retries: 3
|
||||||
|
timeout: 5s
|
||||||
|
mailpit:
|
||||||
|
image: 'axllent/mailpit:latest'
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=${NETWORK_NAME}"
|
||||||
|
- "traefik.http.routers.solidtime-mailpit.rule=Host(`mail.${NGINX_HOST_NAME}`)"
|
||||||
|
- "traefik.http.routers.solidtime-mailpit.entrypoints=web"
|
||||||
|
- "traefik.http.services.solidtime-mailpit.loadbalancer.server.port=8025"
|
||||||
|
- "traefik.http.routers.solidtime-mailpit-https.rule=Host(`mail.${NGINX_HOST_NAME}`)"
|
||||||
|
- "traefik.http.routers.solidtime-mailpit-https.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.solidtime-mailpit-https.tls=true"
|
||||||
|
networks:
|
||||||
|
- sail
|
||||||
|
- reverse-proxy
|
||||||
playwright:
|
playwright:
|
||||||
image: mcr.microsoft.com/playwright:v1.41.1-jammy
|
image: mcr.microsoft.com/playwright:v1.41.1-jammy
|
||||||
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
|
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
|
||||||
@@ -88,3 +121,5 @@ networks:
|
|||||||
volumes:
|
volumes:
|
||||||
sail-pgsql:
|
sail-pgsql:
|
||||||
driver: local
|
driver: local
|
||||||
|
sail-pgsql-test:
|
||||||
|
driver: local
|
||||||
|
|||||||
13
lang/en/exceptions.php
Normal file
13
lang/en/exceptions.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||||
|
use App\Exceptions\Api\UserNotPlaceholderApiException;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'api' => [
|
||||||
|
TimeEntryStillRunningApiException::KEY => 'Time entry is still running',
|
||||||
|
UserNotPlaceholderApiException::KEY => 'The given user is not a placeholder',
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
<env name="CACHE_DRIVER" value="array"/>
|
<env name="CACHE_DRIVER" value="array"/>
|
||||||
<env name="DB_CONNECTION" value="pgsql"/>
|
<env name="DB_CONNECTION" value="pgsql"/>
|
||||||
|
<env name="DB_HOST" value="pgsql_test"/>
|
||||||
<env name="MAIL_MAILER" value="array"/>
|
<env name="MAIL_MAILER" value="array"/>
|
||||||
<env name="PULSE_ENABLED" value="false"/>
|
<env name="PULSE_ENABLED" value="false"/>
|
||||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Http\Controllers\Api\V1\OrganizationController;
|
|||||||
use App\Http\Controllers\Api\V1\ProjectController;
|
use App\Http\Controllers\Api\V1\ProjectController;
|
||||||
use App\Http\Controllers\Api\V1\TagController;
|
use App\Http\Controllers\Api\V1\TagController;
|
||||||
use App\Http\Controllers\Api\V1\TimeEntryController;
|
use App\Http\Controllers\Api\V1\TimeEntryController;
|
||||||
|
use App\Http\Controllers\Api\V1\UserController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
@@ -29,6 +30,12 @@ Route::middleware('auth:api')->prefix('v1')->name('v1.')->group(static function
|
|||||||
Route::put('/organizations/{organization}', [OrganizationController::class, 'update'])->name('update');
|
Route::put('/organizations/{organization}', [OrganizationController::class, 'update'])->name('update');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// User routes
|
||||||
|
Route::name('users.')->group(static function () {
|
||||||
|
Route::get('/organizations/{organization}/users', [UserController::class, 'index'])->name('index');
|
||||||
|
Route::post('/organizations/{organization}/users/{user}/invite-placeholder', [UserController::class, 'invitePlaceholder'])->name('invite-placeholder');
|
||||||
|
});
|
||||||
|
|
||||||
// Project routes
|
// Project routes
|
||||||
Route::name('projects.')->group(static function () {
|
Route::name('projects.')->group(static function () {
|
||||||
Route::get('/organizations/{organization}/projects', [ProjectController::class, 'index'])->name('index');
|
Route::get('/organizations/{organization}/projects', [ProjectController::class, 'index'])->name('index');
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\TimeEntry;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
use Laravel\Jetstream\Mail\TeamInvitation;
|
use Laravel\Jetstream\Mail\TeamInvitation;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -31,6 +33,49 @@ class InviteTeamMemberTest extends TestCase
|
|||||||
$this->assertCount(1, $user->currentTeam->fresh()->teamInvitations);
|
$this->assertCount(1, $user->currentTeam->fresh()->teamInvitations);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_team_member_can_not_be_invited_to_team_if_already_on_team(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Mail::fake();
|
||||||
|
$user = User::factory()->withPersonalOrganization()->create();
|
||||||
|
$existingUser = User::factory()->create();
|
||||||
|
$user->currentTeam->users()->attach($existingUser, ['role' => 'admin']);
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->post('/teams/'.$user->currentTeam->id.'/members', [
|
||||||
|
'email' => $existingUser->email,
|
||||||
|
'role' => 'admin',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertInvalid(['email'], 'addTeamMember');
|
||||||
|
Mail::assertNotSent(TeamInvitation::class);
|
||||||
|
$this->assertCount(0, $user->currentTeam->fresh()->teamInvitations);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_team_member_can_be_invited_to_team_if_already_on_team_as_placeholder(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Mail::fake();
|
||||||
|
$user = User::factory()->withPersonalOrganization()->create();
|
||||||
|
$existingUser = User::factory()->create([
|
||||||
|
'is_placeholder' => true,
|
||||||
|
]);
|
||||||
|
$user->currentTeam->users()->attach($existingUser, ['role' => 'employee']);
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->post('/teams/'.$user->currentTeam->id.'/members', [
|
||||||
|
'email' => $existingUser->email,
|
||||||
|
'role' => 'employee',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Mail::assertSent(TeamInvitation::class);
|
||||||
|
$this->assertCount(1, $user->currentTeam->fresh()->teamInvitations);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_team_member_invitations_can_be_cancelled(): void
|
public function test_team_member_invitations_can_be_cancelled(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -49,4 +94,97 @@ class InviteTeamMemberTest extends TestCase
|
|||||||
// Assert
|
// Assert
|
||||||
$this->assertCount(0, $user->currentTeam->fresh()->teamInvitations);
|
$this->assertCount(0, $user->currentTeam->fresh()->teamInvitations);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_team_member_invitations_can_be_accepted(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Mail::fake();
|
||||||
|
$owner = User::factory()->withPersonalOrganization()->create();
|
||||||
|
$user = User::factory()->withPersonalOrganization()->create();
|
||||||
|
$invitation = $owner->currentTeam->teamInvitations()->create([
|
||||||
|
'email' => $user->email,
|
||||||
|
'role' => 'employee',
|
||||||
|
]);
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$acceptUrl = URL::temporarySignedRoute(
|
||||||
|
'team-invitations.accept',
|
||||||
|
now()->addMinutes(60),
|
||||||
|
[$invitation->getKey()]
|
||||||
|
);
|
||||||
|
$response = $this->get($acceptUrl);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertCount(0, $owner->currentTeam->fresh()->teamInvitations);
|
||||||
|
$user->refresh();
|
||||||
|
$this->assertCount(1, $user->organizations);
|
||||||
|
$this->assertContains($owner->currentTeam->getKey(), $user->organizations->pluck('id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_team_member_invitations_of_placeholder_can_be_accepted_and_migrates_date_to_real_user(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Mail::fake();
|
||||||
|
$placeholder = User::factory()->withPersonalOrganization()->create([
|
||||||
|
'is_placeholder' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$owner = User::factory()->withPersonalOrganization()->create();
|
||||||
|
$owner->currentTeam->users()->attach($placeholder, ['role' => 'employee']);
|
||||||
|
$timeEntries = TimeEntry::factory()->forOrganization($owner->currentTeam)->forUser($placeholder)->createMany(5);
|
||||||
|
|
||||||
|
$user = User::factory()->withPersonalOrganization()->create([
|
||||||
|
'email' => $placeholder->email,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$invitation = $owner->currentTeam->teamInvitations()->create([
|
||||||
|
'email' => $user->email,
|
||||||
|
'role' => 'employee',
|
||||||
|
]);
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$acceptUrl = URL::temporarySignedRoute(
|
||||||
|
'team-invitations.accept',
|
||||||
|
now()->addMinutes(60),
|
||||||
|
[$invitation->getKey()]
|
||||||
|
);
|
||||||
|
$response = $this->get($acceptUrl);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$user->refresh();
|
||||||
|
$placeholder->refresh();
|
||||||
|
$this->assertCount(0, $owner->currentTeam->fresh()->teamInvitations);
|
||||||
|
$this->assertCount(1, $user->organizations);
|
||||||
|
$this->assertContains($owner->currentTeam->getKey(), $user->organizations->pluck('id'));
|
||||||
|
$this->assertCount(5, $user->timeEntries);
|
||||||
|
$this->assertCount(0, $placeholder->timeEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_team_member_accept_fails_if_user_with_that_email_does_not_exist(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Mail::fake();
|
||||||
|
$owner = User::factory()->withPersonalOrganization()->create();
|
||||||
|
$user = User::factory()->withPersonalOrganization()->create();
|
||||||
|
$invitation = $owner->currentTeam->teamInvitations()->create([
|
||||||
|
'email' => 'firstname.lastname@mail.test',
|
||||||
|
'role' => 'employee',
|
||||||
|
]);
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$acceptUrl = URL::temporarySignedRoute(
|
||||||
|
'team-invitations.accept',
|
||||||
|
now()->addMinutes(60),
|
||||||
|
[$invitation->getKey()]
|
||||||
|
);
|
||||||
|
$response = $this->get($acceptUrl);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertCount(1, $owner->currentTeam->fresh()->teamInvitations);
|
||||||
|
$user->refresh();
|
||||||
|
$this->assertCount(0, $user->organizations);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
use App\Providers\RouteServiceProvider;
|
use App\Providers\RouteServiceProvider;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Laravel\Fortify\Features;
|
use Laravel\Fortify\Features;
|
||||||
@@ -38,10 +39,47 @@ class RegistrationTest extends TestCase
|
|||||||
|
|
||||||
public function test_new_users_can_register(): void
|
public function test_new_users_can_register(): void
|
||||||
{
|
{
|
||||||
if (! Features::enabled(Features::registration())) {
|
$response = $this->post('/register', [
|
||||||
$this->markTestSkipped('Registration support is not enabled.');
|
'name' => 'Test User',
|
||||||
}
|
'email' => 'test@example.com',
|
||||||
|
'password' => 'password',
|
||||||
|
'password_confirmation' => 'password',
|
||||||
|
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertAuthenticated();
|
||||||
|
$response->assertRedirect(RouteServiceProvider::HOME);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_new_users_can_not_register_if_user_with_email_already_exists(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'test@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->post('/register', [
|
||||||
|
'name' => 'Test User',
|
||||||
|
'email' => 'test@example.com',
|
||||||
|
'password' => 'password',
|
||||||
|
'password_confirmation' => 'password',
|
||||||
|
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertFalse($this->isAuthenticated(), 'The user is authenticated');
|
||||||
|
$response->assertInvalid(['email']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_new_users_can_register_if_placeholder_user_with_email_already_exists(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'test@example.com',
|
||||||
|
'is_placeholder' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
$response = $this->post('/register', [
|
$response = $this->post('/register', [
|
||||||
'name' => 'Test User',
|
'name' => 'Test User',
|
||||||
'email' => 'test@example.com',
|
'email' => 'test@example.com',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace Tests\Unit\Endpoint\Api\V1;
|
namespace Tests\Unit\Endpoint\Api\V1;
|
||||||
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
|
use App\Service\Import\Importers\ReportDto;
|
||||||
use App\Service\Import\ImportService;
|
use App\Service\Import\ImportService;
|
||||||
use Laravel\Passport\Passport;
|
use Laravel\Passport\Passport;
|
||||||
use Mockery\MockInterface;
|
use Mockery\MockInterface;
|
||||||
@@ -20,7 +21,7 @@ class ImportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
Passport::actingAs($data->user);
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
$response = $this->postJson(route('api.v1.import', ['organization' => $data->organization->id]), [
|
$response = $this->postJson(route('api.v1.import.import', ['organization' => $data->organization->id]), [
|
||||||
'type' => 'toggl_time_entries',
|
'type' => 'toggl_time_entries',
|
||||||
'data' => 'some data',
|
'data' => 'some data',
|
||||||
'options' => [],
|
'options' => [],
|
||||||
@@ -41,6 +42,14 @@ class ImportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
->withArgs(function (Organization $organization, string $importerType, string $data, array $options) use (&$user): bool {
|
->withArgs(function (Organization $organization, string $importerType, string $data, array $options) use (&$user): bool {
|
||||||
return $organization->is($user->organization) && $importerType === 'toggl_time_entries' && $data === 'some data' && $options === [];
|
return $organization->is($user->organization) && $importerType === 'toggl_time_entries' && $data === 'some data' && $options === [];
|
||||||
})
|
})
|
||||||
|
->andReturn(new ReportDto(
|
||||||
|
clientsCreated: 1,
|
||||||
|
projectsCreated: 2,
|
||||||
|
tasksCreated: 3,
|
||||||
|
timeEntriesCreated: 4,
|
||||||
|
tagsCreated: 5,
|
||||||
|
usersCreated: 6,
|
||||||
|
))
|
||||||
->once();
|
->once();
|
||||||
});
|
});
|
||||||
Passport::actingAs($user->user);
|
Passport::actingAs($user->user);
|
||||||
@@ -54,5 +63,27 @@ class ImportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
|
$response->assertExactJson([
|
||||||
|
'report' => [
|
||||||
|
'clients' => [
|
||||||
|
'created' => 1,
|
||||||
|
],
|
||||||
|
'projects' => [
|
||||||
|
'created' => 2,
|
||||||
|
],
|
||||||
|
'tasks' => [
|
||||||
|
'created' => 3,
|
||||||
|
],
|
||||||
|
'time-entries' => [
|
||||||
|
'created' => 4,
|
||||||
|
],
|
||||||
|
'tags' => [
|
||||||
|
'created' => 5,
|
||||||
|
],
|
||||||
|
'users' => [
|
||||||
|
'created' => 6,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
85
tests/Unit/Endpoint/Api/V1/UserEndpointTest.php
Normal file
85
tests/Unit/Endpoint/Api/V1/UserEndpointTest.php
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit\Endpoint\Api\V1;
|
||||||
|
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\User;
|
||||||
|
use Laravel\Passport\Passport;
|
||||||
|
|
||||||
|
class UserEndpointTest extends ApiEndpointTestAbstract
|
||||||
|
{
|
||||||
|
public function test_index_returns_members_of_organization(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'users:view',
|
||||||
|
]);
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.users.index', $data->organization->id));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_invite_placeholder_fails_if_user_does_not_have_permission(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
]);
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'is_placeholder' => true,
|
||||||
|
]);
|
||||||
|
$data->organization->users()->attach($user);
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->postJson(route('api.v1.users.invite-placeholder', ['organization' => $data->organization->id, 'user' => $user->id]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_invite_placeholder_fails_if_user_is_not_part_of_organization(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'users:invite-placeholder',
|
||||||
|
]);
|
||||||
|
$otherOrganization = Organization::factory()->create();
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'is_placeholder' => true,
|
||||||
|
]);
|
||||||
|
$otherOrganization->users()->attach($user);
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->postJson(route('api.v1.users.invite-placeholder', ['organization' => $data->organization->id, 'user' => $user->id]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_invite_placeholder_returns_400_if_user_is_not_placeholder(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'users:invite-placeholder',
|
||||||
|
]);
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->postJson(route('api.v1.users.invite-placeholder', ['organization' => $data->organization->id, 'user' => $data->user->id]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(400);
|
||||||
|
$response->assertExactJson([
|
||||||
|
'error' => true,
|
||||||
|
'key' => 'user_not_placeholder',
|
||||||
|
'message' => 'The given user is not a placeholder',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tests\Unit\Model;
|
namespace Tests\Unit\Model;
|
||||||
|
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\TimeEntry;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Providers\Filament\AdminPanelProvider;
|
use App\Providers\Filament\AdminPanelProvider;
|
||||||
use Filament\Panel;
|
use Filament\Panel;
|
||||||
@@ -42,4 +44,47 @@ class UserModelTest extends ModelTestAbstract
|
|||||||
// Assert
|
// Assert
|
||||||
$this->assertTrue($canAccess);
|
$this->assertTrue($canAccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_scope_belongs_to_organization_returns_only_users_of_organization_including_owners(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$owner = User::factory()->create();
|
||||||
|
$organization = Organization::factory()->withOwner($owner)->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$user->organizations()->attach($organization, [
|
||||||
|
'role' => 'employee',
|
||||||
|
]);
|
||||||
|
$otherOrganization = Organization::factory()->create();
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
$otherUser->organizations()->attach($otherOrganization, [
|
||||||
|
'role' => 'employee',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$users = User::query()
|
||||||
|
->belongsToOrganization($organization)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertCount(2, $users);
|
||||||
|
$userIds = $users->pluck('id')->toArray();
|
||||||
|
$this->assertContains($user->getKey(), $userIds);
|
||||||
|
$this->assertContains($owner->getKey(), $userIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_has_many_time_entries(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$timeEntries = TimeEntry::factory()->forUser($user)->createMany(3);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$user->refresh();
|
||||||
|
$timeEntriesRel = $user->timeEntries;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertNotNull($timeEntriesRel);
|
||||||
|
$this->assertCount(3, $timeEntriesRel);
|
||||||
|
$this->assertTrue($timeEntriesRel->first()->is($timeEntries->first()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,21 +51,27 @@ class ImportDatabaseHelperTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_get_key_not_attach_to_existing_returns_key_for_identifier_without_creating_model(): void
|
public function test_get_key_not_attach_to_existing_is_not_implemented_yet(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
$project = Project::factory()->create();
|
$project = Project::factory()->create();
|
||||||
$helper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], false);
|
$helper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], false);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
$key = $helper->getKey([
|
try {
|
||||||
'name' => $project->name,
|
$key = $helper->getKey([
|
||||||
'organization_id' => $project->organization_id,
|
'name' => $project->name,
|
||||||
], [
|
'organization_id' => $project->organization_id,
|
||||||
'color' => '#000000',
|
], [
|
||||||
]);
|
'color' => '#000000',
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->assertSame('Not implemented', $e->getMessage());
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$this->assertNotSame($project->getKey(), $key);
|
$this->fail();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
37
tests/Unit/Service/UserServiceTest.php
Normal file
37
tests/Unit/Service/UserServiceTest.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit\Service;
|
||||||
|
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\TimeEntry;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Service\UserService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class UserServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_assign_organization_entities_to_different_user(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
$fromUser = User::factory()->create();
|
||||||
|
$toUser = User::factory()->create();
|
||||||
|
TimeEntry::factory()->forOrganization($organization)->forUser($otherUser)->createMany(3);
|
||||||
|
TimeEntry::factory()->forOrganization($organization)->forUser($fromUser)->createMany(3);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$userService = app(UserService::class);
|
||||||
|
$userService->assignOrganizationEntitiesToDifferentUser($organization, $fromUser, $toUser);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertSame(3, TimeEntry::query()->whereBelongsTo($toUser, 'user')->count());
|
||||||
|
$this->assertSame(3, TimeEntry::query()->whereBelongsTo($otherUser, 'user')->count());
|
||||||
|
$this->assertSame(0, TimeEntry::query()->whereBelongsTo($fromUser, 'user')->count());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user