diff --git a/.env.ci b/.env.ci index 40ce2768f..21ee262b8 100644 --- a/.env.ci +++ b/.env.ci @@ -34,7 +34,12 @@ SESSION_DRIVER=database SESSION_LIFETIME=120 # Mail -MAIL_MAILER=log +MAIL_MAILER=smtp +MAIL_HOST=localhost +MAIL_PORT=1025 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS="no-reply@solidtime.test" MAIL_FROM_NAME="solidtime" MAIL_REPLY_TO_ADDRESS="hello@solidtime.test" @@ -56,3 +61,6 @@ TELESCOPE_ENABLED=false # Services GOTENBERG_URL=http://0.0.0.0:3000 + +# Octane +OCTANE_SERVER=frankenphp diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index d0f9b8052..f9317663c 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -6,10 +6,18 @@ jobs: test: runs-on: ubuntu-latest timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + shardIndex: [1, 2, 3, 4] + shardTotal: [4] services: mailpit: image: 'axllent/mailpit:latest' + ports: + - 1025:1025 + - 8025:8025 pgsql_test: image: postgres:15 env: @@ -57,22 +65,63 @@ jobs: - name: "Build Frontend" run: npm run build - - name: "Run Laravel Server" - run: php artisan serve > /dev/null 2>&1 & + - name: "Install FrankenPHP" + run: | + ARCH="$(uname -m)" + curl -fsSL "https://github.com/dunglas/frankenphp/releases/latest/download/frankenphp-linux-${ARCH}" -o /usr/local/bin/frankenphp + chmod +x /usr/local/bin/frankenphp + + - name: "Run Laravel Octane Server" + run: php artisan octane:start --server=frankenphp --host=127.0.0.1 --port=8000 --workers=4 --max-requests=500 > /dev/null 2>&1 & + env: + OCTANE_SERVER: frankenphp - name: "Install Playwright Browsers" run: npx playwright install --with-deps - name: "Run Playwright tests" - run: npx playwright test + run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} env: PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000' + MAILPIT_BASE_URL: 'http://localhost:8025' - - name: "Upload test results" + - name: "Upload blob report" uses: actions/upload-artifact@v4 if: always() with: - name: test-results - path: test-results/ - retention-days: 30 + name: blob-report-${{ matrix.shardIndex }} + path: blob-report/ + retention-days: 7 + + merge-reports: + if: always() + needs: [test] + runs-on: ubuntu-latest + steps: + - name: "Checkout code" + uses: actions/checkout@v4 + + - name: "Setup node" + uses: actions/setup-node@v4 + with: + node-version: '20.x' + + - name: "Install dependencies" + run: npm ci + - name: "Download blob reports" + uses: actions/download-artifact@v4 + with: + path: all-blob-reports + pattern: blob-report-* + merge-multiple: true + + - name: "Merge reports" + run: npx playwright merge-reports --reporter html ./all-blob-reports + + - name: "Upload merged HTML report" + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/app/Filament/Resources/TimeEntryResource.php b/app/Filament/Resources/TimeEntryResource.php index ffd133b3c..c54a46786 100644 --- a/app/Filament/Resources/TimeEntryResource.php +++ b/app/Filament/Resources/TimeEntryResource.php @@ -5,6 +5,7 @@ namespace App\Filament\Resources; use App\Filament\Resources\TimeEntryResource\Pages; +use App\Models\Member; use App\Models\TimeEntry; use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\Select; @@ -16,6 +17,7 @@ use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Builder; class TimeEntryResource extends Resource { @@ -51,15 +53,23 @@ public static function form(Form $form): Form ->rules([ 'after_or_equal:start', ]), - Select::make('user_id') - ->relationship(name: 'user', titleAttribute: 'email') - ->searchable(['name', 'email']) + Select::make('member_id') + ->relationship( + name: 'member', + titleAttribute: 'id', + modifyQueryUsing: fn (Builder $query) => $query->with(['user', 'organization']) + ) + ->getOptionLabelFromRecordUsing(fn (Member $record): string => $record->user->email.' ('.$record->organization->name.')') + ->searchable() ->required(), Select::make('project_id') ->relationship(name: 'project', titleAttribute: 'name') ->searchable(['name']) ->nullable(), - // TODO + Select::make('task_id') + ->relationship(name: 'task', titleAttribute: 'name') + ->searchable(['name']) + ->nullable(), ]); } diff --git a/app/Filament/Resources/TimeEntryResource/Pages/CreateTimeEntry.php b/app/Filament/Resources/TimeEntryResource/Pages/CreateTimeEntry.php index 35a85daf7..a8bb9498b 100644 --- a/app/Filament/Resources/TimeEntryResource/Pages/CreateTimeEntry.php +++ b/app/Filament/Resources/TimeEntryResource/Pages/CreateTimeEntry.php @@ -5,9 +5,28 @@ namespace App\Filament\Resources\TimeEntryResource\Pages; use App\Filament\Resources\TimeEntryResource; +use App\Models\Member; use Filament\Resources\Pages\CreateRecord; class CreateTimeEntry extends CreateRecord { protected static string $resource = TimeEntryResource::class; + + /** + * @param array $data + * @return array + */ + protected function mutateFormDataBeforeCreate(array $data): array + { + if (isset($data['member_id'])) { + /** @var Member|null $member */ + $member = Member::query()->find($data['member_id']); + if ($member !== null) { + $data['user_id'] = $member->user_id; + $data['organization_id'] = $member->organization_id; + } + } + + return $data; + } } diff --git a/app/Filament/Resources/TimeEntryResource/Pages/EditTimeEntry.php b/app/Filament/Resources/TimeEntryResource/Pages/EditTimeEntry.php index 96105ecda..f8ef533fc 100644 --- a/app/Filament/Resources/TimeEntryResource/Pages/EditTimeEntry.php +++ b/app/Filament/Resources/TimeEntryResource/Pages/EditTimeEntry.php @@ -5,6 +5,7 @@ namespace App\Filament\Resources\TimeEntryResource\Pages; use App\Filament\Resources\TimeEntryResource; +use App\Models\Member; use Filament\Actions; use Filament\Resources\Pages\EditRecord; @@ -19,4 +20,22 @@ protected function getHeaderActions(): array ->icon('heroicon-m-trash'), ]; } + + /** + * @param array $data + * @return array + */ + protected function mutateFormDataBeforeSave(array $data): array + { + if (isset($data['member_id'])) { + /** @var Member|null $member */ + $member = Member::query()->find($data['member_id']); + if ($member !== null) { + $data['user_id'] = $member->user_id; + $data['organization_id'] = $member->organization_id; + } + } + + return $data; + } } diff --git a/app/Http/Controllers/Api/V1/ChartController.php b/app/Http/Controllers/Api/V1/ChartController.php index 3e034df90..167b9ed17 100644 --- a/app/Http/Controllers/Api/V1/ChartController.php +++ b/app/Http/Controllers/Api/V1/ChartController.php @@ -102,7 +102,7 @@ public function dailyTrackedHours(Organization $organization, DashboardService $ $this->checkPermission($organization, 'charts:view:own'); $user = $this->user(); - $dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60); + $dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 100); return response()->json($dailyTrackedHours); } diff --git a/app/Http/Controllers/Api/V1/ReportController.php b/app/Http/Controllers/Api/V1/ReportController.php index b8f2fd21a..5a8ca3122 100644 --- a/app/Http/Controllers/Api/V1/ReportController.php +++ b/app/Http/Controllers/Api/V1/ReportController.php @@ -150,6 +150,9 @@ public function update(Organization $organization, Report $report, ReportUpdateR $report->share_secret = null; $report->public_until = null; } + } elseif ($report->is_public && $request->has('public_until')) { + // Allow updating expiration date on already-public reports + $report->public_until = $request->getPublicUntil(); } $report->save(); diff --git a/app/Http/Requests/V1/Report/ReportStoreRequest.php b/app/Http/Requests/V1/Report/ReportStoreRequest.php index da609ba1c..443bf01cd 100644 --- a/app/Http/Requests/V1/Report/ReportStoreRequest.php +++ b/app/Http/Requests/V1/Report/ReportStoreRequest.php @@ -10,9 +10,11 @@ use App\Enums\Weekday; use App\Http\Requests\V1\BaseFormRequest; use App\Models\Organization; +use App\Service\TimeEntryFilter; use Illuminate\Contracts\Validation\Rule as LegacyValidationRule; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Support\Carbon; +use Illuminate\Support\Str; use Illuminate\Validation\Rule; /** @@ -23,7 +25,7 @@ class ReportStoreRequest extends BaseFormRequest /** * Get the validation rules that apply to the request. * - * @return array> + * @return array> */ public function rules(): array { @@ -81,7 +83,14 @@ public function rules(): array ], 'properties.client_ids.*' => [ 'string', - 'uuid', + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + if (! Str::isUuid($value)) { + $fail('The '.$attribute.' must be a valid UUID.'); + } + }, ], // Filter by project IDs, project IDs are OR combined 'properties.project_ids' => [ @@ -90,7 +99,14 @@ public function rules(): array ], 'properties.project_ids.*' => [ 'string', - 'uuid', + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + if (! Str::isUuid($value)) { + $fail('The '.$attribute.' must be a valid UUID.'); + } + }, ], // Filter by tag IDs, tag IDs are OR combined 'properties.tag_ids' => [ @@ -99,7 +115,14 @@ public function rules(): array ], 'properties.tag_ids.*' => [ 'string', - 'uuid', + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + if (! Str::isUuid($value)) { + $fail('The '.$attribute.' must be a valid UUID.'); + } + }, ], 'properties.task_ids' => [ 'nullable', @@ -107,7 +130,14 @@ public function rules(): array ], 'properties.task_ids.*' => [ 'string', - 'uuid', + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + if (! Str::isUuid($value)) { + $fail('The '.$attribute.' must be a valid UUID.'); + } + }, ], 'properties.group' => [ 'required', diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php index 35f845194..a356198c9 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php @@ -16,6 +16,7 @@ use App\Models\Tag; use App\Models\Task; use App\Models\User; +use App\Service\TimeEntryFilter; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; @@ -30,7 +31,7 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest /** * Get the validation rules that apply to the request. * - * @return array> + * @return array> */ public function rules(): array { @@ -94,10 +95,15 @@ public function rules(): array ], 'project_ids.*' => [ 'string', - ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->whereBelongsTo($this->organization, 'organization'); - })->uuid(), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter by client IDs, client IDs are OR combined 'client_ids' => [ @@ -106,10 +112,15 @@ public function rules(): array ], 'client_ids.*' => [ 'string', - ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->whereBelongsTo($this->organization, 'organization'); - })->uuid(), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter by tag IDs, tag IDs are OR combined 'tag_ids' => [ @@ -118,10 +129,15 @@ public function rules(): array ], 'tag_ids.*' => [ 'string', - ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->whereBelongsTo($this->organization, 'organization'); - })->uuid(), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter by task IDs, task IDs are OR combined 'task_ids' => [ @@ -130,9 +146,14 @@ public function rules(): array ], 'task_ids.*' => [ 'string', - ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { - return $builder->whereBelongsTo($this->organization, 'organization'); - })->uuid(), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z) 'start' => [ diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php index 39c9270e6..92378f82a 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php @@ -14,6 +14,7 @@ use App\Models\Tag; use App\Models\Task; use App\Models\User; +use App\Service\TimeEntryFilter; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; @@ -28,7 +29,7 @@ class TimeEntryAggregateRequest extends BaseFormRequest /** * Get the validation rules that apply to the request. * - * @return array> + * @return array> */ public function rules(): array { @@ -80,10 +81,15 @@ public function rules(): array ], 'project_ids.*' => [ 'string', - ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->whereBelongsTo($this->organization, 'organization'); - })->uuid(), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter by client IDs, client IDs are OR combined 'client_ids' => [ @@ -92,10 +98,15 @@ public function rules(): array ], 'client_ids.*' => [ 'string', - ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->whereBelongsTo($this->organization, 'organization'); - })->uuid(), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter by tag IDs, tag IDs are OR combined 'tag_ids' => [ @@ -104,10 +115,15 @@ public function rules(): array ], 'tag_ids.*' => [ 'string', - ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->whereBelongsTo($this->organization, 'organization'); - })->uuid(), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter by task IDs, task IDs are OR combined 'task_ids' => [ @@ -116,9 +132,14 @@ public function rules(): array ], 'task_ids.*' => [ 'string', - ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { - return $builder->whereBelongsTo($this->organization, 'organization'); - })->uuid(), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z) 'start' => [ diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php index 6c3180b26..704476099 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php @@ -6,11 +6,13 @@ use App\Enums\ExportFormat; use App\Enums\TimeEntryRoundingType; +use App\Models\Client; use App\Models\Member; use App\Models\Organization; use App\Models\Project; use App\Models\Tag; use App\Models\Task; +use App\Service\TimeEntryFilter; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; @@ -25,7 +27,7 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest /** * Get the validation rules that apply to the request. * - * @return array> + * @return array> */ public function rules(): array { @@ -57,6 +59,23 @@ public function rules(): array return $builder->whereBelongsTo($this->organization, 'organization'); }), ], + // Filter by client IDs, client IDs are OR combined + 'client_ids' => [ + 'array', + 'min:1', + ], + 'client_ids.*' => [ + 'string', + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, + ], // Filter by project IDs, project IDs are OR combined 'project_ids' => [ 'array', @@ -64,11 +83,15 @@ public function rules(): array ], 'project_ids.*' => [ 'string', - 'uuid', - new ExistsEloquent(Project::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->whereBelongsTo($this->organization, 'organization'); - }), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter by tag IDs, tag IDs are OR combined 'tag_ids' => [ @@ -77,11 +100,15 @@ public function rules(): array ], 'tag_ids.*' => [ 'string', - 'uuid', - new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->whereBelongsTo($this->organization, 'organization'); - }), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter by task IDs, task IDs are OR combined 'task_ids' => [ @@ -90,11 +117,15 @@ public function rules(): array ], 'task_ids.*' => [ 'string', - 'uuid', - new ExistsEloquent(Task::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->whereBelongsTo($this->organization, 'organization'); - }), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z) 'start' => [ diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php index 2c6dd61e4..230e5134f 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php @@ -12,6 +12,7 @@ use App\Models\Project; use App\Models\Tag; use App\Models\Task; +use App\Service\TimeEntryFilter; use Illuminate\Contracts\Validation\Rule as RuleContract; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; @@ -26,7 +27,7 @@ class TimeEntryIndexRequest extends BaseFormRequest /** * Get the validation rules that apply to the request. * - * @return array> + * @return array> */ public function rules(): array { @@ -58,10 +59,15 @@ public function rules(): array ], 'client_ids.*' => [ 'string', - ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->whereBelongsTo($this->organization, 'organization'); - })->uuid(), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter by project IDs, project IDs are OR combined 'project_ids' => [ @@ -70,10 +76,15 @@ public function rules(): array ], 'project_ids.*' => [ 'string', - ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->whereBelongsTo($this->organization, 'organization'); - })->uuid(), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter by tag IDs, tag IDs are OR combined 'tag_ids' => [ @@ -82,10 +93,15 @@ public function rules(): array ], 'tag_ids.*' => [ 'string', - ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->whereBelongsTo($this->organization, 'organization'); - })->uuid(), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter by task IDs, task IDs are OR combined 'task_ids' => [ @@ -94,10 +110,15 @@ public function rules(): array ], 'task_ids.*' => [ 'string', - ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->whereBelongsTo($this->organization, 'organization'); - })->uuid(), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z) 'start' => [ diff --git a/app/Service/Dto/ReportPropertiesDto.php b/app/Service/Dto/ReportPropertiesDto.php index ac056d0f3..a3ff85dbb 100644 --- a/app/Service/Dto/ReportPropertiesDto.php +++ b/app/Service/Dto/ReportPropertiesDto.php @@ -8,6 +8,7 @@ use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryRoundingType; use App\Enums\Weekday; +use App\Service\TimeEntryFilter; use Illuminate\Contracts\Database\Eloquent\Castable; use Illuminate\Contracts\Database\Eloquent\CastsAttributes; use Illuminate\Database\Eloquent\Model; @@ -174,7 +175,7 @@ public static function idArrayToCollection(array $ids): Collection if (! is_string($id)) { throw new \InvalidArgumentException('The given ID is not a string'); } - if (! Str::isUuid($id)) { + if ($id !== TimeEntryFilter::NONE_VALUE && ! Str::isUuid($id)) { throw new \InvalidArgumentException('The given ID is not a valid UUID'); } $collection->push($id); diff --git a/app/Service/TimeEntryFilter.php b/app/Service/TimeEntryFilter.php index 160adddc3..02fbe6891 100644 --- a/app/Service/TimeEntryFilter.php +++ b/app/Service/TimeEntryFilter.php @@ -12,6 +12,8 @@ class TimeEntryFilter { + public const string NONE_VALUE = 'none'; + /** * @var Builder */ @@ -149,7 +151,17 @@ public function addClientIdsFilter(?array $clientIds): self if ($clientIds === null) { return $this; } - $this->builder->whereIn('client_id', $clientIds); + $includeNone = in_array(self::NONE_VALUE, $clientIds, true); + $clientIds = array_values(array_filter($clientIds, fn (string $id): bool => $id !== self::NONE_VALUE)); + + $this->builder->where(function (Builder $builder) use ($clientIds, $includeNone): void { + if (count($clientIds) > 0) { + $builder->whereIn('client_id', $clientIds); + } + if ($includeNone) { + $builder->orWhereNull('client_id'); + } + }); return $this; } @@ -162,7 +174,17 @@ public function addProjectIdsFilter(?array $projectIds): self if ($projectIds === null) { return $this; } - $this->builder->whereIn('project_id', $projectIds); + $includeNone = in_array(self::NONE_VALUE, $projectIds, true); + $projectIds = array_values(array_filter($projectIds, fn (string $id): bool => $id !== self::NONE_VALUE)); + + $this->builder->where(function (Builder $builder) use ($projectIds, $includeNone): void { + if (count($projectIds) > 0) { + $builder->whereIn('project_id', $projectIds); + } + if ($includeNone) { + $builder->orWhereNull('project_id'); + } + }); return $this; } @@ -175,10 +197,18 @@ public function addTagIdsFilter(?array $tagIds): self if ($tagIds === null) { return $this; } - $this->builder->where(function (Builder $builder) use ($tagIds): void { + $includeNone = in_array(self::NONE_VALUE, $tagIds, true); + $tagIds = array_values(array_filter($tagIds, fn (string $id): bool => $id !== self::NONE_VALUE)); + + $this->builder->where(function (Builder $builder) use ($tagIds, $includeNone): void { foreach ($tagIds as $tagId) { $builder->orWhereJsonContains('tags', $tagId); } + if ($includeNone) { + $builder->orWhere(function (Builder $query): void { + $query->whereJsonLength('tags', 0)->orWhereNull('tags'); + }); + } }); return $this; @@ -192,7 +222,17 @@ public function addTaskIdsFilter(?array $taskIds): self if ($taskIds === null) { return $this; } - $this->builder->whereIn('task_id', $taskIds); + $includeNone = in_array(self::NONE_VALUE, $taskIds, true); + $taskIds = array_values(array_filter($taskIds, fn (string $id): bool => $id !== self::NONE_VALUE)); + + $this->builder->where(function (Builder $builder) use ($taskIds, $includeNone): void { + if (count($taskIds) > 0) { + $builder->whereIn('task_id', $taskIds); + } + if ($includeNone) { + $builder->orWhereNull('task_id'); + } + }); return $this; } diff --git a/docker-compose.yml b/docker-compose.yml index 6974efb1a..99148a2ed 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -107,7 +107,7 @@ services: - sail - reverse-proxy playwright: - image: mcr.microsoft.com/playwright:v1.57.0-jammy + image: mcr.microsoft.com/playwright:v1.58.1-jammy command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0'] working_dir: /src extra_hosts: diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts index 634e71ead..b0d7720dc 100644 --- a/e2e/auth.spec.ts +++ b/e2e/auth.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; +import { getPasswordResetUrl } from './utils/mailpit'; async function registerNewUser(page, email, password) { await page.goto(PLAYWRIGHT_BASE_URL + '/register'); @@ -35,14 +36,198 @@ test('can register and delete account', async ({ page }) => { await registerNewUser(page, email, password); await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); await page.getByRole('button', { name: 'Delete Account' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); await page.getByPlaceholder('Password').fill(password); - await page.getByRole('button', { name: 'Delete Account' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Delete Account' }).click(); await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login'); await page.goto(PLAYWRIGHT_BASE_URL + '/login'); await page.getByLabel('Email').fill(email); await page.getByLabel('Password').fill(password); await page.getByRole('button', { name: 'Log in' }).click(); - await expect(page.getByRole('paragraph')).toContainText( + await expect(page.getByRole('alert')).toContainText( 'These credentials do not match our records.' ); }); + +test('shows error for invalid email on forgot password', async ({ page }) => { + await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password'); + + // Request password reset with non-existent email + await page.getByLabel('Email').fill('nonexistent@example.com'); + await page.getByRole('button', { name: 'Email Password Reset Link' }).click(); + + // Should show error message + await expect(page.getByText("We can't find a user with that email address.")).toBeVisible(); +}); + +test('shows browser validation for invalid email format on forgot password', async ({ page }) => { + await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password'); + + // Request password reset with invalid email format + const emailInput = page.getByLabel('Email'); + await emailInput.fill('notanemail'); + + // Check for browser validation - the input should be invalid + const isInvalid = await emailInput.evaluate((el: HTMLInputElement) => !el.validity.valid); + expect(isInvalid).toBe(true); +}); + +test('shows browser validation for empty email on forgot password', async ({ page }) => { + await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password'); + + // The email input is required, so it should be invalid when empty + const emailInput = page.getByLabel('Email'); + + // Check for browser validation - the input should be invalid because it's required and empty + const isInvalid = await emailInput.evaluate((el: HTMLInputElement) => el.validity.valueMissing); + expect(isInvalid).toBe(true); +}); + +test('can reset password via email link', async ({ page, request }) => { + // First register a new user + const email = `john+${Math.round(Math.random() * 10000)}@doe.com`; + const originalPassword = 'suchagreatpassword123'; + const newPassword = 'mynewsecurepassword456'; + await registerNewUser(page, email, originalPassword); + + // Log out + await page.getByTestId('current_user_button').click(); + await page.getByText('Log Out').click(); + await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login'); + + // Request password reset + await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password'); + await page.getByLabel('Email').fill(email); + await page.getByRole('button', { name: 'Email Password Reset Link' }).click(); + await expect(page.getByText('We have emailed your password reset link.')).toBeVisible(); + + // Get password reset URL from email + const resetUrl = await getPasswordResetUrl(request, email); + + // Navigate to reset page + await page.goto(resetUrl); + + // Fill in new password + await page.getByLabel('Password', { exact: true }).fill(newPassword); + await page.getByLabel('Confirm Password').fill(newPassword); + await page.getByRole('button', { name: 'Reset Password' }).click(); + + // Should redirect to login page after successful reset + await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login'); + + // Try logging in with new password + await page.getByLabel('Email').fill(email); + await page.getByLabel('Password').fill(newPassword); + await page.getByRole('button', { name: 'Log in' }).click(); + await expect(page.getByTestId('dashboard_view')).toBeVisible(); +}); + +test('shows validation error for password mismatch on reset', async ({ page, request }) => { + // First register a new user + const email = `john+${Math.round(Math.random() * 10000)}@doe.com`; + const originalPassword = 'suchagreatpassword123'; + await registerNewUser(page, email, originalPassword); + + // Log out + await page.getByTestId('current_user_button').click(); + await page.getByText('Log Out').click(); + await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login'); + + // Request password reset + await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password'); + await page.getByLabel('Email').fill(email); + await page.getByRole('button', { name: 'Email Password Reset Link' }).click(); + await expect(page.getByText('We have emailed your password reset link.')).toBeVisible(); + + // Get password reset URL from email + const resetUrl = await getPasswordResetUrl(request, email); + + // Navigate to reset page + await page.goto(resetUrl); + + // Fill in mismatched passwords + await page.getByLabel('Password', { exact: true }).fill('newpassword123'); + await page.getByLabel('Confirm Password').fill('differentpassword456'); + await page.getByRole('button', { name: 'Reset Password' }).click(); + + // Should show validation error + await expect(page.getByText('The password field confirmation does not match.')).toBeVisible(); +}); + +test('shows validation error for short password on reset', async ({ page, request }) => { + // First register a new user + const email = `john+${Math.round(Math.random() * 10000)}@doe.com`; + const originalPassword = 'suchagreatpassword123'; + await registerNewUser(page, email, originalPassword); + + // Log out + await page.getByTestId('current_user_button').click(); + await page.getByText('Log Out').click(); + await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login'); + + // Request password reset + await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password'); + await page.getByLabel('Email').fill(email); + await page.getByRole('button', { name: 'Email Password Reset Link' }).click(); + await expect(page.getByText('We have emailed your password reset link.')).toBeVisible(); + + // Get password reset URL from email + const resetUrl = await getPasswordResetUrl(request, email); + + // Navigate to reset page + await page.goto(resetUrl); + + // Fill in short password + await page.getByLabel('Password', { exact: true }).fill('short'); + await page.getByLabel('Confirm Password').fill('short'); + await page.getByRole('button', { name: 'Reset Password' }).click(); + + // Should show validation error about minimum length + await expect(page.getByText('must be at least')).toBeVisible(); +}); + +test('shows error for invalid login credentials', async ({ page }) => { + await page.goto(PLAYWRIGHT_BASE_URL + '/login'); + await page.getByLabel('Email').fill('nonexistent@example.com'); + await page.getByLabel('Password').fill('wrongpassword123'); + await page.getByRole('button', { name: 'Log in' }).click(); + + await expect(page.getByText('These credentials do not match our records.')).toBeVisible(); +}); + +test('shows error when registering with existing email', async ({ page }) => { + const email = `john+${Math.round(Math.random() * 10000)}@doe.com`; + const password = 'suchagreatpassword123'; + + // Register first user + await registerNewUser(page, email, password); + + // Log out + await page.getByTestId('current_user_button').click(); + await page.getByText('Log Out').click(); + await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login'); + + // Try to register with the same email + await page.goto(PLAYWRIGHT_BASE_URL + '/register'); + await page.getByLabel('Name').fill('Another User'); + await page.getByLabel('Email').fill(email); + await page.getByLabel('Password', { exact: true }).fill(password); + await page.getByLabel('Confirm Password').fill(password); + await page.getByLabel('I agree to the Terms of').click(); + await page.getByRole('button', { name: 'Register' }).click(); + + // Should show error about email already taken + await expect(page.getByText('The resource already exists.')).toBeVisible(); +}); + +test('shows validation error for weak password on registration', async ({ page }) => { + await page.goto(PLAYWRIGHT_BASE_URL + '/register'); + await page.getByLabel('Name').fill('Weak Password User'); + await page.getByLabel('Email').fill(`weak+${Math.round(Math.random() * 10000)}@test.com`); + await page.getByLabel('Password', { exact: true }).fill('short'); + await page.getByLabel('Confirm Password').fill('short'); + await page.getByLabel('I agree to the Terms of').click(); + await page.getByRole('button', { name: 'Register' }).click(); + + await expect(page.getByText('must be at least')).toBeVisible(); +}); diff --git a/e2e/calendar.spec.ts b/e2e/calendar.spec.ts new file mode 100644 index 000000000..ec2b5b2bc --- /dev/null +++ b/e2e/calendar.spec.ts @@ -0,0 +1,326 @@ +import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; +import { test } from '../playwright/fixtures'; +import { expect } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { + createBillableProjectViaApi, + createProjectViaApi, + createBareTimeEntryViaApi, + createTimeEntryViaApi, +} from './utils/api'; + +async function goToCalendar(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/calendar'); +} + +/** + * These tests verify that changing the project on a time entry via the calendar + * updates the billable status to match the new project's is_billable setting. + * + * Issue: https://github.com/solidtime-io/solidtime/issues/981 + */ + +test('test that changing project in calendar edit modal from non-billable to billable updates billable status', async ({ + page, + ctx, +}) => { + const billableProjectName = 'Billable Cal Project ' + Math.floor(1 + Math.random() * 10000); + + await createBillableProjectViaApi(ctx, { name: billableProjectName }); + await createBareTimeEntryViaApi(ctx, 'Test billable calendar', '1h'); + + await goToCalendar(page); + + // Click on the time entry event in the calendar + await page.locator('.fc-event').filter({ hasText: 'Test billable calendar' }).first().click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // Verify initially non-billable + await expect( + page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' }) + ).toBeVisible(); + + // Select the billable project + await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click(); + await page.getByRole('option', { name: billableProjectName }).click(); + + // Verify the billable dropdown updated to Billable + await expect( + page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' }) + ).toBeVisible(); + + // Save and verify + const [updateResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries/') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page.getByRole('button', { name: 'Update Time Entry' }).click(), + ]); + const responseBody = await updateResponse.json(); + expect(responseBody.data.billable).toBe(true); +}); + +test('test that changing project in calendar edit modal from billable to non-billable updates billable status', async ({ + page, + ctx, +}) => { + const billableProjectName = 'Billable Cal Rev Project ' + Math.floor(1 + Math.random() * 10000); + const nonBillableProjectName = + 'NonBillable Cal Rev Project ' + Math.floor(1 + Math.random() * 10000); + + await createBillableProjectViaApi(ctx, { name: billableProjectName }); + await createProjectViaApi(ctx, { name: nonBillableProjectName }); + await createBareTimeEntryViaApi(ctx, 'Test billable cal reverse', '1h'); + + await goToCalendar(page); + + // Click on the time entry event in the calendar + await page + .locator('.fc-event') + .filter({ hasText: 'Test billable cal reverse' }) + .first() + .click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // First assign the billable project + await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click(); + await page.getByRole('option', { name: billableProjectName }).click(); + + // Verify billable status flipped to Billable + await expect( + page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' }) + ).toBeVisible(); + + // Now switch to the non-billable project + await page.getByRole('dialog').getByRole('button', { name: billableProjectName }).click(); + await page.getByRole('option', { name: nonBillableProjectName }).click(); + + // Verify billable status reverted to Non-Billable + await expect( + page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' }) + ).toBeVisible(); + + // Save and verify + const [updateResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries/') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page.getByRole('button', { name: 'Update Time Entry' }).click(), + ]); + const responseBody = await updateResponse.json(); + expect(responseBody.data.billable).toBe(false); +}); + +test('test that opening calendar edit modal for a time entry with manually overridden billable status preserves that status', async ({ + page, + ctx, +}) => { + const billableProjectName = + 'Billable Cal Persist Project ' + Math.floor(1 + Math.random() * 10000); + + await createBillableProjectViaApi(ctx, { name: billableProjectName }); + await createBareTimeEntryViaApi(ctx, 'Test cal persist override', '1h'); + + await goToCalendar(page); + + // Click on the time entry event in the calendar + await page + .locator('.fc-event') + .filter({ hasText: 'Test cal persist override' }) + .first() + .click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // Assign the billable project + await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click(); + await page.getByRole('option', { name: billableProjectName }).click(); + + // Verify it auto-set to Billable + await expect( + page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' }) + ).toBeVisible(); + + // Now manually override billable to Non-Billable via the dropdown + await page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' }).click(); + await page.getByRole('option', { name: 'Non Billable' }).click(); + + // Verify it shows Non-Billable now + await expect( + page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' }) + ).toBeVisible(); + + // Save + const [firstSaveResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries/') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page.getByRole('button', { name: 'Update Time Entry' }).click(), + ]); + const firstBody = await firstSaveResponse.json(); + expect(firstBody.data.billable).toBe(false); + + // Re-open the edit modal from the calendar — the project_id watcher should NOT override billable + await page + .locator('.fc-event') + .filter({ hasText: 'Test cal persist override' }) + .first() + .click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // The billable dropdown should still show Non-Billable + await expect( + page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' }) + ).toBeVisible(); + + // Save without changes and verify the response still has billable=false + const [updateResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries/') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page.getByRole('button', { name: 'Update Time Entry' }).click(), + ]); + const responseBody = await updateResponse.json(); + expect(responseBody.data.billable).toBe(false); +}); + +test('test that calendar page loads and displays time entries', async ({ page, ctx }) => { + await createBareTimeEntryViaApi(ctx, 'Calendar display test', '1h'); + + await goToCalendar(page); + + // Calendar container should be visible + await expect(page.locator('.fc')).toBeVisible(); + + // The time entry should appear as a calendar event + await expect( + page.locator('.fc-event').filter({ hasText: 'Calendar display test' }).first() + ).toBeVisible(); +}); + +test('test that calendar navigation buttons work', async ({ page }) => { + await goToCalendar(page); + await expect(page.locator('.fc')).toBeVisible(); + + // Click the "next" button to navigate forward + await page.locator('button.fc-next-button').click(); + await expect(page.locator('.fc')).toBeVisible(); + + // Click the "prev" button to navigate back + await page.locator('button.fc-prev-button').click(); + await expect(page.locator('.fc')).toBeVisible(); + + // Navigate forward first so "today" button becomes enabled, then click it + await page.locator('button.fc-next-button').click(); + await page.locator('button.fc-today-button').click(); + await expect(page.locator('.fc')).toBeVisible(); +}); + +test('test that editing time entry description via calendar modal works', async ({ page, ctx }) => { + const originalDescription = 'Edit me in calendar ' + Math.floor(1 + Math.random() * 10000); + const updatedDescription = 'Updated in calendar ' + Math.floor(1 + Math.random() * 10000); + await createBareTimeEntryViaApi(ctx, originalDescription, '1h'); + + await goToCalendar(page); + + // Click on the time entry event + await page.locator('.fc-event').filter({ hasText: originalDescription }).first().click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // Update the description (edit modal uses placeholder, not data-testid) + const descriptionInput = page.getByRole('dialog').getByPlaceholder('What did you work on?'); + await descriptionInput.fill(updatedDescription); + + // Save and verify + const [editResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries/') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page.getByRole('button', { name: 'Update Time Entry' }).click(), + ]); + const editBody = await editResponse.json(); + expect(editBody.data.description).toBe(updatedDescription); + + // Verify the updated description is shown in the calendar UI + await expect( + page.locator('.fc-event').filter({ hasText: updatedDescription }).first() + ).toBeVisible(); + // Verify the old description is no longer shown + await expect( + page.locator('.fc-event').filter({ hasText: originalDescription }) + ).not.toBeVisible(); +}); + +test('test that deleting time entry from calendar modal works', async ({ page, ctx }) => { + const description = 'Delete me from calendar ' + Math.floor(1 + Math.random() * 10000); + await createBareTimeEntryViaApi(ctx, description, '1h'); + + await goToCalendar(page); + + // Click on the time entry event + await page.locator('.fc-event').filter({ hasText: description }).first().click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // Click the delete button + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries/') && + response.request().method() === 'DELETE' && + response.status() === 204 + ), + page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(), + ]); + + // Verify the event is removed from the calendar + await expect(page.locator('.fc-event').filter({ hasText: description })).not.toBeVisible(); +}); + +// ============================================= +// Employee Permission Tests +// ============================================= + +test.describe('Employee Calendar Isolation', () => { + test('employee can only see their own time entries on the calendar', async ({ + ctx, + employee, + }) => { + // Owner creates a time entry for today + const ownerDescription = 'OwnerCalEntry ' + Math.floor(Math.random() * 10000); + await createBareTimeEntryViaApi(ctx, ownerDescription, '1h'); + + // Create a time entry for the employee for today + const employeeDescription = 'EmpCalEntry ' + Math.floor(Math.random() * 10000); + await createTimeEntryViaApi( + { ...ctx, memberId: employee.memberId }, + { description: employeeDescription, duration: '30min' } + ); + + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/calendar'); + await expect(employee.page.locator('.fc')).toBeVisible({ timeout: 10000 }); + + // Employee's event IS visible + await expect( + employee.page.locator('.fc-event').filter({ hasText: employeeDescription }).first() + ).toBeVisible({ timeout: 10000 }); + + // Owner's event is NOT visible + await expect( + employee.page.locator('.fc-event').filter({ hasText: ownerDescription }) + ).not.toBeVisible(); + }); +}); diff --git a/e2e/clients.spec.ts b/e2e/clients.spec.ts index dab9c81f8..761f215ee 100644 --- a/e2e/clients.spec.ts +++ b/e2e/clients.spec.ts @@ -1,15 +1,22 @@ -import { expect, Page } from '@playwright/test'; +import { expect } from '@playwright/test'; +import type { Page } from '@playwright/test'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { test } from '../playwright/fixtures'; +import { + createClientViaApi, + createProjectMemberViaApi, + createProjectViaApi, + createPublicProjectViaApi, +} from './utils/api'; -async function goToProjectsOverview(page: Page) { +async function goToClientsOverview(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/clients'); } -// Create new project via modal +// Create new client via modal test('test that creating and deleting a new client via the modal works', async ({ page }) => { const newClientName = 'New Project ' + Math.floor(1 + Math.random() * 10000); - await goToProjectsOverview(page); + await goToClientsOverview(page); await page.getByRole('button', { name: 'Create Client' }).click(); await page.getByPlaceholder('Client Name').fill(newClientName); await Promise.all([ @@ -26,7 +33,7 @@ test('test that creating and deleting a new client via the modal works', async ( await expect(page.getByTestId('client_table')).toContainText(newClientName); const moreButton = page.locator("[aria-label='Actions for Client " + newClientName + "']"); - moreButton.click(); + await moreButton.click(); const deleteButton = page.locator("[aria-label='Delete Client " + newClientName + "']"); await Promise.all([ @@ -41,13 +48,11 @@ test('test that creating and deleting a new client via the modal works', async ( await expect(page.getByTestId('client_table')).not.toContainText(newClientName); }); -test('test that archiving and unarchiving clients works', async ({ page }) => { +test('test that archiving and unarchiving clients works', async ({ page, ctx }) => { const newClientName = 'New Client ' + Math.floor(1 + Math.random() * 10000); - await goToProjectsOverview(page); - await page.getByRole('button', { name: 'Create Client' }).click(); - await page.getByLabel('Client Name').fill(newClientName); + await createClientViaApi(ctx, { name: newClientName }); - await page.getByRole('button', { name: 'Create Client' }).click(); + await goToClientsOverview(page); await expect(page.getByText(newClientName)).toBeVisible(); await page.getByRole('row').first().getByRole('button').click(); @@ -71,4 +76,140 @@ test('test that archiving and unarchiving clients works', async ({ page }) => { ]); }); -// TODO: Add Name Update Test +test('test that editing a client name works', async ({ page, ctx }) => { + const originalName = 'Original Client ' + Math.floor(1 + Math.random() * 10000); + const updatedName = 'Updated Client ' + Math.floor(1 + Math.random() * 10000); + await createClientViaApi(ctx, { name: originalName }); + + await goToClientsOverview(page); + await expect(page.getByText(originalName)).toBeVisible(); + + // Open edit modal via actions menu + const moreButton = page.locator("[aria-label='Actions for Client " + originalName + "']"); + await moreButton.click(); + await page.getByTestId('client_edit').click(); + + // Update the client name + await page.getByPlaceholder('Client Name').fill(updatedName); + await Promise.all([ + page.getByRole('button', { name: 'Update Client' }).click(), + page.waitForResponse( + async (response) => + response.url().includes('/clients') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + ]); + + // Verify updated name is shown and old name is gone + await expect(page.getByTestId('client_table')).toContainText(updatedName); + await expect(page.getByTestId('client_table')).not.toContainText(originalName); +}); + +test('test that deleting a client via actions menu works', async ({ page, ctx }) => { + const clientName = 'DeleteMe Client ' + Math.floor(1 + Math.random() * 10000); + + await createClientViaApi(ctx, { name: clientName }); + + await goToClientsOverview(page); + await expect(page.getByTestId('client_table')).toContainText(clientName); + + const moreButton = page.locator("[aria-label='Actions for Client " + clientName + "']"); + await moreButton.click(); + const deleteButton = page.locator("[aria-label='Delete Client " + clientName + "']"); + + await Promise.all([ + deleteButton.click(), + page.waitForResponse( + (response) => + response.url().includes('/clients') && + response.request().method() === 'DELETE' && + response.status() === 204 + ), + ]); + + await expect(page.getByTestId('client_table')).not.toContainText(clientName); +}); + +// ============================================= +// Employee Permission Tests +// ============================================= + +test.describe('Employee Clients Restrictions', () => { + test('employee can view clients but cannot create', async ({ ctx, employee }) => { + // Create a client with a public project so the employee can see the client + const clientName = 'EmpViewClient ' + Math.floor(Math.random() * 10000); + const client = await createClientViaApi(ctx, { name: clientName }); + await createPublicProjectViaApi(ctx, { name: 'EmpClientProj', client_id: client.id }); + + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients'); + await expect(employee.page.getByTestId('clients_view')).toBeVisible({ + timeout: 10000, + }); + + // Employee can see the client + await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 }); + + // Employee cannot see Create Client button + await expect( + employee.page.getByRole('button', { name: 'Create Client' }) + ).not.toBeVisible(); + }); + + test('employee cannot see edit/delete/archive actions on clients', async ({ + ctx, + employee, + }) => { + const clientName = 'EmpActionsClient ' + Math.floor(Math.random() * 10000); + const client = await createClientViaApi(ctx, { name: clientName }); + await createPublicProjectViaApi(ctx, { name: 'EmpClientActProj', client_id: client.id }); + + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients'); + await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 }); + + // Click the actions dropdown trigger to open the menu + const actionsButton = employee.page.locator( + `[aria-label='Actions for Client ${clientName}']` + ); + await actionsButton.click(); + + // The dropdown menu items (Edit, Archive, Delete) should NOT be visible + await expect( + employee.page.locator(`[aria-label='Edit Client ${clientName}']`) + ).not.toBeVisible(); + await expect( + employee.page.locator(`[aria-label='Archive Client ${clientName}']`) + ).not.toBeVisible(); + await expect( + employee.page.locator(`[aria-label='Delete Client ${clientName}']`) + ).not.toBeVisible(); + }); + + test('employee can see client when they are a member of its private project', async ({ + ctx, + employee, + }) => { + const clientName = 'EmpPrivateClient ' + Math.floor(Math.random() * 10000); + const client = await createClientViaApi(ctx, { name: clientName }); + + // Create a private project under this client + const project = await createProjectViaApi(ctx, { + name: 'PrivateProj', + client_id: client.id, + is_public: false, + }); + + // Add the employee as a project member + await createProjectMemberViaApi(ctx, project.id, { + member_id: employee.memberId, + }); + + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients'); + await expect(employee.page.getByTestId('clients_view')).toBeVisible({ + timeout: 10000, + }); + + // Employee can see the client because they are a member of its private project + await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/e2e/command-palette.spec.ts b/e2e/command-palette.spec.ts new file mode 100644 index 000000000..013f01380 --- /dev/null +++ b/e2e/command-palette.spec.ts @@ -0,0 +1,468 @@ +import { expect, test } from '../playwright/fixtures'; +import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; +import type { Page } from '@playwright/test'; + +const TIMER_BUTTON_SELECTOR = '[data-testid="dashboard_timer"] [data-testid="timer_button"]'; + +async function goToDashboard(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); +} + +async function openCommandPalette(page: Page) { + await page.getByTestId('command_palette_button').click(); + await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 }); +} + +async function closeCommandPalette(page: Page) { + await page.keyboard.press('Escape'); + await expect(page.locator('[role="dialog"]')).not.toBeVisible(); +} + +async function searchInCommandPalette(page: Page, query: string) { + await page.locator('[role="dialog"] input').fill(query); + // Wait for search debounce to settle (command palette uses a debounced search) + await page.waitForTimeout(300); +} + +async function selectCommand(page: Page, name: string) { + const option = page.getByRole('option', { name, exact: true }); + await option.scrollIntoViewIfNeeded(); + await option.click(); +} + +async function assertTimerIsRunning(page: Page) { + await expect(page.locator(TIMER_BUTTON_SELECTOR)).toHaveClass(/bg-red-400\/80/, { + timeout: 10000, + }); +} + +async function assertTimerIsStopped(page: Page) { + await expect(page.locator(TIMER_BUTTON_SELECTOR)).toHaveClass(/bg-accent-300\/70/, { + timeout: 10000, + }); +} + +test.describe('Command Palette', () => { + test.describe('Opening and Closing', () => { + test('opens via search button and closes with Escape', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + await expect( + page.locator('[role="dialog"] input[placeholder*="command"]') + ).toBeVisible(); + + await closeCommandPalette(page); + await expect(page.locator('[role="dialog"]')).not.toBeVisible(); + }); + + test('opens with keyboard shortcut', async ({ page }) => { + await goToDashboard(page); + // Click on body to ensure page has focus + await page.locator('body').click(); + // Use ControlOrMeta which resolves to Ctrl on Linux/Windows and Meta on macOS + await page.keyboard.press('ControlOrMeta+k'); + await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 }); + }); + + test('clears search on close', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + await searchInCommandPalette(page, 'dashboard'); + await closeCommandPalette(page); + + await openCommandPalette(page); + await expect(page.locator('[role="dialog"] input')).toHaveValue(''); + }); + }); + + test.describe('Command Display', () => { + test('displays navigation and timer commands', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + + // Navigation commands + await expect(page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible(); + await expect(page.getByRole('option', { name: 'Go to Time' })).toBeVisible(); + await expect(page.getByRole('option', { name: 'Go to Calendar' })).toBeVisible(); + + // Timer commands + await expect(page.getByRole('option', { name: 'Start Timer' })).toBeVisible(); + await expect(page.getByRole('option', { name: 'Create Time Entry' })).toBeVisible(); + }); + + test('displays create commands', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + + await expect(page.getByRole('option', { name: 'Create Project' })).toBeVisible(); + await expect(page.getByRole('option', { name: 'Create Client' })).toBeVisible(); + await expect(page.getByRole('option', { name: 'Create Tag' })).toBeVisible(); + }); + }); + + test.describe('Navigation Commands', () => { + // Tests use element visibility assertions for consistency with codebase patterns + const navigationTests = [ + ['Go to Dashboard', 'dashboard_view', '/time'], + ['Go to Time', 'time_view', '/dashboard'], + ['Go to Calendar', 'calendar_view', '/dashboard'], + ['Go to Projects', 'projects_view', '/dashboard'], + ['Go to Clients', 'clients_view', '/dashboard'], + ['Go to Members', 'members_view', '/dashboard'], + ['Go to Tags', 'tags_view', '/dashboard'], + ] as const; + + for (const [commandName, expectedTestId, startUrl] of navigationTests) { + test(`${commandName}`, async ({ page }) => { + await page.goto(PLAYWRIGHT_BASE_URL + startUrl); + await openCommandPalette(page); + await searchInCommandPalette(page, commandName.replace('Go to ', '')); + await selectCommand(page, commandName); + await expect(page.getByTestId(expectedTestId)).toBeVisible({ timeout: 10000 }); + }); + } + + test('Go to Profile', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + await searchInCommandPalette(page, 'Profile'); + await selectCommand(page, 'Go to Profile'); + // Profile page doesn't have a testId, so check for a unique element + await expect(page.getByRole('heading', { name: 'Profile Information' })).toBeVisible({ + timeout: 10000, + }); + }); + + test('Go to Reporting Overview', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + await searchInCommandPalette(page, 'Reporting Overview'); + await selectCommand(page, 'Go to Reporting Overview'); + await expect(page.getByTestId('reporting_view')).toBeVisible({ timeout: 10000 }); + }); + + test('Go to Settings', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + await searchInCommandPalette(page, 'Settings'); + await selectCommand(page, 'Go to Settings'); + // Settings page uses team settings which has an h3 heading + await expect( + page.getByRole('heading', { name: 'Organization Name', level: 3 }) + ).toBeVisible({ + timeout: 10000, + }); + }); + }); + + test.describe('Search and Filtering', () => { + test('filters commands when searching', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + + await searchInCommandPalette(page, 'dashboard'); + await expect(page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible(); + + await searchInCommandPalette(page, 'calendar'); + await expect(page.getByRole('option', { name: 'Go to Calendar' })).toBeVisible(); + }); + + test('search is case insensitive', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + + await searchInCommandPalette(page, 'DASHBOARD'); + await expect(page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible(); + }); + + test('partial word search works', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + + await searchInCommandPalette(page, 'proj'); + await expect(page.getByRole('option', { name: 'Go to Projects' })).toBeVisible(); + await expect(page.getByRole('option', { name: 'Create Project' })).toBeVisible(); + }); + + test('keyboard navigation and selection works', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + + await expect(page.locator('[role="dialog"]')).not.toBeVisible(); + }); + }); + + test.describe('Theme Commands', () => { + test('switches to dark theme', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + await searchInCommandPalette(page, 'Dark Theme'); + await selectCommand(page, 'Switch to Dark Theme'); + await expect(page.locator('html')).toHaveClass(/dark/); + }); + + test('switches to light theme', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + await searchInCommandPalette(page, 'Light Theme'); + await selectCommand(page, 'Switch to Light Theme'); + await expect(page.locator('html')).toHaveClass(/light/); + }); + }); + + test.describe('Timer Commands', () => { + test('starts and stops timer', async ({ page }) => { + await goToDashboard(page); + + // Start timer + await openCommandPalette(page); + await searchInCommandPalette(page, 'Start Timer'); + await selectCommand(page, 'Start Timer'); + await assertTimerIsRunning(page); + + // Stop timer + await openCommandPalette(page); + await searchInCommandPalette(page, 'Stop Timer'); + await selectCommand(page, 'Stop Timer'); + await assertTimerIsStopped(page); + }); + + test('shows active timer commands when running', async ({ page }) => { + await goToDashboard(page); + + // Start timer + await openCommandPalette(page); + await searchInCommandPalette(page, 'Start Timer'); + await selectCommand(page, 'Start Timer'); + await assertTimerIsRunning(page); + + // Check active timer commands - search for them to ensure visibility + await openCommandPalette(page); + await searchInCommandPalette(page, 'Set Project'); + await expect(page.getByRole('option', { name: 'Set Project' })).toBeVisible(); + }); + }); + + test.describe('Create Commands', () => { + test('opens create time entry modal', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + await searchInCommandPalette(page, 'Create Time Entry'); + await selectCommand(page, 'Create Time Entry'); + await expect( + page.locator('[role="dialog"]').getByText('Create manual time entry') + ).toBeVisible(); + }); + + test('opens create project modal', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + await searchInCommandPalette(page, 'Create Project'); + await selectCommand(page, 'Create Project'); + await expect( + page.locator('[role="dialog"]').getByRole('heading', { name: 'Create Project' }) + ).toBeVisible(); + }); + + test('opens create client modal', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + await searchInCommandPalette(page, 'Create Client'); + await selectCommand(page, 'Create Client'); + await expect( + page.locator('[role="dialog"]').getByRole('heading', { name: 'Create Client' }) + ).toBeVisible(); + }); + + test('opens create tag modal', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + await searchInCommandPalette(page, 'Create Tag'); + await selectCommand(page, 'Create Tag'); + await expect(page.locator('[role="dialog"]').getByText('Create Tags')).toBeVisible(); + }); + + test('opens invite member modal', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + await searchInCommandPalette(page, 'Invite Member'); + await selectCommand(page, 'Invite Member'); + // Modal has title with "Invite Member" text - use first() to get the title span + await expect( + page.locator('[role="dialog"]').getByText('Invite Member').first() + ).toBeVisible(); + }); + }); + + test.describe('Entity Search', () => { + test('searches for projects and navigates on selection', async ({ page }) => { + const projectName = 'CmdPalette' + Math.floor(Math.random() * 10000); + + // Create project first + await page.goto(PLAYWRIGHT_BASE_URL + '/projects'); + await page.getByRole('button', { name: 'Create Project' }).click(); + await page.getByPlaceholder('The next big thing').fill(projectName); + + await page.getByRole('button', { name: 'Create Project' }).click(); + // Wait for project to be created and page to update + await expect(page.getByText(projectName)).toBeVisible({ timeout: 10000 }); + + // Search from the projects page where the query cache now has the new project + await openCommandPalette(page); + await searchInCommandPalette(page, projectName); + + // Wait for entity search to return results + const projectOption = page.getByRole('option').filter({ hasText: projectName }); + await expect(projectOption).toBeVisible({ + timeout: 5000, + }); + + // Select the project from search results + await projectOption.click(); + }); + }); + + test.describe('Organization Switching', () => { + test('shows switch commands only when multiple organizations exist', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + + // With only one org, no switch commands should appear + await searchInCommandPalette(page, 'Switch to'); + // Check that no organization switch commands appear (only theme switch commands) + const switchOptions = page.getByRole('option', { name: /^Switch to (?!.*Theme)/ }); + await expect(switchOptions).toHaveCount(0); + }); + + test('switches organization via command palette', async ({ page }) => { + const newOrgName = 'TestOrg' + Math.floor(Math.random() * 10000); + + // Create a new organization + await page.goto(PLAYWRIGHT_BASE_URL + '/teams/create'); + await page.getByLabel('Organization Name').fill(newOrgName); + await page.getByRole('button', { name: 'Create' }).click(); + + // Wait for navigation to new org's dashboard + await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 }); + + // Use visible switcher (desktop sidebar has one, mobile header has another) + const orgSwitcher = page.locator('[data-testid="organization_switcher"]:visible'); + + // Verify we're in the new org by checking the switcher + await expect(orgSwitcher).toContainText(newOrgName); + + // Get the original org name from switcher dropdown + await orgSwitcher.click(); + await expect(page.getByText('Switch Organizations')).toBeVisible(); + + // Find the other organization button (has ArrowRightIcon, not CheckCircleIcon) + // The button contains an SVG and a div with the org name + const otherOrgItem = page.locator('form button').filter({ hasText: /.+/ }).first(); + await expect(otherOrgItem).toBeVisible(); + const originalOrgName = (await otherOrgItem.innerText()).trim(); + await page.keyboard.press('Escape'); // Close dropdown + + // Now use command palette to switch back to original org + await openCommandPalette(page); + await searchInCommandPalette(page, 'Switch to'); + + // Should see the switch command for the original org + const switchCommand = page.getByRole('option', { + name: new RegExp(`Switch to ${originalOrgName}`), + }); + await expect(switchCommand).toBeVisible(); + await switchCommand.click(); + + // Wait for organization switch to complete + await expect(orgSwitcher).toContainText(originalOrgName, { + timeout: 10000, + }); + }); + + test('organization switch commands appear in Organization group', async ({ page }) => { + const newOrgName = 'GroupTestOrg' + Math.floor(Math.random() * 10000); + + // Create a new organization to ensure we have multiple + await page.goto(PLAYWRIGHT_BASE_URL + '/teams/create'); + await page.getByLabel('Organization Name').fill(newOrgName); + await page.getByRole('button', { name: 'Create' }).click(); + await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 }); + + // Open command palette and check for Organization group heading + await openCommandPalette(page); + + // The Organization group should be visible when there are switch commands + await expect(page.getByText('Organization', { exact: true })).toBeVisible(); + }); + }); +}); + +// ============================================= +// Employee Permission Tests +// ============================================= + +test.describe('Employee Command Palette Restrictions', () => { + test('employee command palette does not show restricted navigation commands', async ({ + employee, + }) => { + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); + await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ + timeout: 10000, + }); + + // Open command palette + await employee.page.getByTestId('command_palette_button').click(); + await expect(employee.page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 }); + + // Available navigation commands + await expect(employee.page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible(); + await expect(employee.page.getByRole('option', { name: 'Go to Time' })).toBeVisible(); + await expect(employee.page.getByRole('option', { name: 'Go to Calendar' })).toBeVisible(); + + // Restricted commands should NOT be visible + await expect( + employee.page.getByRole('option', { name: 'Go to Members' }) + ).not.toBeVisible(); + await expect( + employee.page.getByRole('option', { name: 'Go to Settings' }) + ).not.toBeVisible(); + }); + + test('employee command palette does not show create commands for restricted entities', async ({ + employee, + }) => { + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); + await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ + timeout: 10000, + }); + + // Open command palette + await employee.page.getByTestId('command_palette_button').click(); + await expect(employee.page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 }); + + // Search for "Create" to filter + await employee.page.locator('[role="dialog"] input').fill('Create'); + await employee.page.waitForTimeout(300); + + // Should NOT see create commands for restricted entities + await expect( + employee.page.getByRole('option', { name: 'Create Project' }) + ).not.toBeVisible(); + await expect( + employee.page.getByRole('option', { name: 'Create Client' }) + ).not.toBeVisible(); + await expect(employee.page.getByRole('option', { name: 'Create Tag' })).not.toBeVisible(); + await expect( + employee.page.getByRole('option', { name: 'Invite Member' }) + ).not.toBeVisible(); + + // Should still see Create Time Entry (employees can create time entries) + await expect( + employee.page.getByRole('option', { name: 'Create Time Entry' }) + ).toBeVisible(); + }); +}); diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts new file mode 100644 index 000000000..79dba6b77 --- /dev/null +++ b/e2e/dashboard.spec.ts @@ -0,0 +1,190 @@ +import { expect, test } from '../playwright/fixtures'; +import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; +import type { Page } from '@playwright/test'; +import { + assertThatTimerHasStarted, + assertThatTimerIsStopped, + newTimeEntryResponse, + startOrStopTimerWithButton, + stoppedTimeEntryResponse, +} from './utils/currentTimeEntry'; +import { + createBareTimeEntryViaApi, + createPublicProjectViaApi, + createTimeEntryViaApi, + updateOrganizationSettingViaApi, +} from './utils/api'; + +async function goToDashboard(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); +} + +test('test that dashboard loads with all expected sections', async ({ page }) => { + await goToDashboard(page); + await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 }); + + // Timer section (scoped to dashboard_timer to avoid matching sidebar timer) + await expect(page.getByTestId('time_entry_description')).toBeVisible(); + await expect(page.getByTestId('dashboard_timer').getByTestId('timer_button')).toBeVisible(); + + // Dashboard cards + await expect(page.getByText('Recent Time Entries', { exact: true })).toBeVisible(); + await expect(page.getByText('Last 7 Days', { exact: true })).toBeVisible(); + await expect(page.getByText('Activity Graph', { exact: true })).toBeVisible(); + await expect(page.getByText('Team Activity', { exact: true })).toBeVisible(); + + // Weekly overview section + await expect(page.getByText('This Week', { exact: true })).toBeVisible(); +}); + +test('test that dashboard shows time entry data after creating entries', async ({ page, ctx }) => { + await createBareTimeEntryViaApi(ctx, 'Dashboard test entry', '1h'); + + await goToDashboard(page); + await expect(page.getByTestId('dashboard_view')).toBeVisible(); + + // The "Last 7 Days" or "This Week" section should reflect tracked time + await expect(page.getByText('This Week', { exact: true })).toBeVisible(); +}); + +test('test that timer on dashboard can start and stop', async ({ page }) => { + await goToDashboard(page); + await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]); + await assertThatTimerHasStarted(page); + + await page.waitForTimeout(1500); + + await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]); + await assertThatTimerIsStopped(page); +}); + +test('test that weekly overview section displays stat cards', async ({ page, ctx }) => { + await createBareTimeEntryViaApi(ctx, 'Stats test entry', '2h'); + + await goToDashboard(page); + + // Verify stat card labels are visible + await expect(page.getByText('Spent Time')).toBeVisible(); + await expect(page.getByText('Billable Time')).toBeVisible(); + await expect(page.getByText('Billable Amount')).toBeVisible(); +}); + +test('test that stopping timer refreshes dashboard data', async ({ page }) => { + await goToDashboard(page); + + // Start timer + await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]); + await assertThatTimerHasStarted(page); + await page.waitForTimeout(1500); + + // Stop timer and verify dashboard queries are refetched + await Promise.all([ + stoppedTimeEntryResponse(page), + page.waitForResponse( + (response) => + response.url().includes('/charts/') && + response.request().method() === 'GET' && + response.status() === 200 + ), + startOrStopTimerWithButton(page), + ]); + await assertThatTimerIsStopped(page); +}); + +// ============================================= +// Employee Permission Tests +// ============================================= + +test.describe('Employee Dashboard Restrictions', () => { + test('employee dashboard loads and timer is functional', async ({ employee }) => { + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); + await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ + timeout: 10000, + }); + + // Timer should be available + await expect( + employee.page.getByTestId('dashboard_timer').getByTestId('timer_button') + ).toBeVisible(); + await expect(employee.page.getByTestId('time_entry_description')).toBeEditable(); + }); + + test('employee cannot see Team Activity card', async ({ employee }) => { + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); + await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ + timeout: 10000, + }); + + // Other dashboard cards should be visible + await expect(employee.page.getByText('Recent Time Entries', { exact: true })).toBeVisible(); + + // Team Activity should NOT be visible for employees + await expect(employee.page.getByText('Team Activity', { exact: true })).not.toBeVisible(); + }); + + test('employee cannot see Cost column in This Week table by default', async ({ + ctx, + employee, + }) => { + const project = await createPublicProjectViaApi(ctx, { + name: 'EmpDashBillProj', + is_billable: true, + billable_rate: 10000, + }); + await createTimeEntryViaApi( + { ...ctx, memberId: employee.memberId }, + { + description: 'Emp dashboard cost entry', + duration: '1h', + projectId: project.id, + billable: true, + } + ); + + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); + await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ + timeout: 10000, + }); + + // This Week table should be visible + await expect(employee.page.getByText('This Week', { exact: true })).toBeVisible(); + + // Duration column should be visible, but Cost column should NOT + await expect(employee.page.getByText('Duration', { exact: true })).toBeVisible(); + await expect(employee.page.getByText('Cost', { exact: true })).not.toBeVisible(); + }); + + test('employee can see Cost column in This Week table when employees_can_see_billable_rates is enabled', async ({ + ctx, + employee, + }) => { + await updateOrganizationSettingViaApi(ctx, { employees_can_see_billable_rates: true }); + + const project = await createPublicProjectViaApi(ctx, { + name: 'EmpDashBillVisProj', + is_billable: true, + billable_rate: 10000, + }); + await createTimeEntryViaApi( + { ...ctx, memberId: employee.memberId }, + { + description: 'Emp dashboard cost visible entry', + duration: '1h', + projectId: project.id, + billable: true, + } + ); + + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); + await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ + timeout: 10000, + }); + + // Both Duration and Cost columns should be visible + await expect(employee.page.getByText('Duration', { exact: true })).toBeVisible(); + await expect(employee.page.getByText('Cost', { exact: true })).toBeVisible(); + + // 1h at 100.00/h = 100.00 EUR cost should be visible + await expect(employee.page.getByText('100,00 EUR').first()).toBeVisible(); + }); +}); diff --git a/e2e/import-export.spec.ts b/e2e/import-export.spec.ts new file mode 100644 index 000000000..740274c87 --- /dev/null +++ b/e2e/import-export.spec.ts @@ -0,0 +1,154 @@ +import { expect, test } from '../playwright/fixtures'; +import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; +import type { Page } from '@playwright/test'; +import path from 'path'; + +async function goToImportExport(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/import'); +} + +test('test that import page loads with type dropdown and file upload', async ({ page }) => { + await goToImportExport(page); + await expect(page.getByTestId('import_view')).toBeVisible({ timeout: 10000 }); + + // Import section + await expect(page.getByRole('heading', { name: 'Import Data' })).toBeVisible(); + await expect(page.locator('#importType')).toBeVisible(); + + // Export section + await expect(page.getByRole('heading', { name: 'Export Data' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Export Organization Data' })).toBeVisible(); +}); + +test('test that selecting an import type shows instructions', async ({ page }) => { + await goToImportExport(page); + + // Select a Toggl import type + await page.getByLabel('Import Type').selectOption({ index: 1 }); + + // Instructions should appear + await expect(page.getByText('Instructions:')).toBeVisible(); +}); + +test('test that importing without selecting type shows error', async ({ page }) => { + await goToImportExport(page); + + // Click Import Data without selecting a type + await page.getByRole('button', { name: 'Import Data' }).click(); + + // Should show an error notification + await expect(page.getByText('Please select the import type')).toBeVisible(); +}); + +test('test that importing without selecting file shows error', async ({ page }) => { + await goToImportExport(page); + + // Select an import type first + await page.getByLabel('Import Type').selectOption({ index: 1 }); + + // Click Import Data without selecting a file + await page.getByRole('button', { name: 'Import Data' }).click(); + + // Should show an error notification + await expect( + page.getByText('Please select the CSV or ZIP file that you want to import') + ).toBeVisible(); +}); + +test('test that export button triggers export and shows success modal', async ({ page }) => { + await goToImportExport(page); + await expect(page.getByRole('button', { name: 'Export Organization Data' })).toBeVisible(); + + // Override window.open to prevent the page from navigating away to the + // download URL (the app uses window.open(url, '_self') which would navigate + // away before we can verify the success modal) + await page.evaluate(() => { + window.open = () => null; + }); + + // Click Export Organization Data and wait for the API response + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/export') && + response.request().method() === 'POST' && + response.status() === 200, + { timeout: 60000 } + ), + page.getByRole('button', { name: 'Export Organization Data' }).click(), + ]); + + // Success modal should appear after export completes + await expect(page.getByText('The export was successful!')).toBeVisible(); +}); + +test('test that import type dropdown has multiple options', async ({ page }) => { + await goToImportExport(page); + + // The dropdown should load with options from the API + await page.waitForResponse( + (response) => + response.url().includes('/importers') && + response.request().method() === 'GET' && + response.status() === 200 + ); + + // Verify the select has options besides the default placeholder + const options = page.getByLabel('Import Type').locator('option'); + const count = await options.count(); + // Should have at least the placeholder + some import types + expect(count).toBeGreaterThan(1); +}); + +test('test that importing a generic time entries CSV works', async ({ page }) => { + await goToImportExport(page); + await expect(page.getByTestId('import_view')).toBeVisible({ timeout: 10000 }); + + // Select "Generic Time Entries" import type + await page.getByLabel('Import Type').selectOption({ label: 'Generic Time Entries' }); + await expect(page.getByText('Instructions:')).toBeVisible(); + + // Upload the test CSV file + const csvPath = path.resolve('resources/testfiles/generic_time_entries_import_test_1.csv'); + await page.locator('#file-upload').setInputFiles(csvPath); + + // Click Import and wait for the API response + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/import') && + response.request().method() === 'POST' && + response.status() === 200, + { timeout: 30000 } + ), + page.getByRole('button', { name: 'Import Data' }).click(), + ]); + + // Verify success modal with import results + await expect(page.getByRole('heading', { name: 'Import Result' })).toBeVisible(); + await expect(page.getByText('The import was successful!')).toBeVisible(); + + // The CSV has 2 time entries, 1 client, 2 projects, 1 task + await expect(page.getByText('Time entries created:').locator('..')).toContainText('2'); + await expect(page.getByText('Projects created:').locator('..')).toContainText('2'); + await expect(page.getByText('Clients created:').locator('..')).toContainText('1'); + await expect(page.getByText('Tasks created:').locator('..')).toContainText('1'); +}); + +// ============================================= +// Employee Permission Tests +// ============================================= + +test.describe('Employee Import Restrictions', () => { + test('employee does not see Import / Export link in the sidebar', async ({ employee }) => { + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); + await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ + timeout: 10000, + }); + + // The Import / Export link should NOT be visible in the sidebar for employees + await expect( + employee.page.getByRole('link', { name: 'Import / Export' }) + ).not.toBeVisible(); + }); +}); diff --git a/e2e/members.spec.ts b/e2e/members.spec.ts index 70be0e7c1..b5cf4ec06 100644 --- a/e2e/members.spec.ts +++ b/e2e/members.spec.ts @@ -3,53 +3,63 @@ // TODO: Remove Invitation import { expect, test } from '../playwright/fixtures'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; +import type { Page } from '@playwright/test'; +import { inviteAndAcceptMember } from './utils/members'; +import { createPlaceholderMemberViaImportApi } from './utils/api'; -async function goToMembersPage(page) { +// Tests that invite + accept members need more time +test.describe.configure({ timeout: 45000 }); + +async function goToMembersPage(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/members'); } -async function openInviteMemberModal(page) { +async function openInviteMemberModal(page: Page) { await Promise.all([ page.getByRole('button', { name: 'Invite Member' }).click(), expect(page.getByPlaceholder('Member Email')).toBeVisible(), ]); } -test('test that new manager can be invited', async ({ page }) => { +test('test that new manager can be invited and accepted', async ({ page, browser }) => { + const memberId = Math.round(Math.random() * 100000); + const memberEmail = `manager+${memberId}@invite.test`; + + await inviteAndAcceptMember(page, browser, 'Invited Mgr', memberEmail, 'Manager'); + + // Verify the member appears in the members table with the correct role await goToMembersPage(page); - await openInviteMemberModal(page); - const editorId = Math.round(Math.random() * 10000); - await page.getByLabel('Email').fill(`new+${editorId}@editor.test`); - await page.getByRole('button', { name: 'Manager' }).click(); - await Promise.all([ - page.getByRole('button', { name: 'Invite Member', exact: true }).click(), - expect(page.getByRole('main')).toContainText(`new+${editorId}@editor.test`), - ]); + const memberRow = page.getByRole('row').filter({ hasText: 'Invited Mgr' }); + await expect(memberRow).toBeVisible(); + await expect(memberRow.getByText('Manager', { exact: true })).toBeVisible(); }); -test('test that new employee can be invited', async ({ page }) => { +test('test that new employee can be invited and accepted', async ({ page, browser }) => { + const memberId = Math.round(Math.random() * 100000); + const memberEmail = `employee+${memberId}@invite.test`; + + await inviteAndAcceptMember(page, browser, 'Invited Emp', memberEmail, 'Employee'); + + // Verify the member appears in the members table with the correct role await goToMembersPage(page); - await openInviteMemberModal(page); - const editorId = Math.round(Math.random() * 10000); - await page.getByLabel('Email').fill(`new+${editorId}@editor.test`); - await page.getByRole('button', { name: 'Employee' }).click(); - await Promise.all([ - page.getByRole('button', { name: 'Invite Member', exact: true }).click(), - await expect(page.getByRole('main')).toContainText(`new+${editorId}@editor.test`), - ]); + const memberRow = page.getByRole('row').filter({ hasText: 'Invited Emp' }); + await expect(memberRow).toBeVisible(); + await expect(memberRow.getByText('Employee', { exact: true })).toBeVisible(); }); -test('test that new admin can be invited', async ({ page }) => { +test('test that new admin can be invited and accepted', async ({ page, browser }) => { + const memberId = Math.round(Math.random() * 100000); + const memberEmail = `admin+${memberId}@invite.test`; + + await inviteAndAcceptMember(page, browser, 'Invited Adm', memberEmail, 'Administrator'); + + // Verify the member appears in the members table with the correct role await goToMembersPage(page); - await openInviteMemberModal(page); - const adminId = Math.round(Math.random() * 10000); - await page.getByLabel('Email').fill(`new+${adminId}@admin.test`); - await page.getByRole('button', { name: 'Administrator' }).click(); - await Promise.all([ - page.getByRole('button', { name: 'Invite Member', exact: true }).click(), - expect(page.getByRole('main')).toContainText(`new+${adminId}@admin.test`), - ]); + const memberRow = page.getByRole('row').filter({ hasText: 'Invited Adm' }); + await expect(memberRow).toBeVisible(); + await expect(memberRow.getByText('Admin', { exact: true })).toBeVisible(); }); + test('test that error shows if no role is selected', async ({ page }) => { await goToMembersPage(page); await openInviteMemberModal(page); @@ -91,3 +101,434 @@ test('test that organization billable rate can be updated with all existing time ), ]); }); + +test('test that changing role of placeholder member is rejected', async ({ page, ctx }) => { + const placeholderName = 'RoleChange ' + Math.floor(Math.random() * 10000); + + // Create a placeholder member via import + await createPlaceholderMemberViaImportApi(ctx, placeholderName); + + // Go to members page and verify placeholder exists with role "Placeholder" + await goToMembersPage(page); + const memberRow = page.getByRole('row').filter({ hasText: placeholderName }); + await expect(memberRow).toBeVisible(); + await expect(memberRow.getByText('Placeholder', { exact: true })).toBeVisible(); + + // Open the edit modal for the placeholder member + await memberRow.getByRole('button').click(); + await page.getByRole('menuitem').getByText('Edit').click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible(); + + // Change role to Employee + const roleSelect = page.getByRole('dialog').getByRole('combobox').first(); + await roleSelect.click(); + await expect(page.getByRole('option', { name: 'Employee' })).toBeVisible(); + await page.getByRole('option', { name: 'Employee' }).click(); + await expect(roleSelect).toContainText('Employee'); + + // Submit the change - the API should reject it with 400 + await Promise.all([ + page.getByRole('button', { name: 'Update Member' }).click(), + page.waitForResponse( + (response) => + response.url().includes('/members/') && + response.request().method() === 'PUT' && + response.status() === 400 + ), + ]); + + // Verify error notification is shown + await expect(page.getByText('Failed to update member')).toBeVisible(); +}); + +test('test that changing member role updates the role in the member table', async ({ + page, + browser, +}) => { + const memberId = Math.floor(Math.random() * 100000); + const memberEmail = `member+${memberId}@rolechange.test`; + + // Invite and accept a new Employee member + await inviteAndAcceptMember(page, browser, 'Jane Smith', memberEmail, 'Employee'); + + // Verify the new member appears with the Employee role + await goToMembersPage(page); + const memberRow = page.getByRole('row').filter({ hasText: 'Jane Smith' }); + await expect(memberRow).toBeVisible(); + await expect(memberRow.getByText('Employee', { exact: true })).toBeVisible(); + + // Open the edit modal + await memberRow.getByRole('button').click(); + await page.getByRole('menuitem').getByText('Edit').click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible(); + + // Change role to Manager + const roleSelect = page.getByRole('dialog').getByRole('combobox').first(); + await roleSelect.click(); + await expect(page.getByRole('option', { name: 'Manager' })).toBeVisible(); + await page.getByRole('option', { name: 'Manager' }).click(); + await expect(roleSelect).toContainText('Manager'); + + // Submit the change and verify the API call succeeds + await Promise.all([ + page.getByRole('button', { name: 'Update Member' }).click(), + page.waitForResponse( + (response) => + response.url().includes('/members/') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + ]); + + // Verify dialog closed + await expect(page.getByRole('dialog')).not.toBeVisible(); + + // Verify the role updated in the table + await expect(memberRow.getByText('Manager', { exact: true })).toBeVisible(); +}); + +test('test that merging a placeholder member works', async ({ page, ctx }) => { + const placeholderName = 'Merge Target ' + Math.floor(Math.random() * 10000); + + // Create a placeholder member via import + await createPlaceholderMemberViaImportApi(ctx, placeholderName); + + // Go to members page + await goToMembersPage(page); + await expect(page.getByText(placeholderName)).toBeVisible(); + + // Find the placeholder member row and open actions menu + const placeholderRow = page.getByRole('row').filter({ hasText: placeholderName }); + await placeholderRow.getByRole('button').click(); + + // Click Merge + await page.getByTestId('member_merge').click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Merge Member' })).toBeVisible(); + + // Select the current user (the owner) as merge target via MemberCombobox + // The MemberCombobox renders a Button as trigger; clicking it opens the popover with the combobox input + await page.getByRole('dialog').getByRole('button', { name: 'Select a member...' }).click(); + + // Wait for dropdown options to load + const firstOption = page.getByRole('option').first(); + await expect(firstOption).toBeVisible({ timeout: 10000 }); + await firstOption.click(); + + // Submit merge + await Promise.all([ + page.getByRole('button', { name: 'Merge Member' }).click(), + page.waitForResponse( + (response) => + response.url().includes('/member/') && + response.url().includes('/merge-into') && + response.ok() + ), + ]); + + // Wait for merge dialog to close after successful merge + await expect(page.getByRole('dialog').filter({ hasText: 'Merge Member' })).not.toBeVisible(); + + // Verify placeholder member is no longer in the members table + await expect(page.getByRole('main').getByText(placeholderName)).not.toBeVisible(); +}); + +test('test that deleting a placeholder member works', async ({ page, ctx }) => { + const placeholderName = 'Delete Target ' + Math.floor(Math.random() * 10000); + + // Create a placeholder member via import + await createPlaceholderMemberViaImportApi(ctx, placeholderName); + + // Go to members page + await goToMembersPage(page); + const memberRow = page.getByRole('row').filter({ hasText: placeholderName }); + await expect(memberRow).toBeVisible(); + + // Open actions menu and click Delete + await memberRow.getByRole('button').click(); + await page.getByRole('menuitem').getByText('Delete').click(); + + // Verify delete modal is shown + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Delete Member' })).toBeVisible(); + + // Try to delete without checking the confirmation checkbox + await page.getByRole('button', { name: 'Delete Member' }).click(); + + // Should show validation error + await expect( + page.getByText('You must confirm that you understand the consequences of this action') + ).toBeVisible(); + + // Check the confirmation checkbox + await page.getByRole('checkbox').click(); + + // Click Delete Member button and wait for API response + await Promise.all([ + page.getByRole('button', { name: 'Delete Member' }).click(), + page.waitForResponse( + (response) => + response.url().includes('/members/') && + response.request().method() === 'DELETE' && + response.ok() + ), + ]); + + // Verify modal is closed + await expect(page.getByRole('dialog')).not.toBeVisible(); + + // Verify member is removed from the table + await expect(page.getByRole('main').getByText(placeholderName)).not.toBeVisible(); +}); + +test('test that member delete modal can be cancelled', async ({ page, ctx }) => { + const placeholderName = 'Delete Cancel ' + Math.floor(Math.random() * 10000); + + // Create a placeholder member via import + await createPlaceholderMemberViaImportApi(ctx, placeholderName); + + // Go to members page + await goToMembersPage(page); + const memberRow = page.getByRole('row').filter({ hasText: placeholderName }); + await expect(memberRow).toBeVisible(); + + // Open actions menu and click Delete + await memberRow.getByRole('button').click(); + await page.getByRole('menuitem').getByText('Delete').click(); + + // Verify delete modal is shown + await expect(page.getByRole('dialog')).toBeVisible(); + + // Set up listener to verify no DELETE request is sent + let deleteRequestSent = false; + page.on('request', (request) => { + if (request.url().includes('/members/') && request.method() === 'DELETE') { + deleteRequestSent = true; + } + }); + + // Click Cancel + await page.getByRole('button', { name: 'Cancel' }).click(); + + // Verify modal is closed + await expect(page.getByRole('dialog')).not.toBeVisible(); + + // Verify member is still in the table + await expect(memberRow).toBeVisible(); + + // Verify no DELETE request was sent + expect(deleteRequestSent).toBe(false); +}); + +test('test that organization owner cannot be deleted', async ({ page }) => { + await goToMembersPage(page); + + // Find the owner row (John Doe with Owner role) + const ownerRow = page.getByRole('row').filter({ hasText: 'Owner' }); + await expect(ownerRow).toBeVisible(); + + // Open the actions menu for the owner + await ownerRow.getByRole('button').click(); + + // Click Delete + await page.getByRole('menuitem').getByText('Delete').click(); + + // Verify delete modal is shown + await expect(page.getByRole('dialog')).toBeVisible(); + + // Check the confirmation checkbox + await page.getByRole('checkbox').click(); + + // Try to delete - should fail with 400 error + const responsePromise = page.waitForResponse( + (response) => + response.url().includes('/members/') && response.request().method() === 'DELETE' + ); + await page.getByRole('button', { name: 'Delete Member' }).click(); + const response = await responsePromise; + + // Verify the API returned an error status + expect(response.status()).toBe(400); + + // Close the modal by pressing Escape + await page.keyboard.press('Escape'); + + // Refresh and verify the owner is still there + await goToMembersPage(page); + await expect(page.getByRole('row').filter({ hasText: 'Owner' })).toBeVisible(); +}); + +// ============================================= +// Invitations Tab Tests +// ============================================= + +test('test that invitation shows in invitations tab and can be revoked', async ({ page }) => { + const inviteEmail = `invite+${Math.floor(Math.random() * 100000)}@pending.test`; + + await goToMembersPage(page); + await openInviteMemberModal(page); + + await page.getByPlaceholder('Member Email').fill(inviteEmail); + await page.getByRole('button', { name: 'Employee' }).click(); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/invitations') && + response.request().method() === 'POST' && + response.status() === 204 + ), + page.getByRole('button', { name: 'Invite Member', exact: true }).click(), + ]); + + // Wait for modal to close + await expect(page.getByPlaceholder('Member Email')).not.toBeVisible(); + + // Switch to Invitations tab and verify the invitation is visible + await page.getByText('Invitations', { exact: true }).click(); + await expect(page.getByText(inviteEmail)).toBeVisible(); + + // Find and click the actions menu for this invitation + const invitationRow = page.locator('tr, [role="row"]').filter({ hasText: inviteEmail }); + await invitationRow.getByRole('button').click(); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/invitations/') && + response.request().method() === 'DELETE' && + response.status() === 204 + ), + page.getByRole('menuitem').getByText('Delete').click(), + ]); + + // Verify invitation is removed + await expect(page.getByText(inviteEmail)).not.toBeVisible(); +}); + +test('test that invitation can be resent', async ({ page }) => { + const inviteEmail = `resend+${Math.floor(Math.random() * 100000)}@invite.test`; + + await goToMembersPage(page); + await openInviteMemberModal(page); + + await page.getByPlaceholder('Member Email').fill(inviteEmail); + await page.getByRole('button', { name: 'Employee' }).click(); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/invitations') && + response.request().method() === 'POST' && + response.status() === 204 + ), + page.getByRole('button', { name: 'Invite Member', exact: true }).click(), + ]); + + // Wait for modal to close + await expect(page.getByPlaceholder('Member Email')).not.toBeVisible(); + + // Switch to Invitations tab + await page.getByText('Invitations', { exact: true }).click(); + await expect(page.getByText(inviteEmail)).toBeVisible(); + + // Find and click the actions menu, then resend + const invitationRow = page.locator('tr, [role="row"]').filter({ hasText: inviteEmail }); + await invitationRow.getByRole('button').click(); + // Wait for dropdown menu to appear + await expect(page.getByRole('menuitem').getByText('Resend Invitation')).toBeVisible(); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/resend') && response.request().method() === 'POST' + ), + page.getByRole('menuitem').getByText('Resend Invitation').click(), + ]); +}); + +test('test that admin user cannot transfer ownership', async ({ page, browser }) => { + const memberId = Math.floor(Math.random() * 100000); + const memberEmail = `admin+${memberId}@perms.test`; + + // Invite and accept an admin member + await inviteAndAcceptMember( + page, + browser, + 'Admin User ' + memberId, + memberEmail, + 'Administrator' + ); + + // Go to members page and verify the admin exists + await goToMembersPage(page); + const adminRow = page.getByRole('row').filter({ hasText: 'Admin User' }); + await expect(adminRow).toBeVisible(); + + // The owner should still be the owner + const ownerRow = page.getByRole('row').filter({ hasText: 'Owner' }); + await expect(ownerRow).toBeVisible(); + + // Open actions menu for the admin - should NOT have "Transfer Ownership" option + await adminRow.getByRole('button').click(); + await expect(page.getByRole('menuitem').getByText('Edit')).toBeVisible(); +}); + +test('test that accepted invitation disappears from invitations tab', async ({ page, browser }) => { + const memberId = Math.round(Math.random() * 100000); + const memberEmail = `accepted+${memberId}@invite.test`; + + // Invite and accept the member + await inviteAndAcceptMember(page, browser, 'Accepted Member', memberEmail, 'Employee'); + + // Go to members page and switch to Invitations tab + await goToMembersPage(page); + await page.getByRole('tab', { name: 'Invitations' }).click(); + + // The accepted invitation should not be visible + await expect(page.getByText(memberEmail)).not.toBeVisible(); +}); + +// ============================================= +// Employee Permission Tests +// ============================================= + +test.describe('Employee Sidebar Navigation', () => { + test('employee sidebar shows correct navigation links', async ({ employee }) => { + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); + await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ + timeout: 10000, + }); + + // Visible links + await expect(employee.page.getByRole('link', { name: 'Dashboard' })).toBeVisible(); + await expect(employee.page.getByRole('link', { name: 'Time' })).toBeVisible(); + await expect(employee.page.getByRole('link', { name: 'Calendar' })).toBeVisible(); + await expect(employee.page.getByRole('link', { name: 'Projects' })).toBeVisible(); + await expect(employee.page.getByRole('link', { name: 'Clients' })).toBeVisible(); + await expect(employee.page.getByRole('link', { name: 'Tags' })).toBeVisible(); + + // Hidden links + await expect(employee.page.getByRole('link', { name: 'Members' })).not.toBeVisible(); + await expect( + employee.page.getByRole('link', { name: 'Settings', exact: true }) + ).not.toBeVisible(); + }); + + test('employee cannot see members list or invite members', async ({ employee }) => { + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/members'); + + // Page loads but the members API returns 403 (no members:view permission) + await expect(employee.page.getByRole('heading', { name: 'Members' })).toBeVisible({ + timeout: 10000, + }); + + // Member table is empty — no rows rendered (only headers) + await expect(employee.page.getByTestId('client_table').locator('[role="row"]')).toHaveCount( + 0 + ); + + // Employee should NOT see the Invite Member button + await expect( + employee.page.getByRole('button', { name: 'Invite member' }) + ).not.toBeVisible(); + }); +}); diff --git a/e2e/organization.spec.ts b/e2e/organization.spec.ts index 78541c258..dceb4b026 100644 --- a/e2e/organization.spec.ts +++ b/e2e/organization.spec.ts @@ -223,9 +223,177 @@ test('test that format settings are reflected in the dashboard', async ({ page } // check that the current date is displayed in the dd/mm/yyyy format on the time page await page.goto(PLAYWRIGHT_BASE_URL + '/time'); + // Wait for time entries to load so organization data is available for date formatting + await page.waitForResponse( + (response) => response.url().includes('/time-entries') && response.status() === 200 + ); await expect( page.getByText(new Date().toLocaleDateString('en-GB'), { exact: true }).nth(0) - ).toBeVisible(); + ).toBeVisible({ timeout: 10000 }); +}); + +test('test that organization time entry settings can be toggled', async ({ page }) => { + await goToOrganizationSettings(page); + + const preventOverlappingCheckbox = page.getByLabel( + 'Prevent overlapping time entries (new entries only)' + ); + const manageTasksCheckbox = page.getByLabel('Allow Employees to manage tasks'); + + // Get current states and toggle both + const wasOverlappingChecked = await preventOverlappingCheckbox.isChecked(); + const wasManageTasksChecked = await manageTasksCheckbox.isChecked(); + + if (wasOverlappingChecked) { + await preventOverlappingCheckbox.uncheck(); + } else { + await preventOverlappingCheckbox.check(); + } + + if (wasManageTasksChecked) { + await manageTasksCheckbox.uncheck(); + } else { + await manageTasksCheckbox.check(); + } + + // Save + const settingsForm = page.locator('form').filter({ hasText: 'Prevent overlapping' }); + await Promise.all([ + settingsForm.getByRole('button', { name: 'Save' }).click(), + page.waitForResponse( + async (response) => + response.url().includes('/organizations/') && + response.request().method() === 'PUT' && + response.status() === 200 && + (await response.json()).data.prevent_overlapping_time_entries === + !wasOverlappingChecked + ), + ]); + + // Reload and verify both settings persisted + await page.reload(); + await expect(preventOverlappingCheckbox).toBeChecked({ checked: !wasOverlappingChecked }); + await expect(manageTasksCheckbox).toBeChecked({ checked: !wasManageTasksChecked }); + + // Toggle both back to restore original state + if (!wasOverlappingChecked) { + await preventOverlappingCheckbox.uncheck(); + } else { + await preventOverlappingCheckbox.check(); + } + + if (!wasManageTasksChecked) { + await manageTasksCheckbox.uncheck(); + } else { + await manageTasksCheckbox.check(); + } + + await Promise.all([ + settingsForm.getByRole('button', { name: 'Save' }).click(), + page.waitForResponse( + async (response) => + response.url().includes('/organizations/') && + response.request().method() === 'PUT' && + response.status() === 200 && + (await response.json()).data.prevent_overlapping_time_entries === + wasOverlappingChecked + ), + ]); +}); + +test('test that 12-hour clock format can be set', async ({ page }) => { + await goToOrganizationSettings(page); + + await page.getByLabel('Time Format').click(); + await page.getByRole('option', { name: '12-hour clock' }).click(); + await Promise.all([ + page + .locator('form') + .filter({ hasText: 'Time Format' }) + .getByRole('button', { name: 'Save' }) + .click(), + page.waitForResponse( + async (response) => + response.url().includes('/organizations/') && + response.request().method() === 'PUT' && + response.status() === 200 && + (await response.json()).data.time_format === '12-hours' + ), + ]); + + // Reload and verify it persisted + await page.reload(); + await expect(page.getByLabel('Time Format')).toContainText('12-hour clock'); + + // Reset back to 24-hour + await page.getByLabel('Time Format').click(); + await page.getByRole('option', { name: '24-hour clock' }).click(); + await Promise.all([ + page + .locator('form') + .filter({ hasText: 'Time Format' }) + .getByRole('button', { name: 'Save' }) + .click(), + page.waitForResponse( + async (response) => + response.url().includes('/organizations/') && + response.request().method() === 'PUT' && + response.status() === 200 && + (await response.json()).data.time_format === '24-hours' + ), + ]); }); -// TODO: Test 12-hour clock format +test('test that format settings persist after page reload', async ({ page }) => { + await goToOrganizationSettings(page); + + // Set a specific date format + await page.getByLabel('Date Format').click(); + await page.getByRole('option', { name: 'DD/MM/YYYY' }).click(); + await Promise.all([ + page + .locator('form') + .filter({ hasText: 'Date Format' }) + .getByRole('button', { name: 'Save' }) + .click(), + page.waitForResponse( + async (response) => + response.url().includes('/organizations/') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + ]); + + // Reload and verify it persisted + await page.reload(); + await expect(page.getByLabel('Date Format')).toContainText('DD/MM/YYYY'); +}); + +// ============================================= +// Employee Permission Tests +// ============================================= + +test.describe('Employee Organization Settings Restrictions', () => { + test('employee can see org name but not editable settings', async ({ ctx, employee }) => { + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/teams/' + ctx.orgId); + + // Organization Name section is visible (but inputs are disabled) + await expect( + employee.page.getByRole('heading', { name: 'Organization Name', level: 3 }) + ).toBeVisible({ timeout: 10000 }); + + // Editable settings sections should NOT be visible + await expect( + employee.page.getByRole('heading', { name: 'Billable Rate', level: 3 }) + ).not.toBeVisible(); + await expect( + employee.page.getByRole('heading', { name: 'Format Settings', level: 3 }) + ).not.toBeVisible(); + await expect( + employee.page.getByRole('heading', { name: 'Organization Settings', level: 3 }) + ).not.toBeVisible(); + + // Save button should not be visible (employee cannot update) + await expect(employee.page.getByRole('button', { name: 'Save' })).not.toBeVisible(); + }); +}); diff --git a/e2e/profile.spec.ts b/e2e/profile.spec.ts index a2f34388e..93d9248cf 100644 --- a/e2e/profile.spec.ts +++ b/e2e/profile.spec.ts @@ -1,5 +1,10 @@ import { test, expect } from '../playwright/fixtures'; -import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; +import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config'; +import type { Page } from '@playwright/test'; + +async function goToProfilePage(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); +} test('test that user name can be updated', async ({ page }) => { await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); @@ -39,6 +44,28 @@ test('test that user can create an API key', async ({ page }) => { await createNewApiToken(page); }); +test('test that creating an API key with empty name shows validation error', async ({ page }) => { + await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); + + // Wait for the API Key Name input to be visible before interacting + const nameInput = page.getByLabel('API Key Name'); + await expect(nameInput).toBeVisible(); + + // Ensure the API Key Name input is empty + await nameInput.fill(''); + + // Click the create button and wait for the 422 response + const [response] = await Promise.all([ + page.waitForResponse('**/users/me/api-tokens'), + page.getByRole('button', { name: 'Create API Key' }).click(), + ]); + + expect(response.status()).toBe(422); + + // Verify that an error notification is shown with validation message about the name field + await expect(page.getByText('name field is required')).toBeVisible({ timeout: 5000 }); +}); + test('test that user can delete an API key', async ({ page }) => { await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); await createNewApiToken(page); @@ -68,3 +95,254 @@ test('test that user can revoke an API key', async ({ page }) => { await expect(page.locator('body')).toContainText('NEW API KEY'); await expect(page.locator('body')).toContainText('Revoked'); }); + +// ============================================= +// Update Password Form Tests +// ============================================= + +test('test that password mismatch shows error', async ({ page }) => { + await goToProfilePage(page); + + // Fill in with mismatched passwords + await page.getByLabel('Current Password').fill(TEST_USER_PASSWORD); + await page.getByLabel('New Password').fill('newSecurePassword456'); + await page.getByLabel('Confirm Password').fill('differentPassword789'); + + // Find the form containing the Confirm Password field and click its Save button + const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form'); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/user/password') && response.request().method() === 'PUT' + ), + passwordForm.getByRole('button', { name: 'Save' }).click(), + ]); + + // Verify error message about password confirmation + await expect(page.getByText('confirmation does not match')).toBeVisible(); +}); + +test('test that short password shows validation error', async ({ page }) => { + await goToProfilePage(page); + + // Fill in with a too short password + await page.getByLabel('Current Password').fill(TEST_USER_PASSWORD); + await page.getByLabel('New Password').fill('short'); + await page.getByLabel('Confirm Password').fill('short'); + + // Find the form containing the Confirm Password field and click its Save button + const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form'); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/user/password') && response.request().method() === 'PUT' + ), + passwordForm.getByRole('button', { name: 'Save' }).click(), + ]); + + // Verify error message about password length + await expect(page.getByText('must be at least')).toBeVisible(); +}); + +test('test that incorrect current password shows validation error', async ({ page }) => { + await goToProfilePage(page); + + // Fill in with wrong current password + await page.getByLabel('Current Password').fill('wrongCurrentPassword123'); + await page.getByLabel('New Password').fill('newSecurePassword456'); + await page.getByLabel('Confirm Password').fill('newSecurePassword456'); + + // Find the form containing the Confirm Password field and click its Save button + const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form'); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/user/password') && response.request().method() === 'PUT' + ), + passwordForm.getByRole('button', { name: 'Save' }).click(), + ]); + + // Verify error message about incorrect password + await expect(page.getByText('does not match')).toBeVisible(); +}); + +test('test that password can be updated successfully', async ({ page }) => { + await goToProfilePage(page); + const newPassword = 'newSecurePassword456'; + + // Change password to new password + await page.getByLabel('Current Password').fill(TEST_USER_PASSWORD); + await page.getByLabel('New Password').fill(newPassword); + await page.getByLabel('Confirm Password').fill(newPassword); + + const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form'); + const responsePromise = page.waitForResponse( + (response) => + response.url().includes('/user/password') && response.request().method() === 'PUT' + ); + await passwordForm.getByRole('button', { name: 'Save' }).click(); + const response = await responsePromise; + + // Verify successful response (303 is Inertia redirect on success, means password was updated) + expect(response.status()).toBe(303); + + // Verify no error messages are displayed + await expect(page.getByText('does not match')).not.toBeVisible(); + await expect(page.getByText('must be at least')).not.toBeVisible(); +}); + +// ============================================= +// Theme Selection Tests +// ============================================= + +test('test that theme can be changed to dark and light', async ({ page }) => { + await goToProfilePage(page); + + // The theme select is a Reka UI combobox (button), not a native