diff --git a/app/Console/Commands/Report/ReportSetExpiredToPrivateCommand.php b/app/Console/Commands/Report/ReportSetExpiredToPrivateCommand.php new file mode 100644 index 00000000..57b09718 --- /dev/null +++ b/app/Console/Commands/Report/ReportSetExpiredToPrivateCommand.php @@ -0,0 +1,61 @@ +comment('Makes public reports private if the public_until date has passed...'); + $dryRun = (bool) $this->option('dry-run'); + if ($dryRun) { + $this->comment('Running in dry-run mode. Nothing will be saved to the database.'); + } + + $resetReports = 0; + Report::query() + ->where('public_until', '<', Carbon::now()) + ->orderBy('created_at', 'asc') + ->chunk(500, function (Collection $reports) use ($dryRun, &$resetReports): void { + /** @var Collection $reports */ + foreach ($reports as $report) { + $this->info('Make report "'.$report->name.'" ('.$report->getKey().') private, expired: '.$report->public_until->toIso8601ZuluString().' ('.$report->public_until->diffForHumans().')'); + $resetReports++; + if (! $dryRun) { + $report->is_public = false; + $report->share_secret = null; + $report->save(); + } + } + }); + + $this->comment('Finished setting '.$resetReports.' expired reports to private...'); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Api/V1/Public/ReportController.php b/app/Http/Controllers/Api/V1/Public/ReportController.php new file mode 100644 index 00000000..31e422da --- /dev/null +++ b/app/Http/Controllers/Api/V1/Public/ReportController.php @@ -0,0 +1,44 @@ +header('X-Api-Key'); + if (! is_string($shareSecret)) { + throw new ModelNotFoundException; + } + + $report = Report::query() + ->where('share_secret', '=', $shareSecret) + ->where('is_public', '=', true) + ->where(function (Builder $builder): void { + /** @var Builder $builder */ + $builder->whereNull('public_until') + ->orWhere('public_until', '>', now()); + }) + ->firstOrFail(); + + return new DetailedReportResource($report); + } +} diff --git a/app/Http/Controllers/Api/V1/ReportController.php b/app/Http/Controllers/Api/V1/ReportController.php new file mode 100644 index 00000000..5b4e85c1 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ReportController.php @@ -0,0 +1,146 @@ +organization_id !== $organization->id) { + throw new AuthorizationException('Report does not belong to organization'); + } + } + + /** + * Get reports + * + * @throws AuthorizationException + * + * @operationId getReports + */ + public function index(Organization $organization): ReportCollection + { + $this->checkPermission($organization, 'reports:view'); + + $reports = Report::query() + ->orderBy('created_at', 'desc') + ->whereBelongsTo($organization, 'organization') + ->paginate(config('app.pagination_per_page_default')); + + return new ReportCollection($reports); + } + + /** + * Get report + * + * @throws AuthorizationException + * + * @operationId getReport + */ + public function show(Organization $organization, Report $report): DetailedReportResource + { + $this->checkPermission($organization, 'reports:view', $report); + + return new DetailedReportResource($report); + } + + /** + * Create report + * + * @throws AuthorizationException + * + * @operationId createReport + */ + public function store(Organization $organization, ReportStoreRequest $request): DetailedReportResource + { + $this->checkPermission($organization, 'reports:create'); + + $report = new Report; + $report->name = $request->getName(); + $report->description = $request->getDescription(); + $isPublic = $request->getIsPublic(); + $report->is_public = $isPublic; + $properties = new ReportPropertiesDto; + $properties->group = TimeEntryAggregationType::from($request->input('properties.group')); + $properties->subGroup = TimeEntryAggregationType::from($request->input('properties.sub_group')); + $report->properties = $properties; + if ($isPublic) { + $report->share_secret = app(ReportService::class)->generateSecret(); + $report->public_until = $request->getPublicUntil(); + } else { + $report->share_secret = null; + $report->public_until = null; + } + $report->organization()->associate($organization); + $report->save(); + + return new DetailedReportResource($report); + } + + /** + * Update report + * + * @throws AuthorizationException + * + * @operationId updateReport + */ + public function update(Organization $organization, Report $report, ReportUpdateRequest $request): DetailedReportResource + { + $this->checkPermission($organization, 'reports:update', $report); + + if ($request->has('name')) { + $report->name = $request->getName(); + } + if ($request->has('description')) { + $report->description = $request->getDescription(); + } + if ($request->has('is_public') && $request->getIsPublic() !== $report->is_public) { + $isPublic = $request->getIsPublic(); + $report->is_public = $isPublic; + if ($isPublic) { + $report->share_secret = app(ReportService::class)->generateSecret(); + $report->public_until = $request->getPublicUntil(); + } else { + $report->share_secret = null; + $report->public_until = null; + } + } + $report->save(); + + return new DetailedReportResource($report); + } + + /** + * Delete report + * + * @throws AuthorizationException + * + * @operationId deleteReport + */ + public function destroy(Organization $organization, Report $report): JsonResponse + { + $this->checkPermission($organization, 'reports:delete', $report); + + $report->delete(); + + return response()->json(null, 204); + } +} diff --git a/app/Http/Requests/V1/Report/ReportStoreRequest.php b/app/Http/Requests/V1/Report/ReportStoreRequest.php new file mode 100644 index 00000000..bdcaa25f --- /dev/null +++ b/app/Http/Requests/V1/Report/ReportStoreRequest.php @@ -0,0 +1,140 @@ +> + */ + public function rules(): array + { + return [ + 'name' => [ + 'required', + 'string', + 'max:255', + ], + 'description' => [ + 'nullable', + 'string', + ], + 'is_public' => [ + 'required', + 'boolean', + ], + // After this date the report will be automatically set to private (is_public=false) (ISO 8601 format, UTC timezone) + 'public_until' => [ + 'nullable', + 'date_format:Y-m-d\TH:i:s\Z', + 'after:now', + ], + 'properties' => [ + 'required', + 'array', + ], + 'properties.start' => [ + 'nullable', + 'date_format:Y-m-d\TH:i:s\Z', + ], + 'properties.end' => [ + 'nullable', + 'date_format:Y-m-d\TH:i:s\Z', + ], + 'properties.active' => [ + 'nullable', + 'boolean', + ], + 'properties.member_ids' => [ + 'nullable', + 'array', + ], + 'properties.member_ids.*' => [ + 'string', + 'uuid', + ], + 'properties.billable' => [ + 'nullable', + 'boolean', + ], + 'properties.client_ids' => [ + 'nullable', + 'array', + ], + 'properties.client_ids.*' => [ + 'string', + 'uuid', + ], + 'properties.project_ids' => [ + 'nullable', + 'array', + ], + 'properties.project_ids.*' => [ + 'string', + 'uuid', + ], + 'properties.tag_ids' => [ + 'nullable', + 'array', + ], + 'properties.tag_ids.*' => [ + 'string', + 'uuid', + ], + 'properties.task_ids' => [ + 'nullable', + 'array', + ], + 'properties.task_ids.*' => [ + 'string', + 'uuid', + ], + 'properties.group' => [ + 'required', + Rule::enum(TimeEntryAggregationType::class), + ], + + 'properties.sub_group' => [ + 'required', + Rule::enum(TimeEntryAggregationType::class), + ], + ]; + } + + public function getName(): string + { + return (string) $this->input('name'); + } + + public function getDescription(): ?string + { + return $this->input('description'); + } + + public function getIsPublic(): bool + { + return (bool) $this->input('is_public'); + } + + public function getPublicUntil(): ?Carbon + { + $publicUntil = $this->input('public_until'); + + return $publicUntil === null ? null : Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $publicUntil); + } +} diff --git a/app/Http/Requests/V1/Report/ReportUpdateRequest.php b/app/Http/Requests/V1/Report/ReportUpdateRequest.php new file mode 100644 index 00000000..b7b486c6 --- /dev/null +++ b/app/Http/Requests/V1/Report/ReportUpdateRequest.php @@ -0,0 +1,65 @@ +> + */ + public function rules(): array + { + return [ + 'name' => [ + 'string', + 'max:255', + ], + 'description' => [ + 'nullable', + 'string', + ], + 'is_public' => [ + 'boolean', + ], + 'public_until' => [ + 'nullable', + 'date_format:Y-m-d\TH:i:s\Z', + 'after:now', + ], + ]; + } + + public function getName(): string + { + return (string) $this->input('name'); + } + + public function getDescription(): ?string + { + return $this->input('description'); + } + + public function getIsPublic(): bool + { + return (bool) $this->input('is_public'); + } + + public function getPublicUntil(): ?Carbon + { + $publicUntil = $this->input('public_until'); + + return $publicUntil === null ? null : Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $publicUntil); + } +} diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php index 8d400817..3cc54692 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php @@ -34,7 +34,7 @@ public function rules(): array return [ 'group' => [ 'nullable', - 'required_with:group_2', + 'required_with:sub_group', Rule::enum(TimeEntryAggregationType::class), ], diff --git a/app/Http/Resources/V1/Report/DetailedReportResource.php b/app/Http/Resources/V1/Report/DetailedReportResource.php new file mode 100644 index 00000000..b576932f --- /dev/null +++ b/app/Http/Resources/V1/Report/DetailedReportResource.php @@ -0,0 +1,54 @@ +>> + */ + public function toArray(Request $request): array + { + return [ + /** @var string $id ID of the report */ + 'id' => $this->resource->id, + /** @var string $name Name */ + 'name' => $this->resource->name, + /** @var string|null $email Description */ + 'description' => $this->resource->description, + /** @var bool $is_public Whether the report can be accessed via an external link */ + 'is_public' => $this->resource->is_public, + /** @var string|null $public_until Date until the report is public */ + 'public_until' => $this->resource->public_until?->toIso8601ZuluString(), + /** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */ + 'shareable_link' => $this->resource->getShareableLink(), + 'properties' => [ + 'group' => $this->resource->properties->group->value, + 'sub_group' => $this->resource->properties->subGroup->value, + /** @var string|null $start Start date of the report */ + 'start' => $this->resource->properties->start?->toIso8601ZuluString(), + /** @var string|null $end End date of the report */ + 'end' => $this->resource->properties->end?->toIso8601ZuluString(), + /** @var bool|null $active Whether the report is active */ + 'active' => $this->resource->properties->active, + 'member_ids' => $this->resource->properties->memberIds?->toArray(), + 'billable' => $this->resource->properties->billable, + 'client_ids' => $this->resource->properties->clientIds?->toArray(), + 'project_ids' => $this->resource->properties->projectIds?->toArray(), + 'tag_ids' => $this->resource->properties->tagIds?->toArray(), + 'task_ids' => $this->resource->properties->taskIds?->toArray(), + ], + ]; + } +} diff --git a/app/Http/Resources/V1/Report/ReportCollection.php b/app/Http/Resources/V1/Report/ReportCollection.php new file mode 100644 index 00000000..4b1c4f5d --- /dev/null +++ b/app/Http/Resources/V1/Report/ReportCollection.php @@ -0,0 +1,18 @@ +> + */ + public function toArray(Request $request): array + { + return [ + /** @var string $id ID of the report */ + 'id' => $this->resource->id, + /** @var string $name Name */ + 'name' => $this->resource->name, + /** @var string|null $email Description */ + 'description' => $this->resource->description, + /** @var bool $is_public Whether the report can be accessed via an external link */ + 'is_public' => $this->resource->is_public, + /** @var string|null $public_until Date until the report is public */ + 'public_until' => $this->resource->public_until?->toIso8601ZuluString(), + /** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */ + 'shareable_link' => $this->resource->getShareableLink(), + ]; + } +} diff --git a/app/Models/Report.php b/app/Models/Report.php new file mode 100644 index 00000000..9751a191 --- /dev/null +++ b/app/Models/Report.php @@ -0,0 +1,62 @@ + */ + use HasFactory; + + use HasUuids; + + /** + * The attributes that should be cast. + * + * @var array + */ + protected $casts = [ + 'is_public' => 'bool', + 'public_until' => 'datetime', + 'properties' => ReportPropertiesDto::class, + ]; + + public function getShareableLink(): ?string + { + if ($this->is_public && $this->share_secret !== null) { + return route('shared-report').'#'.$this->share_secret; + } + + return null; + } + + /** + * @return BelongsTo + */ + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } +} diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index 028fdb71..0a4a234c 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -126,6 +126,10 @@ protected function configurePermissions(): void 'members:update', 'members:delete', 'billing', + 'reports:view', + 'reports:create', + 'reports:update', + 'reports:delete', ])->description('Owner users can perform any action. There is only one owner per organization.'); Jetstream::role(Role::Admin->value, 'Administrator', [ @@ -170,6 +174,10 @@ protected function configurePermissions(): void 'members:view', 'members:update', 'members:invite-placeholder', + 'reports:view', + 'reports:create', + 'reports:update', + 'reports:delete', ])->description('Administrator users can perform any action, except accessing the billing dashboard.'); Jetstream::role(Role::Manager->value, 'Manager', [ @@ -206,6 +214,10 @@ protected function configurePermissions(): void 'organizations:view', 'invitations:view', 'members:view', + 'reports:view', + 'reports:create', + 'reports:update', + 'reports:delete', ])->description('Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).'); Jetstream::role(Role::Employee->value, 'Employee', [ diff --git a/app/Service/Dto/ReportPropertiesDto.php b/app/Service/Dto/ReportPropertiesDto.php new file mode 100644 index 00000000..6de7074f --- /dev/null +++ b/app/Service/Dto/ReportPropertiesDto.php @@ -0,0 +1,160 @@ +|null + */ + public ?Collection $memberIds = null; + + public ?bool $billable = null; + + /** + * @var Collection|null + */ + public ?Collection $clientIds = null; + + /** + * @var Collection|null + */ + public ?Collection $projectIds = null; + + /** + * @var Collection|null + */ + public ?Collection $tagIds = null; + + /** + * @var Collection|null + */ + public ?Collection $taskIds = null; + + /** + * Get the caster class to use when casting from / to this cast target. + * + * @param array $arguments + * @return CastsAttributes + */ + public static function castUsing(array $arguments): CastsAttributes + { + return new class implements CastsAttributes + { + private const array REQUIRED_PROPERTIES = [ + 'group', + 'subGroup', + 'start', + 'end', + 'active', + 'memberIds', + 'billable', + 'clientIds', + 'projectIds', + 'tagIds', + 'taskIds', + ]; + + public function get(Model $model, string $key, mixed $value, array $attributes): ReportPropertiesDto + { + if (! is_string($value)) { + throw new \InvalidArgumentException('The given value is not a string'); + } + $data = json_decode($value, false); + if ($data === null) { + throw new \InvalidArgumentException('The given value is not a JSON string'); + } + foreach (self::REQUIRED_PROPERTIES as $property) { + if (! property_exists($data, $property)) { + throw new \InvalidArgumentException('The given JSON string does not contain the required property "'.$property.'"'); + } + } + $dto = new ReportPropertiesDto; + $dto->end = $data->end !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->end) : null; + $dto->start = $data->start !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->start) : null; + $dto->active = $data->active; + $dto->memberIds = $data->memberIds !== null ? $this->idArrayToCollection($data->memberIds) : null; + $dto->billable = $data->billable; + $dto->clientIds = $data->clientIds !== null ? $this->idArrayToCollection($data->clientIds) : null; + $dto->projectIds = $data->projectIds !== null ? $this->idArrayToCollection($data->projectIds) : null; + $dto->tagIds = $data->tagIds !== null ? $this->idArrayToCollection($data->tagIds) : null; + $dto->taskIds = $data->taskIds ? $this->idArrayToCollection($data->taskIds) : null; + $dto->group = $data->group !== null ? TimeEntryAggregationType::from($data->group) : null; + $dto->subGroup = $data->subGroup !== null ? TimeEntryAggregationType::from($data->subGroup) : null; + + return $dto; + } + + /** + * @param array $ids + * @return Collection + */ + private function idArrayToCollection(array $ids): Collection + { + $collection = new Collection; + foreach ($ids as $id) { + if (! is_string($id)) { + throw new \InvalidArgumentException('The given ID is not a string'); + } + if (Str::isUuid($id)) { + throw new \InvalidArgumentException('The given ID is not a valid UUID'); + } + $collection->push($id); + } + + return $collection; + } + + /** + * @param ReportPropertiesDto $value + */ + public function set(Model $model, string $key, mixed $value, array $attributes): string + { + if (! ($value instanceof ReportPropertiesDto)) { + throw new \InvalidArgumentException('The given value is not an instance of ReportPropertiesDto'); + } + + $data = (object) [ + 'end' => $value->end?->toIso8601ZuluString(), + 'start' => $value->start?->toIso8601ZuluString(), + 'active' => $value->active, + 'memberIds' => $value->memberIds?->toArray(), + 'billable' => $value->billable, + 'clientIds' => $value->clientIds?->toArray(), + 'projectIds' => $value->projectIds?->toArray(), + 'tagIds' => $value->tagIds?->toArray(), + 'taskIds' => $value->taskIds?->toArray(), + 'group' => $value->group?->value, + 'subGroup' => $value->subGroup?->value, + ]; + + $jsonString = json_encode($data); + if ($jsonString === false) { + throw new \InvalidArgumentException('Could not encode the given data to a JSON string'); + } + + return $jsonString; + } + }; + } +} diff --git a/app/Service/ReportService.php b/app/Service/ReportService.php new file mode 100644 index 00000000..ee39c436 --- /dev/null +++ b/app/Service/ReportService.php @@ -0,0 +1,15 @@ +state(function (array $attributes) use ($organization) { - return [ - 'organization_id' => $organization->getKey(), - ]; - }); + return $this->state(fn (array $attributes) => [ + 'organization_id' => $organization->getKey(), + ]); } public function randomCreatedAt(): self diff --git a/database/factories/MemberFactory.php b/database/factories/MemberFactory.php index 93dce2bf..926a10c1 100644 --- a/database/factories/MemberFactory.php +++ b/database/factories/MemberFactory.php @@ -41,20 +41,16 @@ public function role(Role $role): static public function forOrganization(Organization $organization): static { - return $this->state(function (array $attributes) use ($organization): array { - return [ - 'organization_id' => $organization->getKey(), - ]; - }); + return $this->state(fn (array $attributes): array => [ + 'organization_id' => $organization->getKey(), + ]); } public function forUser(User $user): static { - return $this->state(function (array $attributes) use ($user): array { - return [ - 'user_id' => $user->getKey(), - ]; - }); + return $this->state(fn (array $attributes): array => [ + 'user_id' => $user->getKey(), + ]); } /** diff --git a/database/factories/ReportFactory.php b/database/factories/ReportFactory.php new file mode 100644 index 00000000..0a6ae458 --- /dev/null +++ b/database/factories/ReportFactory.php @@ -0,0 +1,68 @@ + + */ +class ReportFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $reportDto = new ReportPropertiesDto; + $reportDto->group = TimeEntryAggregationType::Project; + $reportDto->subGroup = TimeEntryAggregationType::Task; + + return [ + 'name' => $this->faker->company(), + 'description' => $this->faker->paragraph(), + 'is_public' => $this->faker->boolean(), + 'properties' => $reportDto, + 'organization_id' => Organization::factory(), + ]; + } + + public function randomCreatedAt(): self + { + return $this->state(fn (array $attributes): array => [ + 'created_at' => $this->faker->dateTimeBetween('-1 year', 'now'), + ]); + } + + public function public(): self + { + return $this->state(fn (array $attributes): array => [ + 'is_public' => true, + 'share_secret' => app(ReportService::class)->generateSecret(), + ]); + } + + public function private(): self + { + return $this->state(fn (array $attributes): array => [ + 'is_public' => false, + 'share_secret' => null, + ]); + } + + public function forOrganization(Organization $organization): self + { + return $this->state(fn (array $attributes): array => [ + 'organization_id' => $organization->getKey(), + ]); + } +} diff --git a/database/factories/TaskFactory.php b/database/factories/TaskFactory.php index beeaaf56..88c1d5c5 100644 --- a/database/factories/TaskFactory.php +++ b/database/factories/TaskFactory.php @@ -32,28 +32,22 @@ public function definition(): array public function forProject(Project $project): self { - return $this->state(function (array $attributes) use ($project) { - return [ - 'project_id' => $project->getKey(), - ]; - }); + return $this->state(fn (array $attributes) => [ + 'project_id' => $project->getKey(), + ]); } public function isDone(): self { - return $this->state(function (array $attributes) { - return [ - 'done_at' => $this->faker->dateTime('now', 'UTC'), - ]; - }); + return $this->state(fn (array $attributes) => [ + 'done_at' => $this->faker->dateTime('now', 'UTC'), + ]); } public function forOrganization(Organization $organization): self { - return $this->state(function (array $attributes) use ($organization) { - return [ - 'organization_id' => $organization->getKey(), - ]; - }); + return $this->state(fn (array $attributes) => [ + 'organization_id' => $organization->getKey(), + ]); } } diff --git a/database/factories/TimeEntryFactory.php b/database/factories/TimeEntryFactory.php index 1a1fd5d2..72e10bf1 100644 --- a/database/factories/TimeEntryFactory.php +++ b/database/factories/TimeEntryFactory.php @@ -173,22 +173,18 @@ public function forOrganization(Organization $organization): self public function forProject(?Project $project): self { - return $this->state(function (array $attributes) use ($project) { - return [ - 'project_id' => $project?->getKey(), - 'client_id' => $project?->client_id, - ]; - }); + return $this->state(fn (array $attributes) => [ + 'project_id' => $project?->getKey(), + 'client_id' => $project?->client_id, + ]); } public function forTask(?Task $task): self { - return $this->state(function (array $attributes) use ($task) { - return [ - 'task_id' => $task?->getKey(), - 'project_id' => $task?->project?->getKey(), - 'client_id' => $task?->project?->client?->getKey(), - ]; - }); + return $this->state(fn (array $attributes) => [ + 'task_id' => $task?->getKey(), + 'project_id' => $task?->project?->getKey(), + 'client_id' => $task?->project?->client?->getKey(), + ]); } } diff --git a/database/migrations/2024_08_01_104840_create_reports_table.php b/database/migrations/2024_08_01_104840_create_reports_table.php new file mode 100644 index 00000000..592daf67 --- /dev/null +++ b/database/migrations/2024_08_01_104840_create_reports_table.php @@ -0,0 +1,41 @@ +uuid('id')->primary(); + $table->string('name'); + $table->text('description')->nullable(); + $table->boolean('is_public')->default(false)->index(); + $table->string('share_secret', 40)->nullable()->index()->unique(); + $table->jsonb('properties'); + $table->dateTime('public_until')->nullable(); + $table->uuid('organization_id'); + $table->foreign('organization_id') + ->references('id') + ->on('organizations') + ->restrictOnDelete() + ->cascadeOnUpdate(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('reports'); + } +}; diff --git a/routes/api.php b/routes/api.php index 28ea0a41..371f870c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -10,6 +10,8 @@ use App\Http\Controllers\Api\V1\OrganizationController; use App\Http\Controllers\Api\V1\ProjectController; use App\Http\Controllers\Api\V1\ProjectMemberController; +use App\Http\Controllers\Api\V1\Public\ReportController as PublicReportController; +use App\Http\Controllers\Api\V1\ReportController; use App\Http\Controllers\Api\V1\TagController; use App\Http\Controllers\Api\V1\TaskController; use App\Http\Controllers\Api\V1\TimeEntryController; @@ -30,108 +32,124 @@ | */ -Route::middleware([ - 'auth:api', - 'verified', -])->prefix('v1')->name('v1.')->group(static function (): void { - // Organization routes - Route::name('organizations.')->group(static function (): void { - Route::get('/organizations/{organization}', [OrganizationController::class, 'show'])->name('show'); - Route::put('/organizations/{organization}', [OrganizationController::class, 'update'])->name('update')->middleware('check-organization-blocked'); +Route::prefix('v1')->name('v1.')->group(static function (): void { + Route::middleware([ + 'auth:api', + 'verified', + ])->group(static function (): void { + // Organization routes + Route::name('organizations.')->group(static function (): void { + Route::get('/organizations/{organization}', [OrganizationController::class, 'show'])->name('show'); + Route::put('/organizations/{organization}', [OrganizationController::class, 'update'])->name('update'); + }); + + // Member routes + Route::name('members.')->prefix('/organizations/{organization}')->group(static function (): void { + Route::get('/members', [MemberController::class, 'index'])->name('index'); + Route::put('/members/{member}', [MemberController::class, 'update'])->name('update'); + Route::delete('/members/{member}', [MemberController::class, 'destroy'])->name('destroy'); + Route::post('/members/{member}/invite-placeholder', [MemberController::class, 'invitePlaceholder'])->name('invite-placeholder'); + Route::post('/members/{member}/make-placeholder', [MemberController::class, 'makePlaceholder'])->name('make-placeholder'); + }); + + // User routes + Route::name('users.')->group(static function (): void { + Route::get('/users/me', [UserController::class, 'me'])->name('me'); + }); + + // User Member routes + Route::name('users.memberships.')->group(static function (): void { + Route::get('/users/me/memberships', [UserMembershipController::class, 'myMemberships'])->name('my-memberships'); + }); + + // Invitation routes + Route::name('invitations.')->prefix('/organizations/{organization}')->group(static function (): void { + Route::get('/invitations', [InvitationController::class, 'index'])->name('index'); + Route::post('/invitations', [InvitationController::class, 'store'])->name('store')->middleware('check-organization-blocked'); + Route::post('/invitations/{invitation}/resend', [InvitationController::class, 'resend'])->name('resend')->middleware('check-organization-blocked'); + Route::delete('/invitations/{invitation}', [InvitationController::class, 'destroy'])->name('destroy')->middleware('check-organization-blocked'); + }); + + // Project routes + Route::name('projects.')->prefix('/organizations/{organization}')->group(static function (): void { + Route::get('/projects', [ProjectController::class, 'index'])->name('index'); + Route::get('/projects/{project}', [ProjectController::class, 'show'])->name('show'); + Route::post('/projects', [ProjectController::class, 'store'])->name('store')->middleware('check-organization-blocked'); + Route::put('/projects/{project}', [ProjectController::class, 'update'])->name('update')->middleware('check-organization-blocked'); + Route::delete('/projects/{project}', [ProjectController::class, 'destroy'])->name('destroy'); + }); + + // Project member routes + Route::name('project-members.')->prefix('/organizations/{organization}')->group(static function (): void { + Route::get('/projects/{project}/project-members', [ProjectMemberController::class, 'index'])->name('index'); + Route::post('/projects/{project}/project-members', [ProjectMemberController::class, 'store'])->name('store')->middleware('check-organization-blocked'); + Route::put('/project-members/{projectMember}', [ProjectMemberController::class, 'update'])->name('update')->middleware('check-organization-blocked'); + Route::delete('/project-members/{projectMember}', [ProjectMemberController::class, 'destroy'])->name('destroy'); + }); + + // Time entry routes + Route::name('time-entries.')->prefix('/organizations/{organization}')->group(static function (): void { + Route::get('/time-entries', [TimeEntryController::class, 'index'])->name('index'); + Route::get('/time-entries/aggregate', [TimeEntryController::class, 'aggregate'])->name('aggregate'); + Route::post('/time-entries', [TimeEntryController::class, 'store'])->name('store')->middleware('check-organization-blocked'); + Route::put('/time-entries/{timeEntry}', [TimeEntryController::class, 'update'])->name('update')->middleware('check-organization-blocked'); + Route::patch('/time-entries', [TimeEntryController::class, 'updateMultiple'])->name('update-multiple')->middleware('check-organization-blocked'); + Route::delete('/time-entries/{timeEntry}', [TimeEntryController::class, 'destroy'])->name('destroy'); + Route::delete('/time-entries', [TimeEntryController::class, 'destroyMultiple'])->name('destroy-multiple'); + }); + + Route::name('users.time-entries.')->group(static function (): void { + Route::get('/users/me/time-entries/active', [UserTimeEntryController::class, 'myActive'])->name('my-active'); + }); + + // Report routes + Route::name('reports.')->prefix('/organizations/{organization}')->group(static function (): void { + Route::get('/reports', [ReportController::class, 'index'])->name('index'); + Route::get('/reports/{report}', [ReportController::class, 'show'])->name('show'); + Route::post('/reports', [ReportController::class, 'store'])->name('store'); + Route::put('/reports/{report}', [ReportController::class, 'update'])->name('update'); + Route::delete('/reports/{report}', [ReportController::class, 'destroy'])->name('destroy'); + }); + + // Tag routes + Route::name('tags.')->prefix('/organizations/{organization}')->group(static function (): void { + Route::get('/tags', [TagController::class, 'index'])->name('index'); + Route::post('/tags', [TagController::class, 'store'])->name('store')->middleware('check-organization-blocked'); + Route::put('/tags/{tag}', [TagController::class, 'update'])->name('update')->middleware('check-organization-blocked'); + Route::delete('/tags/{tag}', [TagController::class, 'destroy'])->name('destroy'); + }); + + // Client routes + Route::name('clients.')->prefix('/organizations/{organization}')->group(static function (): void { + Route::get('/clients', [ClientController::class, 'index'])->name('index'); + Route::post('/clients', [ClientController::class, 'store'])->name('store')->middleware('check-organization-blocked'); + Route::put('/clients/{client}', [ClientController::class, 'update'])->name('update')->middleware('check-organization-blocked'); + Route::delete('/clients/{client}', [ClientController::class, 'destroy'])->name('destroy'); + }); + + // Task routes + Route::name('tasks.')->prefix('/organizations/{organization}')->group(static function (): void { + Route::get('/tasks', [TaskController::class, 'index'])->name('index'); + Route::post('/tasks', [TaskController::class, 'store'])->name('store')->middleware('check-organization-blocked'); + Route::put('/tasks/{task}', [TaskController::class, 'update'])->name('update')->middleware('check-organization-blocked'); + Route::delete('/tasks/{task}', [TaskController::class, 'destroy'])->name('destroy'); + }); + + // Import routes + Route::name('import.')->prefix('/organizations/{organization}')->group(static function (): void { + Route::get('/importers', [ImportController::class, 'index'])->name('index'); + Route::post('/import', [ImportController::class, 'import'])->name('import')->middleware('check-organization-blocked'); + }); + + // Export routes + Route::name('export.')->prefix('/organizations/{organization}')->group(static function (): void { + Route::post('/export', [ExportController::class, 'export'])->name('export'); + }); }); - // Member routes - Route::name('members.')->group(static function (): void { - Route::get('/organizations/{organization}/members', [MemberController::class, 'index'])->name('index'); - Route::put('/organizations/{organization}/members/{member}', [MemberController::class, 'update'])->name('update'); - Route::delete('/organizations/{organization}/members/{member}', [MemberController::class, 'destroy'])->name('destroy'); - Route::post('/organizations/{organization}/members/{member}/invite-placeholder', [MemberController::class, 'invitePlaceholder'])->name('invite-placeholder'); - Route::post('/organizations/{organization}/members/{member}/make-placeholder', [MemberController::class, 'makePlaceholder'])->name('make-placeholder'); - }); - - // User routes - Route::name('users.')->group(static function (): void { - Route::get('/users/me', [UserController::class, 'me'])->name('me'); - }); - - // User Member routes - Route::name('users.memberships.')->group(static function (): void { - Route::get('/users/me/memberships', [UserMembershipController::class, 'myMemberships'])->name('my-memberships'); - }); - - // Invitation routes - Route::name('invitations.')->group(static function (): void { - Route::get('/organizations/{organization}/invitations', [InvitationController::class, 'index'])->name('index'); - Route::post('/organizations/{organization}/invitations', [InvitationController::class, 'store'])->name('store')->middleware('check-organization-blocked'); - Route::post('/organizations/{organization}/invitations/{invitation}/resend', [InvitationController::class, 'resend'])->name('resend')->middleware('check-organization-blocked'); - Route::delete('/organizations/{organization}/invitations/{invitation}', [InvitationController::class, 'destroy'])->name('destroy')->middleware('check-organization-blocked'); - }); - - // Project routes - Route::name('projects.')->group(static function (): void { - Route::get('/organizations/{organization}/projects', [ProjectController::class, 'index'])->name('index'); - Route::get('/organizations/{organization}/projects/{project}', [ProjectController::class, 'show'])->name('show'); - Route::post('/organizations/{organization}/projects', [ProjectController::class, 'store'])->name('store')->middleware('check-organization-blocked'); - Route::put('/organizations/{organization}/projects/{project}', [ProjectController::class, 'update'])->name('update')->middleware('check-organization-blocked'); - Route::delete('/organizations/{organization}/projects/{project}', [ProjectController::class, 'destroy'])->name('destroy'); - }); - - // Project member routes - Route::name('project-members.')->group(static function (): void { - Route::get('/organizations/{organization}/projects/{project}/project-members', [ProjectMemberController::class, 'index'])->name('index'); - Route::post('/organizations/{organization}/projects/{project}/project-members', [ProjectMemberController::class, 'store'])->name('store')->middleware('check-organization-blocked'); - Route::put('/organizations/{organization}/project-members/{projectMember}', [ProjectMemberController::class, 'update'])->name('update')->middleware('check-organization-blocked'); - Route::delete('/organizations/{organization}/project-members/{projectMember}', [ProjectMemberController::class, 'destroy'])->name('destroy'); - }); - - // Time entry routes - Route::name('time-entries.')->group(static function (): void { - Route::get('/organizations/{organization}/time-entries', [TimeEntryController::class, 'index'])->name('index'); - Route::get('/organizations/{organization}/time-entries/aggregate', [TimeEntryController::class, 'aggregate'])->name('aggregate'); - Route::post('/organizations/{organization}/time-entries', [TimeEntryController::class, 'store'])->name('store')->middleware('check-organization-blocked'); - Route::put('/organizations/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'update'])->name('update')->middleware('check-organization-blocked'); - Route::patch('/organizations/{organization}/time-entries', [TimeEntryController::class, 'updateMultiple'])->name('update-multiple')->middleware('check-organization-blocked'); - Route::delete('/organizations/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'destroy'])->name('destroy'); - Route::delete('/organizations/{organization}/time-entries', [TimeEntryController::class, 'destroyMultiple'])->name('destroy-multiple'); - }); - - Route::name('users.time-entries.')->group(static function (): void { - Route::get('/users/me/time-entries/active', [UserTimeEntryController::class, 'myActive'])->name('my-active'); - }); - - // Tag routes - Route::name('tags.')->group(static function (): void { - Route::get('/organizations/{organization}/tags', [TagController::class, 'index'])->name('index'); - Route::post('/organizations/{organization}/tags', [TagController::class, 'store'])->name('store')->middleware('check-organization-blocked'); - Route::put('/organizations/{organization}/tags/{tag}', [TagController::class, 'update'])->name('update')->middleware('check-organization-blocked'); - Route::delete('/organizations/{organization}/tags/{tag}', [TagController::class, 'destroy'])->name('destroy'); - }); - - // Client routes - Route::name('clients.')->group(static function (): void { - Route::get('/organizations/{organization}/clients', [ClientController::class, 'index'])->name('index'); - Route::post('/organizations/{organization}/clients', [ClientController::class, 'store'])->name('store')->middleware('check-organization-blocked'); - Route::put('/organizations/{organization}/clients/{client}', [ClientController::class, 'update'])->name('update')->middleware('check-organization-blocked'); - Route::delete('/organizations/{organization}/clients/{client}', [ClientController::class, 'destroy'])->name('destroy'); - }); - - // Task routes - Route::name('tasks.')->group(static function (): void { - Route::get('/organizations/{organization}/tasks', [TaskController::class, 'index'])->name('index'); - Route::post('/organizations/{organization}/tasks', [TaskController::class, 'store'])->name('store')->middleware('check-organization-blocked'); - Route::put('/organizations/{organization}/tasks/{task}', [TaskController::class, 'update'])->name('update')->middleware('check-organization-blocked'); - Route::delete('/organizations/{organization}/tasks/{task}', [TaskController::class, 'destroy'])->name('destroy'); - }); - - // Import routes - Route::name('import.')->group(static function (): void { - Route::get('/organizations/{organization}/importers', [ImportController::class, 'index'])->name('index'); - Route::post('/organizations/{organization}/import', [ImportController::class, 'import'])->name('import')->middleware('check-organization-blocked'); - }); - - // Export routes - Route::name('export.')->prefix('/organizations/{organization}')->group(static function (): void { - Route::post('/export', [ExportController::class, 'export'])->name('export'); + // Public routes + Route::name('public.')->prefix('/public')->group(static function (): void { + Route::get('/reports', [PublicReportController::class, 'show'])->name('reports.show'); }); }); diff --git a/routes/web.php b/routes/web.php index b5c59cca..df47fd9a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -21,6 +21,10 @@ Route::get('/', [HomeController::class, 'index']); +Route::get('/shared-report', function () { + return Inertia::render('SharedReport'); +})->name('shared-report'); + Route::middleware([ 'auth:web', config('jetstream.auth_session'), @@ -65,5 +69,4 @@ Route::get('/import', function () { return Inertia::render('Import'); })->name('import'); - }); diff --git a/tests/Feature/DeleteOrganizationTest.php b/tests/Feature/DeleteOrganizationTest.php index e36aff78..a0c1f9d4 100644 --- a/tests/Feature/DeleteOrganizationTest.php +++ b/tests/Feature/DeleteOrganizationTest.php @@ -32,7 +32,7 @@ public function test_organizations_can_be_deleted_and_users_of_the_organization_ ); // Act - $response = $this->withoutExceptionHandling()->delete('/teams/'.$organization->getKey()); + $response = $this->delete('/teams/'.$organization->getKey()); // Assert $this->assertNull($organization->fresh()); diff --git a/tests/Unit/Console/Commands/Report/ReportSetExpiredToPrivateCommandTest.php b/tests/Unit/Console/Commands/Report/ReportSetExpiredToPrivateCommandTest.php new file mode 100644 index 00000000..9df61274 --- /dev/null +++ b/tests/Unit/Console/Commands/Report/ReportSetExpiredToPrivateCommandTest.php @@ -0,0 +1,123 @@ +private()->create([ + 'public_until' => now()->subDay(), + ]); + $reportPublicExpired = Report::factory()->public()->create([ + 'public_until' => now()->subDay(), + ]); + $reportPrivateNoExpiration = Report::factory()->private()->create([ + 'public_until' => null, + ]); + $reportPublicNoExpiration = Report::factory()->public()->create([ + 'public_until' => null, + ]); + $reportPrivateNotExpired = Report::factory()->private()->create([ + 'public_until' => now()->addDay(), + ]); + $reportPublicNotExpired = Report::factory()->public()->create([ + 'public_until' => now()->addDay(), + ]); + + // Act + $exitCode = $this->withoutMockingConsoleOutput()->artisan('report:set-expired-to-private'); + + // Assert + $this->assertSame(Command::SUCCESS, $exitCode); + $output = Artisan::output(); + $this->assertStringContainsString('Makes public reports private if the public_until date has passed...', $output); + $this->assertStringContainsString('Make report "'.$reportPrivateExpired->name.'" ('.$reportPrivateExpired->getKey().') private, expired: '.$reportPrivateExpired->public_until->toIso8601ZuluString().' ('.$reportPrivateExpired->public_until->diffForHumans().')', $output); + $this->assertStringContainsString('Make report "'.$reportPublicExpired->name.'" ('.$reportPublicExpired->getKey().') private, expired: '.$reportPublicExpired->public_until->toIso8601ZuluString().' ('.$reportPublicExpired->public_until->diffForHumans().')', $output); + $this->assertStringContainsString('Finished setting 2 expired reports to private...', $output); + $reportPrivateExpired->refresh(); + $reportPublicExpired->refresh(); + $reportPrivateNoExpiration->refresh(); + $reportPublicNoExpiration->refresh(); + $reportPrivateNotExpired->refresh(); + $reportPublicNotExpired->refresh(); + $this->assertFalse($reportPrivateExpired->is_public); + $this->assertNull($reportPrivateExpired->share_secret); + $this->assertFalse($reportPublicExpired->is_public); + $this->assertNull($reportPublicExpired->share_secret); + $this->assertFalse($reportPrivateNoExpiration->is_public); + $this->assertNull($reportPrivateNoExpiration->share_secret); + $this->assertTrue($reportPublicNoExpiration->is_public); + $this->assertNotNull($reportPublicNoExpiration->share_secret); + $this->assertFalse($reportPrivateNotExpired->is_public); + $this->assertNull($reportPrivateNotExpired->share_secret); + $this->assertTrue($reportPublicNotExpired->is_public); + $this->assertNotNull($reportPublicNotExpired->share_secret); + } + + public function test_command_sets_expired_reports_to_private_in_dry_run_mode(): void + { + // Arrange + $reportPrivateExpired = Report::factory()->private()->create([ + 'public_until' => now()->subDay(), + ]); + $reportPublicExpired = Report::factory()->public()->create([ + 'public_until' => now()->subDay(), + ]); + $reportPrivateNoExpiration = Report::factory()->private()->create([ + 'public_until' => null, + ]); + $reportPublicNoExpiration = Report::factory()->public()->create([ + 'public_until' => null, + ]); + $reportPrivateNotExpired = Report::factory()->private()->create([ + 'public_until' => now()->addDay(), + ]); + $reportPublicNotExpired = Report::factory()->public()->create([ + 'public_until' => now()->addDay(), + ]); + + // Act + $exitCode = $this->withoutMockingConsoleOutput()->artisan('report:set-expired-to-private', ['--dry-run' => true]); + + // Assert + $this->assertSame(Command::SUCCESS, $exitCode); + $output = Artisan::output(); + $this->assertStringContainsString('Makes public reports private if the public_until date has passed...', $output); + $this->assertStringContainsString('Running in dry-run mode. Nothing will be saved to the database.', $output); + $this->assertStringContainsString('Make report "'.$reportPrivateExpired->name.'" ('.$reportPrivateExpired->getKey().') private, expired: '.$reportPrivateExpired->public_until->toIso8601ZuluString().' ('.$reportPrivateExpired->public_until->diffForHumans().')', $output); + $this->assertStringContainsString('Make report "'.$reportPublicExpired->name.'" ('.$reportPublicExpired->getKey().') private, expired: '.$reportPublicExpired->public_until->toIso8601ZuluString().' ('.$reportPublicExpired->public_until->diffForHumans().')', $output); + $this->assertStringContainsString('Finished setting 2 expired reports to private...', $output); + $reportPrivateExpired->refresh(); + $reportPublicExpired->refresh(); + $reportPrivateNoExpiration->refresh(); + $reportPublicNoExpiration->refresh(); + $reportPrivateNotExpired->refresh(); + $reportPublicNotExpired->refresh(); + $this->assertFalse($reportPrivateExpired->is_public); + $this->assertNull($reportPrivateExpired->share_secret); + $this->assertTrue($reportPublicExpired->is_public); + $this->assertNotNull($reportPublicExpired->share_secret); + $this->assertFalse($reportPrivateNoExpiration->is_public); + $this->assertNull($reportPrivateNoExpiration->share_secret); + $this->assertTrue($reportPublicNoExpiration->is_public); + $this->assertNotNull($reportPublicNoExpiration->share_secret); + $this->assertFalse($reportPrivateNotExpired->is_public); + $this->assertNull($reportPrivateNotExpired->share_secret); + $this->assertTrue($reportPublicNotExpired->is_public); + $this->assertNotNull($reportPublicNotExpired->share_secret); + } +} diff --git a/tests/Unit/Console/Commands/SelfHost/SelfHostGenerateKeysCommandTest.php b/tests/Unit/Console/Commands/SelfHost/SelfHostGenerateKeysCommandTest.php index 1dfbcddc..6ea723de 100644 --- a/tests/Unit/Console/Commands/SelfHost/SelfHostGenerateKeysCommandTest.php +++ b/tests/Unit/Console/Commands/SelfHost/SelfHostGenerateKeysCommandTest.php @@ -44,4 +44,17 @@ public function test_generates_app_key_and_passport_keys_in_yaml_format_if_reque $this->assertStringContainsString("PASSPORT_PRIVATE_KEY: |\n -----BEGIN PRIVATE KEY-----", $output); $this->assertStringContainsString("PASSPORT_PUBLIC_KEY: |\n -----BEGIN PUBLIC KEY-----", $output); } + + public function test_generates_app_fail_if_attribute_format_is_invalid(): void + { + // Arrange + + // Act + $exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:generate-keys --format=invalid'); + + // Assert + $this->assertSame(Command::FAILURE, $exitCode); + $output = Artisan::output(); + $this->assertSame("Invalid format\n", $output); + } } diff --git a/tests/Unit/Endpoint/Api/V1/OrganizationEndpointTest.php b/tests/Unit/Endpoint/Api/V1/OrganizationEndpointTest.php index aa6a5480..d8599cbc 100644 --- a/tests/Unit/Endpoint/Api/V1/OrganizationEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/OrganizationEndpointTest.php @@ -200,7 +200,7 @@ public function test_update_endpoint_can_update_billable_rate_of_organization_an Passport::actingAs($data->user); // Act - $response = $this->withoutExceptionHandling()->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [ + $response = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [ 'name' => $organizationFake->name, 'billable_rate' => $organizationFake->billable_rate, ]); diff --git a/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php index 6f503fcc..48c14c0d 100644 --- a/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php @@ -23,7 +23,7 @@ public function test_index_endpoint_fails_if_user_has_no_permission_to_view_proj // Arrange $data = $this->createUserWithPermission(); $project = Project::factory()->forOrganization($data->organization)->create(); - $projectMembers = ProjectMember::factory()->forProject($project)->createMany(4); + ProjectMember::factory()->forProject($project)->createMany(4); Passport::actingAs($data->user); // Act @@ -46,7 +46,7 @@ public function test_index_endpoint_fails_if_the_project_does_not_belong_to_give 'project-members:view', ]); $project = Project::factory()->forOrganization($otherData->organization)->create(); - $projectMembers = ProjectMember::factory()->forProject($project)->createMany(4); + ProjectMember::factory()->forProject($project)->createMany(4); Passport::actingAs($data->user); // Act @@ -66,7 +66,7 @@ public function test_index_endpoint_returns_list_of_all_project_members_of_a_pro 'project-members:view', ]); $project = Project::factory()->forOrganization($data->organization)->create(); - $projectMembers = ProjectMember::factory()->forProject($project)->createMany(4); + ProjectMember::factory()->forProject($project)->createMany(4); Passport::actingAs($data->user); // Act diff --git a/tests/Unit/Endpoint/Api/V1/Public/PublicReportEndpointTest.php b/tests/Unit/Endpoint/Api/V1/Public/PublicReportEndpointTest.php new file mode 100644 index 00000000..6bf12fa3 --- /dev/null +++ b/tests/Unit/Endpoint/Api/V1/Public/PublicReportEndpointTest.php @@ -0,0 +1,113 @@ +public()->create(); + + // Act + $response = $this->getJson(route('api.v1.public.reports.show'), [ + 'X-Api-Key' => 'incorrect-secret', + ]); + + // Assert + $response->assertNotFound(); + } + + public function test_show_fails_with_not_found_if_no_secret_is_provided(): void + { + // Arrange + Report::factory()->public()->create(); + + // Act + $response = $this->getJson(route('api.v1.public.reports.show')); + + // Assert + $response->assertNotFound(); + } + + public function test_show_fails_with_not_found_if_report_is_not_public(): void + { + // Arrange + $report = Report::factory()->private()->create(); + + // Act + $response = $this->getJson(route('api.v1.public.reports.show'), [ + 'X-Api-Key' => $report->share_secret, + ]); + + // Assert + $response->assertNotFound(); + } + + public function test_show_fails_with_not_found_if_report_is_expired(): void + { + // Arrange + $report = Report::factory()->public()->create([ + 'public_until' => now()->subDay(), + ]); + + // Act + $response = $this->getJson(route('api.v1.public.reports.show'), [ + 'X-Api-Key' => $report->share_secret, + ]); + + // Assert + $response->assertNotFound(); + } + + public function test_show_returns_detailed_information_about_the_report(): void + { + // Arrange + $report = Report::factory()->public()->create([ + 'public_until' => null, + ]); + + // Act + $response = $this->getJson(route('api.v1.public.reports.show'), [ + 'X-Api-Key' => $report->share_secret, + ]); + + // Assert + $response->assertOk(); + $response->assertJsonFragment([ + 'id' => $report->id, + 'name' => $report->name, + 'description' => $report->description, + 'is_public' => $report->is_public, + 'public_until' => $report->public_until?->toIso8601ZuluString(), + ]); + } + + public function test_show_returns_detailed_information_about_the_report_with_not_expired_expiration_date(): void + { + // Arrange + $report = Report::factory()->public()->create([ + 'public_until' => now()->addDay(), + ]); + + // Act + $response = $this->getJson(route('api.v1.public.reports.show'), [ + 'X-Api-Key' => $report->share_secret, + ]); + + // Assert + $response->assertOk(); + $response->assertJsonFragment([ + 'id' => $report->id, + 'name' => $report->name, + 'description' => $report->description, + 'is_public' => $report->is_public, + 'public_until' => $report->public_until?->toIso8601ZuluString(), + ]); + } +} diff --git a/tests/Unit/Endpoint/Api/V1/ReportEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ReportEndpointTest.php new file mode 100644 index 00000000..8a432d9a --- /dev/null +++ b/tests/Unit/Endpoint/Api/V1/ReportEndpointTest.php @@ -0,0 +1,490 @@ +createUserWithPermission(); + Report::factory()->forOrganization($data->organization)->createMany(4); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.reports.index', ['organization' => $data->organization->getKey()])); + + // Assert + $response->assertForbidden(); + } + + public function test_index_endpoint_returns_list_of_all_reports_of_organization_ordered_by_created_at_desc_per_default(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'reports:view', + ]); + Report::factory()->forOrganization($data->organization)->randomCreatedAt()->createMany(4); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.reports.index', [$data->organization->getKey()])); + + // Assert + $response->assertStatus(200); + $response->assertJsonCount(4, 'data'); + $reports = Report::query()->orderBy('created_at', 'desc')->get(); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->has('links') + ->has('meta') + ->count('data', 4) + ->where('data.0.id', $reports->get(0)->getKey()) + ->where('data.1.id', $reports->get(1)->getKey()) + ->where('data.2.id', $reports->get(2)->getKey()) + ->where('data.3.id', $reports->get(3)->getKey()) + ); + } + + public function test_store_endpoint_fails_if_user_has_no_permission_to_create_report(): void + { + // Arrange + $data = $this->createUserWithPermission(); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [ + 'name' => 'Test Report', + 'is_public' => false, + 'properties' => [ + 'group' => TimeEntryAggregationType::Project->value, + 'sub_group' => TimeEntryAggregationType::Task->value, + ], + ]); + + // Assert + $response->assertForbidden(); + } + + public function test_store_endpoint_creates_new_report_with_minimal_properties(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'reports:create', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [ + 'name' => 'Test Report', + 'is_public' => false, + 'properties' => [ + 'group' => TimeEntryAggregationType::Project->value, + 'sub_group' => TimeEntryAggregationType::Task->value, + ], + ]); + + // Assert + $response->assertStatus(201); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->where('data.name', 'Test Report') + ->where('data.description', null) + ->where('data.is_public', false) + ->where('data.shareable_link', null) + ->where('data.properties.group', TimeEntryAggregationType::Project->value) + ->where('data.properties.sub_group', TimeEntryAggregationType::Task->value) + ); + } + + public function test_store_endpoint_creates_new_report_with_all_properties(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'reports:create', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [ + 'name' => 'Test Report', + 'description' => 'Test description', + 'is_public' => true, + 'public_until' => Carbon::now()->addDays(30)->toIso8601ZuluString(), + 'properties' => [ + 'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(), + 'end' => Carbon::now()->toIso8601ZuluString(), + 'active' => true, + 'member_ids' => [], + 'billable' => true, + 'client_ids' => [], + 'project_ids' => [], + 'tag_ids' => [], + 'task_ids' => [], + 'group' => TimeEntryAggregationType::Project->value, + 'sub_group' => TimeEntryAggregationType::Task->value, + ], + ]); + + // Assert + $response->assertStatus(201); + /** @var Report $report */ + $report = Report::query()->findOrFail($response->json('data.id')); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->where('data.name', 'Test Report') + ->where('data.description', 'Test description') + ->where('data.is_public', true) + ->where('data.shareable_link', $report->getShareableLink()) + ->where('data.properties.group', TimeEntryAggregationType::Project->value) + ->where('data.properties.sub_group', TimeEntryAggregationType::Task->value) + ); + } + + public function test_update_endpoint_fails_if_user_has_no_permission_to_update_report(): void + { + // Arrange + $data = $this->createUserWithPermission(); + $report = Report::factory()->forOrganization($data->organization)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [ + 'name' => 'Updated Report', + ]); + + // Assert + $response->assertForbidden(); + } + + public function test_update_endpoint_fails_if_report_does_not_exist(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'reports:update', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), 1]), [ + 'name' => 'Updated Report', + ]); + + // Assert + $response->assertNotFound(); + } + + public function test_update_endpoint_fails_if_report_does_not_belong_to_organization(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'reports:update', + ]); + $report = Report::factory()->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [ + 'name' => 'Updated Report', + ]); + + // Assert + $response->assertForbidden(); + } + + public function test_update_endpoint_can_update_only_the_name_of_the_report(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'reports:update', + ]); + $report = Report::factory()->forOrganization($data->organization)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [ + 'name' => 'Updated Report', + ]); + + // Assert + $report->refresh(); + $this->assertSame('Updated Report', $report->name); + $response->assertStatus(200); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->where('data.name', 'Updated Report') + ); + } + + public function test_update_endpoint_can_update_only_the_description_of_the_report(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'reports:update', + ]); + $report = Report::factory()->forOrganization($data->organization)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [ + 'description' => 'Updated description', + ]); + + // Assert + $report->refresh(); + $this->assertSame('Updated description', $report->description); + $response->assertStatus(200); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->where('data.description', 'Updated description') + ); + } + + public function test_update_endpoint_can_set_a_report_to_public_which_generates_a_new_secret(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'reports:update', + ]); + $report = Report::factory()->private()->forOrganization($data->organization)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [ + 'is_public' => true, + ]); + + // Assert + $report->refresh(); + $this->assertTrue($report->is_public); + $this->assertNotNull($report->share_secret); + $response->assertStatus(200); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->where('data.is_public', true) + ->where('data.shareable_link', $report->getShareableLink()) + ); + } + + public function test_update_endpoint_can_set_a_report_to_private_which_resets_the_secret(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'reports:update', + ]); + $report = Report::factory()->public()->forOrganization($data->organization)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [ + 'is_public' => false, + ]); + + // Assert + $report->refresh(); + $this->assertFalse($report->is_public); + $this->assertNull($report->share_secret); + $response->assertStatus(200); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->where('data.is_public', false) + ->where('data.shareable_link', null) + ); + } + + public function test_update_endpoint_does_not_change_the_secret_of_a_public_report_if_it_is_set_to_public_again(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'reports:update', + ]); + $report = Report::factory()->public()->forOrganization($data->organization)->create(); + $secret = $report->share_secret; + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [ + 'is_public' => true, + ]); + + // Assert + $report->refresh(); + $this->assertTrue($report->is_public); + $this->assertSame($secret, $report->share_secret); + $response->assertStatus(200); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->where('data.is_public', true) + ->where('data.shareable_link', $report->getShareableLink()) + ); + } + + public function test_update_endpoint_can_update_the_report_all_properties_set(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'reports:update', + ]); + $report = Report::factory()->forOrganization($data->organization)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [ + 'name' => 'Updated Report', + 'description' => 'Updated description', + 'is_public' => true, + 'public_until' => Carbon::now()->addDays(30)->toIso8601ZuluString(), + ]); + + // Assert + $response->assertStatus(200); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->where('data.name', 'Updated Report') + ->where('data.description', 'Updated description') + ->where('data.is_public', true) + ->where('data.properties.group', TimeEntryAggregationType::Project->value) + ->where('data.properties.sub_group', TimeEntryAggregationType::Task->value) + ); + } + + public function test_show_endpoint_fails_if_user_has_no_permission_to_view_report(): void + { + // Arrange + $data = $this->createUserWithPermission(); + $report = Report::factory()->forOrganization($data->organization)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.reports.show', [$data->organization->getKey(), $report->getKey()])); + + // Assert + $response->assertForbidden(); + } + + public function test_show_endpoint_fails_if_report_does_not_exist(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'reports:view', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.reports.show', [$data->organization->getKey(), 1])); + + // Assert + $response->assertNotFound(); + } + + public function test_show_endpoint_fails_if_report_does_not_belong_to_organization(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'reports:view', + ]); + $report = Report::factory()->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.reports.show', [$data->organization->getKey(), $report->getKey()])); + + // Assert + $response->assertForbidden(); + } + + public function test_show_endpoint_returns_detailed_report(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'reports:view', + ]); + $report = Report::factory()->forOrganization($data->organization)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.reports.show', [$data->organization->getKey(), $report->getKey()])); + + // Assert + $response->assertStatus(200); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->where('data.id', $report->getKey()) + ); + } + + public function test_destroy_endpoint_fails_if_user_has_no_permission_to_delete_report(): void + { + // Arrange + $data = $this->createUserWithPermission(); + $report = Report::factory()->forOrganization($data->organization)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->deleteJson(route('api.v1.reports.destroy', [$data->organization->getKey(), $report->getKey()])); + + // Assert + $response->assertForbidden(); + } + + public function test_destroy_endpoint_fails_if_report_belongs_to_another_organization(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'reports:delete', + ]); + $report = Report::factory()->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->deleteJson(route('api.v1.reports.destroy', [$data->organization->getKey(), $report->getKey()])); + + // Assert + $response->assertForbidden(); + } + + public function test_destroy_endpoint_fails_if_report_does_not_exist(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'reports:delete', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->deleteJson(route('api.v1.reports.destroy', [$data->organization->getKey(), 1])); + + // Assert + $response->assertNotFound(); + } + + public function test_destroy_endpoint_deletes_a_report(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'reports:delete', + ]); + $report = Report::factory()->forOrganization($data->organization)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->deleteJson(route('api.v1.reports.destroy', [$data->organization->getKey(), $report->getKey()])); + + // Assert + $response->assertNoContent(); + $this->assertDatabaseMissing(Report::class, [ + 'id' => $report->getKey(), + ]); + } +} diff --git a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php index 6209a797..86df2c81 100644 --- a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php @@ -5,6 +5,7 @@ namespace Tests\Unit\Endpoint\Api\V1; use App\Enums\Role; +use App\Enums\TimeEntryAggregationType; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Http\Controllers\Api\V1\TimeEntryController; use App\Jobs\RecalculateSpentTimeForProject; @@ -518,6 +519,25 @@ public function test_aggregate_endpoint_fails_if_user_has_no_permission_to_view_ $response->assertForbidden(); } + public function test_aggregate_endpoint_fails_if_request_has_sub_group_but_no_group(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.aggregate', [ + $data->organization->getKey(), + 'sub_group' => TimeEntryAggregationType::Task->value, + ])); + + // Assert + $response->assertStatus(422); + $response->assertJsonValidationErrorFor('group'); + } + public function test_aggregate_endpoint_groups_by_two_groups(): void { // Arrange diff --git a/tests/Unit/Model/ReportModelTest.php b/tests/Unit/Model/ReportModelTest.php new file mode 100644 index 00000000..603abc5f --- /dev/null +++ b/tests/Unit/Model/ReportModelTest.php @@ -0,0 +1,83 @@ +create(); + $report = Report::factory()->forOrganization($organization)->create(); + + // Act + $report->refresh(); + $organizationRel = $report->organization; + + // Assert + $this->assertNotNull($organizationRel); + $this->assertTrue($organizationRel->is($organization)); + } + + public function test_shareable_link_is_null_when_report_is_private_but_share_secret_exists(): void + { + // Arrange + $report = Report::factory()->private()->create([ + 'share_secret' => app(ReportService::class)->generateSecret(), + ]); + + // Act + $report->refresh(); + + // Assert + $this->assertNull($report->getShareableLink()); + } + + public function test_shareable_link_is_null_when_report_is_public_but_share_secret_is_null(): void + { + // Arrange + $report = Report::factory()->public()->create([ + 'share_secret' => null, + ]); + + // Act + $report->refresh(); + + // Assert + $this->assertNull($report->getShareableLink()); + } + + public function test_shareable_link_is_null_when_report_is_public(): void + { + // Arrange + $report = Report::factory()->public()->create(); + + // Act + $report->refresh(); + + // Assert + $this->assertNotNull($report->getShareableLink()); + } + + public function test_shareable_link_is_url_to_web_endpoint_when_report_is_public(): void + { + // Arrange + $report = Report::factory()->public()->create(); + + // Act + $report->refresh(); + + // Assert + $this->assertSame(url('/shared-report#'.$report->share_secret), $report->getShareableLink()); + } +}