diff --git a/.env.example b/.env.example index d3e03c26..ed50bd57 100644 --- a/.env.example +++ b/.env.example @@ -37,7 +37,7 @@ MAIL_PORT=1025 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null -MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_ADDRESS="no-reply@solidtime.test" MAIL_FROM_NAME="${APP_NAME}" AWS_ACCESS_KEY_ID= diff --git a/.gitignore b/.gitignore index e6028879..ffdee140 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ yarn-error.log /blob-report/ /playwright/.cache/ /coverage +/extensions/* diff --git a/README.md b/README.md index eb0c4796..4f7a64f7 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Add the following entry to your `/etc/hosts` ``` 127.0.0.1 solidtime.test 127.0.0.1 playwright.solidtime.test +127.0.0.1 mail.solidtime.test ``` ## Running E2E Tests diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 292463ef..3702cf5a 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -6,9 +6,12 @@ namespace App\Actions\Fortify; use App\Models\Organization; use App\Models\User; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\ValidationException; +use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Laravel\Fortify\Contracts\CreatesNewUsers; use Laravel\Jetstream\Jetstream; @@ -20,12 +23,27 @@ class CreateNewUser implements CreatesNewUsers * Create a newly registered user. * * @param array $input + * + * @throws ValidationException */ public function create(array $input): User { Validator::make($input, [ - 'name' => ['required', 'string', 'max:255'], - 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'], + 'name' => [ + 'required', + 'string', + 'max:255', + ], + 'email' => [ + 'required', + 'string', + 'email', + 'max:255', + new UniqueEloquent(User::class, 'email', function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->where('is_placeholder', '=', false); + }), + ], 'password' => $this->passwordRules(), 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '', ])->validate(); diff --git a/app/Actions/Jetstream/AddOrganizationMember.php b/app/Actions/Jetstream/AddOrganizationMember.php index 47f9ccfa..7a84d53f 100644 --- a/app/Actions/Jetstream/AddOrganizationMember.php +++ b/app/Actions/Jetstream/AddOrganizationMember.php @@ -8,8 +8,10 @@ use App\Models\Organization; use App\Models\User; use Closure; use Illuminate\Contracts\Validation\Rule; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Validator; +use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Laravel\Jetstream\Contracts\AddsTeamMembers; use Laravel\Jetstream\Events\AddingTeamMember; use Laravel\Jetstream\Events\TeamMemberAdded; @@ -21,21 +23,24 @@ class AddOrganizationMember implements AddsTeamMembers /** * 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); - $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( - $newTeamMember, ['role' => $role] + $newOrganizationMember, ['role' => $role] ); - TeamMemberAdded::dispatch($organization, $newTeamMember); + TeamMemberAdded::dispatch($organization, $newOrganizationMember); } /** @@ -46,9 +51,7 @@ class AddOrganizationMember implements AddsTeamMembers Validator::make([ 'email' => $email, 'role' => $role, - ], $this->rules(), [ - 'email.exists' => __('We were unable to find a registered user with this email address.'), - ])->after( + ], $this->rules())->after( $this->ensureUserIsNotAlreadyOnTeam($organization, $email) )->validateWithBag('addTeamMember'); } @@ -61,7 +64,13 @@ class AddOrganizationMember implements AddsTeamMembers protected function rules(): array { 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() ? ['required', 'string', new Role] : null, @@ -75,7 +84,7 @@ class AddOrganizationMember implements AddsTeamMembers { return function ($validator) use ($team, $email) { $validator->errors()->addIf( - $team->hasUserWithEmail($email), + $team->hasRealUserWithEmail($email), 'email', __('This user already belongs to the team.') ); diff --git a/app/Actions/Jetstream/InviteOrganizationMember.php b/app/Actions/Jetstream/InviteOrganizationMember.php index a688fffa..686a9978 100644 --- a/app/Actions/Jetstream/InviteOrganizationMember.php +++ b/app/Actions/Jetstream/InviteOrganizationMember.php @@ -34,6 +34,7 @@ class InviteOrganizationMember implements InvitesTeamMembers InvitingTeamMember::dispatch($organization, $email, $role); + /** @var TeamInvitation $invitation */ $invitation = $organization->teamInvitations()->create([ 'email' => $email, 'role' => $role, @@ -50,9 +51,7 @@ class InviteOrganizationMember implements InvitesTeamMembers Validator::make([ 'email' => $email, 'role' => $role, - ], $this->rules($organization), [ - 'email.unique' => __('This user has already been invited to the team.'), - ])->after( + ], $this->rules($organization))->after( $this->ensureUserIsNotAlreadyOnTeam($organization, $email) )->validateWithBag('addTeamMember'); } @@ -68,10 +67,10 @@ class InviteOrganizationMember implements InvitesTeamMembers 'email' => [ 'required', 'email', - new UniqueEloquent(OrganizationInvitation::class, 'email', function (Builder $builder) use ($organization) { + (new UniqueEloquent(OrganizationInvitation::class, 'email', function (Builder $builder) use ($organization) { /** @var Builder $builder */ return $builder->whereBelongsTo($organization, 'organization'); - }), + }))->withMessage(__('This user has already been invited to the team.')), ], 'role' => Jetstream::hasRoles() ? ['required', 'string', new Role] @@ -86,7 +85,7 @@ class InviteOrganizationMember implements InvitesTeamMembers { return function ($validator) use ($organization, $email) { $validator->errors()->addIf( - $organization->hasUserWithEmail($email), + $organization->hasRealUserWithEmail($email), 'email', __('This user already belongs to the team.') ); diff --git a/app/Exceptions/Api/ApiException.php b/app/Exceptions/Api/ApiException.php new file mode 100644 index 00000000..bcd5ae01 --- /dev/null +++ b/app/Exceptions/Api/ApiException.php @@ -0,0 +1,46 @@ +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()); + } +} diff --git a/app/Exceptions/Api/TimeEntryStillRunningApiException.php b/app/Exceptions/Api/TimeEntryStillRunningApiException.php new file mode 100644 index 00000000..e110a9ae --- /dev/null +++ b/app/Exceptions/Api/TimeEntryStillRunningApiException.php @@ -0,0 +1,10 @@ +json([ - 'error' => true, - 'message' => $this->getMessage(), - ], 400); - } -} diff --git a/app/Exceptions/TimeEntryStillRunning.php b/app/Exceptions/TimeEntryStillRunning.php deleted file mode 100644 index a4ee00a2..00000000 --- a/app/Exceptions/TimeEntryStillRunning.php +++ /dev/null @@ -1,9 +0,0 @@ -get('end') === null && TimeEntry::query()->where('user_id', $request->get('user_id'))->where('end', null)->exists()) { // TODO: API documentation - // TODO: Create concept for api exceptions - throw new TimeEntryStillRunning('User already has an active time entry'); + throw new TimeEntryStillRunningApiException(); } $timeEntry = new TimeEntry(); diff --git a/app/Http/Controllers/Api/V1/UserController.php b/app/Http/Controllers/Api/V1/UserController.php new file mode 100644 index 00000000..94ff6f2e --- /dev/null +++ b/app/Http/Controllers/Api/V1/UserController.php @@ -0,0 +1,56 @@ +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); + } +} diff --git a/app/Http/Requests/V1/User/UserIndexRequest.php b/app/Http/Requests/V1/User/UserIndexRequest.php new file mode 100644 index 00000000..f600d01b --- /dev/null +++ b/app/Http/Requests/V1/User/UserIndexRequest.php @@ -0,0 +1,26 @@ +> + */ + public function rules(): array + { + return [ + ]; + } +} diff --git a/app/Http/Resources/V1/User/UserCollection.php b/app/Http/Resources/V1/User/UserCollection.php new file mode 100644 index 00000000..e9461a8f --- /dev/null +++ b/app/Http/Resources/V1/User/UserCollection.php @@ -0,0 +1,17 @@ +> + */ + 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, + ]; + } +} diff --git a/app/Listeners/RemovePlaceholder.php b/app/Listeners/RemovePlaceholder.php new file mode 100644 index 00000000..4ba70a9d --- /dev/null +++ b/app/Listeners/RemovePlaceholder.php @@ -0,0 +1,30 @@ +where('is_placeholder', '=', true) + ->where('email', '=', $event->user->email) + ->belongsToOrganization($event->team) + ->get(); + + foreach ($placeholders as $placeholder) { + $userService->assignOrganizationEntitiesToDifferentUser($event->team, $placeholder, $event->user); + } + } +} diff --git a/app/Models/Organization.php b/app/Models/Organization.php index 62bd33b7..284675d4 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -8,6 +8,7 @@ use Database\Factories\OrganizationFactory; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Laravel\Jetstream\Events\TeamCreated; use Laravel\Jetstream\Events\TeamDeleted; @@ -59,4 +60,30 @@ class Organization extends JetstreamTeam 'updated' => TeamUpdated::class, 'deleted' => TeamDeleted::class, ]; + + /** + * Get all the non-placeholder users of the organization including its owner. + * + * @return Collection + */ + 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 + */ + public function realUsers(): BelongsToMany + { + return $this->users() + ->where('is_placeholder', false); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 19e6bc8f..a58a737f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -6,6 +6,8 @@ namespace App\Models; use Database\Factories\UserFactory; use Filament\Panel; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -21,9 +23,16 @@ use Laravel\Passport\HasApiTokens; * @property string $id * @property string $name * @property string $email + * @property string|null $email_verified_at + * @property string|null $password + * @property bool $is_placeholder + * @property Collection $organizations + * @property Collection $timeEntries * * @method HasMany ownedTeams() * @method static UserFactory factory() + * @method static Builder query() + * @method Builder belongsToOrganization(Organization $organization) */ class User extends Authenticatable { @@ -97,4 +106,27 @@ class User extends Authenticatable ->withTimestamps() ->as('membership'); } + + /** + * @return HasMany + */ + public function timeEntries(): HasMany + { + return $this->hasMany(TimeEntry::class); + } + + /** + * @param Builder $builder + * @return Builder + */ + 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()); + }); + }); + } } diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index ee09f108..4dc848b5 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -4,10 +4,11 @@ declare(strict_types=1); namespace App\Providers; +use App\Listeners\RemovePlaceholder; use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Listeners\SendEmailVerificationNotification; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; -use Illuminate\Support\Facades\Event; +use Laravel\Jetstream\Events\TeamMemberAdded; class EventServiceProvider extends ServiceProvider { @@ -20,6 +21,9 @@ class EventServiceProvider extends ServiceProvider Registered::class => [ SendEmailVerificationNotification::class, ], + TeamMemberAdded::class => [ + RemovePlaceholder::class, + ], ]; /** diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index 1de94d23..e3757c7c 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -75,6 +75,8 @@ class JetstreamServiceProvider extends ServiceProvider 'organizations:view', 'organizations:update', 'import', + 'users:invite-placeholder', + 'users:view', ])->description('Administrator users can perform any action.'); Jetstream::role('manager', 'Manager', [ @@ -95,6 +97,7 @@ class JetstreamServiceProvider extends ServiceProvider 'tags:update', 'tags:delete', 'organizations:view', + 'users:view', ])->description('Editor users have the ability to read, create, and update.'); Jetstream::role('employee', 'Employee', [ diff --git a/app/Service/Import/Importers/ImporterProvider.php b/app/Service/Import/Importers/ImporterProvider.php index 4ed1033f..ed413566 100644 --- a/app/Service/Import/Importers/ImporterProvider.php +++ b/app/Service/Import/Importers/ImporterProvider.php @@ -6,6 +6,9 @@ namespace App\Service\Import\Importers; class ImporterProvider { + /** + * @var array> + */ private array $importers = [ 'toggl_time_entries' => TogglTimeEntriesImporter::class, ]; diff --git a/app/Service/UserService.php b/app/Service/UserService.php new file mode 100644 index 00000000..e554e078 --- /dev/null +++ b/app/Service/UserService.php @@ -0,0 +1,23 @@ +whereBelongsTo($organization, 'organization') + ->whereBelongsTo($fromUser, 'user') + ->update([ + 'user_id' => $toUser->getKey(), + ])); + } +} diff --git a/config/telescope.php b/config/telescope.php index 21d8ddda..d4057545 100644 --- a/config/telescope.php +++ b/config/telescope.php @@ -155,7 +155,7 @@ return [ Watchers\LogWatcher::class => [ 'enabled' => env('TELESCOPE_LOG_WATCHER', true), - 'level' => 'error', + 'level' => 'debug', ], Watchers\MailWatcher::class => env('TELESCOPE_MAIL_WATCHER', true), diff --git a/database/factories/OrganizationFactory.php b/database/factories/OrganizationFactory.php index e0d629e4..e1efdea6 100644 --- a/database/factories/OrganizationFactory.php +++ b/database/factories/OrganizationFactory.php @@ -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) => [ - 'user_id' => User::factory(), + 'user_id' => $owner === null ? User::factory() : $owner, ]); } } diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 60f31ffb..893c7e65 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -31,9 +31,19 @@ class UserFactory extends Factory 'remember_token' => Str::random(10), 'profile_photo_path' => 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. */ diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 7dd88fa8..b59a486d 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -22,31 +22,57 @@ class DatabaseSeeder extends Seeder public function run(): void { $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', ]); - $user1 = User::factory()->withPersonalOrganization()->create([ + $userAcmeManager = User::factory()->withPersonalOrganization()->create([ 'name' => 'Test User', 'email' => 'test@example.com', ]); - $employee1 = User::factory()->withPersonalOrganization()->create([ - 'name' => 'Test User', - 'email' => 'employee@example.com', - ]); - $userAcmeAdmin = User::factory()->create([ + $userAcmeAdmin = User::factory()->withPersonalOrganization()->create([ 'name' => 'ACME Admin', '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', ]); - $userAcmeAdmin->organizations()->attach($organization1, [ + $userAcmeAdmin->organizations()->attach($organizationAcme, [ 'role' => 'admin', ]); - $timeEntriesEmployees = TimeEntry::factory() + $userAcmeEmployee->organizations()->attach($organizationAcme, [ + 'role' => 'employee', + ]); + $userAcmePlaceholder->organizations()->attach($organizationAcme, [ + 'role' => 'employee', + ]); + + $timeEntriesAcmeAdmin = TimeEntry::factory() ->count(10) - ->forUser($employee1) - ->forOrganization($organization1) + ->forUser($userAcmeAdmin) + ->forOrganization($organizationAcme) + ->create(); + $timeEntriesAcmePlaceholder = TimeEntry::factory() + ->count(10) + ->forUser($userAcmePlaceholder) + ->forOrganization($organizationAcme) + ->create(); + $timeEntriesAcmePlaceholder = TimeEntry::factory() + ->count(10) + ->forUser($userAcmeEmployee) + ->forOrganization($organizationAcme) ->create(); $client = Client::factory()->create([ 'name' => 'Big Company', @@ -63,11 +89,11 @@ class DatabaseSeeder extends Seeder $organization2 = Organization::factory()->create([ 'name' => 'Rival Corp', ]); - $user1 = User::factory()->withPersonalOrganization()->create([ + $userAcmeManager = User::factory()->withPersonalOrganization()->create([ 'name' => 'Other User', 'email' => 'test@rival-company.test', ]); - $user1->organizations()->attach($organization2, [ + $userAcmeManager->organizations()->attach($organization2, [ 'role' => 'admin', ]); $otherCompanyProject = Project::factory()->forClient($client)->create([ diff --git a/docker-compose.yml b/docker-compose.yml index 876bcfe0..d4faaf13 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,10 +57,43 @@ services: - '${DB_USERNAME}' retries: 3 timeout: 5s - mailpit: - image: 'axllent/mailpit:latest' + pgsql_test: + 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: - 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: image: mcr.microsoft.com/playwright:v1.41.1-jammy command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0'] @@ -88,3 +121,5 @@ networks: volumes: sail-pgsql: driver: local + sail-pgsql-test: + driver: local diff --git a/lang/en/exceptions.php b/lang/en/exceptions.php new file mode 100644 index 00000000..706574fb --- /dev/null +++ b/lang/en/exceptions.php @@ -0,0 +1,13 @@ + [ + TimeEntryStillRunningApiException::KEY => 'Time entry is still running', + UserNotPlaceholderApiException::KEY => 'The given user is not a placeholder', + ], +]; diff --git a/phpunit.xml b/phpunit.xml index 82235621..f5ab132a 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -22,6 +22,7 @@ + diff --git a/routes/api.php b/routes/api.php index a75683a9..ca46d271 100644 --- a/routes/api.php +++ b/routes/api.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Api\V1\OrganizationController; use App\Http\Controllers\Api\V1\ProjectController; use App\Http\Controllers\Api\V1\TagController; use App\Http\Controllers\Api\V1\TimeEntryController; +use App\Http\Controllers\Api\V1\UserController; use Illuminate\Support\Facades\Route; 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'); }); + // 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 Route::name('projects.')->group(static function () { Route::get('/organizations/{organization}/projects', [ProjectController::class, 'index'])->name('index'); diff --git a/tests/Feature/InviteTeamMemberTest.php b/tests/Feature/InviteTeamMemberTest.php index a4f14b29..60bffb6b 100644 --- a/tests/Feature/InviteTeamMemberTest.php +++ b/tests/Feature/InviteTeamMemberTest.php @@ -4,9 +4,11 @@ declare(strict_types=1); namespace Tests\Feature; +use App\Models\TimeEntry; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\URL; use Laravel\Jetstream\Mail\TeamInvitation; use Tests\TestCase; @@ -31,6 +33,49 @@ class InviteTeamMemberTest extends TestCase $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 { // Arrange @@ -49,4 +94,97 @@ class InviteTeamMemberTest extends TestCase // Assert $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); + } } diff --git a/tests/Feature/RegistrationTest.php b/tests/Feature/RegistrationTest.php index 23ab3426..4373809f 100644 --- a/tests/Feature/RegistrationTest.php +++ b/tests/Feature/RegistrationTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Tests\Feature; +use App\Models\User; use App\Providers\RouteServiceProvider; use Illuminate\Foundation\Testing\RefreshDatabase; use Laravel\Fortify\Features; @@ -38,10 +39,47 @@ class RegistrationTest extends TestCase public function test_new_users_can_register(): void { - if (! Features::enabled(Features::registration())) { - $this->markTestSkipped('Registration support is not enabled.'); - } + $response = $this->post('/register', [ + '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', [ 'name' => 'Test User', 'email' => 'test@example.com', diff --git a/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php index c53b2fde..5534cf14 100644 --- a/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Tests\Unit\Endpoint\Api\V1; use App\Models\Organization; +use App\Service\Import\Importers\ReportDto; use App\Service\Import\ImportService; use Laravel\Passport\Passport; use Mockery\MockInterface; @@ -20,7 +21,7 @@ class ImportEndpointTest extends ApiEndpointTestAbstract Passport::actingAs($data->user); // 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', 'data' => 'some data', 'options' => [], @@ -41,6 +42,14 @@ class ImportEndpointTest extends ApiEndpointTestAbstract ->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 === []; }) + ->andReturn(new ReportDto( + clientsCreated: 1, + projectsCreated: 2, + tasksCreated: 3, + timeEntriesCreated: 4, + tagsCreated: 5, + usersCreated: 6, + )) ->once(); }); Passport::actingAs($user->user); @@ -54,5 +63,27 @@ class ImportEndpointTest extends ApiEndpointTestAbstract // Assert $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, + ], + ], + ]); } } diff --git a/tests/Unit/Endpoint/Api/V1/UserEndpointTest.php b/tests/Unit/Endpoint/Api/V1/UserEndpointTest.php new file mode 100644 index 00000000..9cbe27a2 --- /dev/null +++ b/tests/Unit/Endpoint/Api/V1/UserEndpointTest.php @@ -0,0 +1,85 @@ +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', + ]); + } +} diff --git a/tests/Unit/Model/UserModelTest.php b/tests/Unit/Model/UserModelTest.php index c0d72d37..228284cf 100644 --- a/tests/Unit/Model/UserModelTest.php +++ b/tests/Unit/Model/UserModelTest.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Tests\Unit\Model; +use App\Models\Organization; +use App\Models\TimeEntry; use App\Models\User; use App\Providers\Filament\AdminPanelProvider; use Filament\Panel; @@ -42,4 +44,47 @@ class UserModelTest extends ModelTestAbstract // Assert $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())); + } } diff --git a/tests/Unit/Service/Import/ImportDatabaseHelperTest.php b/tests/Unit/Service/Import/ImportDatabaseHelperTest.php index f0a03d9b..d1d156f2 100644 --- a/tests/Unit/Service/Import/ImportDatabaseHelperTest.php +++ b/tests/Unit/Service/Import/ImportDatabaseHelperTest.php @@ -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 $project = Project::factory()->create(); $helper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], false); // Act - $key = $helper->getKey([ - 'name' => $project->name, - 'organization_id' => $project->organization_id, - ], [ - 'color' => '#000000', - ]); + try { + $key = $helper->getKey([ + 'name' => $project->name, + 'organization_id' => $project->organization_id, + ], [ + 'color' => '#000000', + ]); + } catch (\Exception $e) { + $this->assertSame('Not implemented', $e->getMessage()); + + return; + } // Assert - $this->assertNotSame($project->getKey(), $key); + $this->fail(); } } diff --git a/tests/Unit/Service/UserServiceTest.php b/tests/Unit/Service/UserServiceTest.php new file mode 100644 index 00000000..79494844 --- /dev/null +++ b/tests/Unit/Service/UserServiceTest.php @@ -0,0 +1,37 @@ +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()); + } +}