Compare commits

...

5 Commits

Author SHA1 Message Date
Constantin Graf
fc274e2093 Use database in API docs diagnostics test so Scramble can read model schemas 2026-10-08 16:30:08 +02:00
Constantin Graf
e5b99da13e Merge remote-tracking branch 'origin/main' into fix/api-docs-scramble
# Conflicts:
#	tests/Unit/ApiDocs/ApiDocsExportTest.php
2026-10-08 13:04:47 +02:00
Constantin Graf
ad9fcdb338 Updated invoicing extension to v0.0.9 2026-10-08 13:03:14 +02:00
Constantin Graf
7a3a9d7089 Fix Scramble API docs warnings
Remove redundant @var annotations from API resources and defer the user
access in the email unique rule of UserUpdateRequest, so rules() can be
evaluated by Scramble without route model binding. Add a test that fails
on any Scramble diagnostic and tests for the email unique rule.
2026-10-08 12:57:56 +02:00
Constantin Graf
f23ed53059 Fix API docs generation with Scramble 0.13
Scramble 0.13 moved the collected resource type of resource collections
to a different template slot, which broke the custom paginated resource
collection extension and the API docs export. Scramble now infers
paginated responses natively, so the extension, the marker interface and
the @return annotations that overrode the inference are removed.

Scramble 0.13 also documents closure routes, so the API fallback routes
are moved to a controller that is excluded from the docs.

Add a test that exports the API docs.
2026-10-08 12:09:16 +02:00
20 changed files with 175 additions and 114 deletions

View File

@@ -43,9 +43,10 @@ class UserUpdateRequest extends BaseFormRequest
'email' => [ 'email' => [
'email:rfc,strict', 'email:rfc,strict',
'max:255', 'max:255',
UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) { UniqueEloquent::make(User::class, 'email')->query(function (Builder $query) {
/** @var Builder<User> $query */ /** @var Builder<User> $query */
return $query->where('is_placeholder', '=', false); return $query->where('is_placeholder', '=', false)
->whereKeyNot($this->user->getKey());
}), }),
], ],
'photo' => [ 'photo' => [

View File

@@ -21,15 +21,15 @@ class ApiTokenResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** @var string $id ID of the API token, this ID is NOT a UUID */ /** ID of the API token, this ID is NOT a UUID */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** @var string $name Name of the API token */ /** @var string $name Name of the API token */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** @var bool $revoked Whether the API token is revoked */ /** Whether the API token is revoked */
'revoked' => $this->resource->revoked, 'revoked' => $this->resource->revoked,
/** @var array<string> $scopes List of scopes that the API token has */ /** List of scopes that the API token has */
'scopes' => $this->resource->scopes, 'scopes' => $this->resource->scopes,
/** @var string $created_at When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */ /** When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
'created_at' => $this->formatDateTime($this->resource->created_at), 'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */ /** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
'expires_at' => $this->formatDateTime($this->resource->expires_at), 'expires_at' => $this->formatDateTime($this->resource->expires_at),

View File

@@ -29,15 +29,15 @@ class ApiTokenWithAccessTokenResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** @var string $id ID of the API token, this ID is NOT a UUID */ /** ID of the API token, this ID is NOT a UUID */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** @var string $name Name of the API token */ /** @var string $name Name of the API token */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** @var bool $revoked Whether the API token is revoked */ /** Whether the API token is revoked */
'revoked' => $this->resource->revoked, 'revoked' => $this->resource->revoked,
/** @var array<string> $scopes List of scopes that the API token has */ /** List of scopes that the API token has */
'scopes' => $this->resource->scopes, 'scopes' => $this->resource->scopes,
/** @var string $created_at When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */ /** When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
'created_at' => $this->formatDateTime($this->resource->created_at), 'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */ /** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
'expires_at' => $this->formatDateTime($this->resource->expires_at), 'expires_at' => $this->formatDateTime($this->resource->expires_at),

View File

@@ -21,15 +21,15 @@ class ClientResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** @var string $id ID */ /** ID */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** @var string $name Name */ /** Name */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** @var bool $is_archived Whether the client is archived */ /** Whether the client is archived */
'is_archived' => $this->resource->is_archived, 'is_archived' => $this->resource->is_archived,
/** @var string $created_at When the tag was created */ /** When the tag was created */
'created_at' => $this->formatDateTime($this->resource->created_at), 'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string $updated_at When the tag was last updated */ /** When the tag was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at), 'updated_at' => $this->formatDateTime($this->resource->updated_at),
]; ];
} }

View File

@@ -21,11 +21,11 @@ class InvitationResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** @var string $id ID of the invitation */ /** ID of the invitation */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** @var string $email Email */ /** Email */
'email' => $this->resource->email, 'email' => $this->resource->email,
/** @var string $role Role */ /** Role */
'role' => $this->resource->role, 'role' => $this->resource->role,
]; ];
} }

View File

@@ -22,19 +22,19 @@ class MemberResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** @var string $id ID of membership */ /** ID of membership */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** @var string $id ID of user */ /** ID of user */
'user_id' => $this->resource->user->id, 'user_id' => $this->resource->user->id,
/** @var string $name Name */ /** Name */
'name' => $this->resource->user->name, 'name' => $this->resource->user->name,
/** @var string $email Email */ /** Email */
'email' => $this->resource->user->email, 'email' => $this->resource->user->email,
/** @var string $role Role */ /** Role */
'role' => $this->resource->role, 'role' => $this->resource->role,
/** @var bool $is_placeholder Placeholder user for imports, user might not really exist and does not know about this placeholder membership */ /** Placeholder user for imports, user might not really exist and does not know about this placeholder membership */
'is_placeholder' => $this->resource->user->is_placeholder, 'is_placeholder' => $this->resource->user->is_placeholder,
/** @var int|null $billable_rate Billable rate in cents per hour */ /** Billable rate in cents per hour */
'billable_rate' => $this->resource->billable_rate, 'billable_rate' => $this->resource->billable_rate,
]; ];
} }

View File

@@ -21,17 +21,17 @@ class PersonalMembershipResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** @var string $id ID of membership */ /** ID of membership */
'id' => $this->resource->id, 'id' => $this->resource->id,
'organization' => [ 'organization' => [
/** @var string $id ID of organization */ /** ID of organization */
'id' => $this->resource->organization->id, 'id' => $this->resource->organization->id,
/** @var string $name Name of organization */ /** Name of organization */
'name' => $this->resource->organization->name, 'name' => $this->resource->organization->name,
/** @var string $currency Currency code (ISO 4217) of organization */ /** Currency code (ISO 4217) of organization */
'currency' => $this->resource->organization->currency, 'currency' => $this->resource->organization->currency,
], ],
/** @var string $role Role */ /** Role */
'role' => $this->resource->role, 'role' => $this->resource->role,
]; ];
} }

View File

@@ -43,23 +43,23 @@ class OrganizationResource extends BaseResource
$currencyService = app(CurrencyService::class); $currencyService = app(CurrencyService::class);
return [ return [
/** @var string $id ID */ /** ID */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** @var string $name Name */ /** Name */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** @var bool $color Personal organizations automatically created after registration */ /** Personal organizations automatically created after registration */
'is_personal' => $this->resource->personal_team, 'is_personal' => $this->resource->personal_team,
/** @var int|null $billable_rate Billable rate in cents per hour */ /** Billable rate in cents per hour */
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null, 'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
/** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */ /** Can members of the organization with role "employee" see the billable rates */
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates, 'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
/** @var bool $employees_can_manage_tasks Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */ /** Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */
'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks, 'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks,
/** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */ /** Prevent creating overlapping time entries (only new entries) */
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries, 'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
/** @var bool $breaks_enabled Whether members of the organization can track breaks */ /** Whether members of the organization can track breaks */
'breaks_enabled' => $this->resource->breaks_enabled, 'breaks_enabled' => $this->resource->breaks_enabled,
/** @var string $currency Currency code (ISO 4217) */ /** Currency code (ISO 4217) */
'currency' => $this->resource->currency, 'currency' => $this->resource->currency,
/** @var string $currency_symbol Currency symbol */ /** @var string $currency_symbol Currency symbol */
'currency_symbol' => $currencyService->getCurrencySymbol($this->resource->currency), 'currency_symbol' => $currencyService->getCurrencySymbol($this->resource->currency),

View File

@@ -30,25 +30,25 @@ class ProjectResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** @var string $id ID of project */ /** ID of project */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** @var string $name Name of project */ /** Name of project */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** @var string $color Color of project */ /** Color of project */
'color' => $this->resource->color, 'color' => $this->resource->color,
/** @var string|null $client_id ID of client */ /** @var string|null $client_id ID of client */
'client_id' => $this->resource->client_id, 'client_id' => $this->resource->client_id,
/** @var bool $is_archived Whether the client is archived */ /** Whether the client is archived */
'is_archived' => $this->resource->is_archived, 'is_archived' => $this->resource->is_archived,
/** @var int|null $billable_rate Billable rate in cents per hour */ /** Billable rate in cents per hour */
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null, 'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
/** @var bool $is_billable Project time entries billable default */ /** Project time entries billable default */
'is_billable' => $this->resource->is_billable, 'is_billable' => $this->resource->is_billable,
/** @var int|null $estimated_time Estimated time in seconds */ /** Estimated time in seconds */
'estimated_time' => $this->resource->estimated_time, 'estimated_time' => $this->resource->estimated_time,
/** @var int $spent_time Spent time on this project in seconds (sum of the duration of all associated time entries, excl. still running time entries) */ /** Spent time on this project in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
'spent_time' => $this->resource->spent_time, 'spent_time' => $this->resource->spent_time,
/** @var bool $is_public Whether the project is public */ /** Whether the project is public */
'is_public' => $this->resource->is_public, 'is_public' => $this->resource->is_public,
]; ];
} }

View File

@@ -21,13 +21,13 @@ class ProjectMemberResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** @var string $id ID of project member */ /** ID of project member */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** @var int|null $billable_rate Billable rate in cents per hour */ /** Billable rate in cents per hour */
'billable_rate' => $this->resource->billable_rate, 'billable_rate' => $this->resource->billable_rate,
/** @var string $member_id ID of the organization member */ /** ID of the organization member */
'member_id' => $this->resource->member_id, 'member_id' => $this->resource->member_id,
/** @var string $project_id ID of the project */ /** ID of the project */
'project_id' => $this->resource->project_id, 'project_id' => $this->resource->project_id,
]; ];
} }

View File

@@ -21,36 +21,36 @@ class DetailedReportResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** @var string $id ID of the report */ /** ID of the report */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** @var string $name Name */ /** Name */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** @var string|null $email Description */ /** Description */
'description' => $this->resource->description, 'description' => $this->resource->description,
/** @var bool $is_public Whether the report can be accessed via an external link */ /** Whether the report can be accessed via an external link */
'is_public' => $this->resource->is_public, 'is_public' => $this->resource->is_public,
/** @var string|null $public_until Date until the report is public */ /** @var string|null $public_until Date until the report is public */
'public_until' => $this->formatDateTime($this->resource->public_until), 'public_until' => $this->formatDateTime($this->resource->public_until),
/** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */ /** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
'shareable_link' => $this->resource->getShareableLink(), 'shareable_link' => $this->resource->getShareableLink(),
'properties' => [ 'properties' => [
/** @var string $group Type of first grouping */ /** Type of first grouping */
'group' => $this->resource->properties->group->value, 'group' => $this->resource->properties->group->value,
/** @var string $sub_group Type of second grouping */ /** Type of second grouping */
'sub_group' => $this->resource->properties->subGroup->value, 'sub_group' => $this->resource->properties->subGroup->value,
/** @var string $history_group Type of grouping of the historic aggregation (time chart) */ /** Type of grouping of the historic aggregation (time chart) */
'history_group' => $this->resource->properties->historyGroup->value, 'history_group' => $this->resource->properties->historyGroup->value,
/** @var string $start Start date of the report */ /** Start date of the report */
'start' => $this->formatDateTime($this->resource->properties->start), 'start' => $this->formatDateTime($this->resource->properties->start),
/** @var string $end End date of the report */ /** End date of the report */
'end' => $this->formatDateTime($this->resource->properties->end), 'end' => $this->formatDateTime($this->resource->properties->end),
/** @var bool|null $active Whether the report is active */ /** Whether the report is active */
'active' => $this->resource->properties->active, 'active' => $this->resource->properties->active,
/** @var array<string>|null $member_ids Filter by multiple member IDs, member IDs are OR combined */ /** @var array<string>|null $member_ids Filter by multiple member IDs, member IDs are OR combined */
'member_ids' => $this->resource->properties->memberIds?->toArray(), 'member_ids' => $this->resource->properties->memberIds?->toArray(),
/** @var bool|null $billable Filter by billable status */ /** Filter by billable status */
'billable' => $this->resource->properties->billable, 'billable' => $this->resource->properties->billable,
/** @var string|null $time_entry_type Filter by time entry type */ /** Filter by time entry type */
'time_entry_type' => $this->resource->properties->timeEntryType?->value, 'time_entry_type' => $this->resource->properties->timeEntryType?->value,
/** @var array<string>|null $client_ids Filter by client IDs, client IDs are OR combined */ /** @var array<string>|null $client_ids Filter by client IDs, client IDs are OR combined */
'client_ids' => $this->resource->properties->clientIds?->toArray(), 'client_ids' => $this->resource->properties->clientIds?->toArray(),
@@ -58,18 +58,18 @@ class DetailedReportResource extends BaseResource
'project_ids' => $this->resource->properties->projectIds?->toArray(), 'project_ids' => $this->resource->properties->projectIds?->toArray(),
/** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */ /** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */
'tag_ids' => $this->resource->properties->tagIds?->toArray(), 'tag_ids' => $this->resource->properties->tagIds?->toArray(),
/** @var string|null $tag_match_type Tag match type */ /** Tag match type */
'tag_match_type' => $this->resource->properties->tagMatchType?->value, 'tag_match_type' => $this->resource->properties->tagMatchType?->value,
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */ /** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
'task_ids' => $this->resource->properties->taskIds?->toArray(), 'task_ids' => $this->resource->properties->taskIds?->toArray(),
/** @var string|null $rounding_type Rounding type for time entries */ /** Rounding type for time entries */
'rounding_type' => $this->resource->properties->roundingType?->value, 'rounding_type' => $this->resource->properties->roundingType?->value,
/** @var int|null $rounding_minutes Rounding minutes for time entries */ /** Rounding minutes for time entries */
'rounding_minutes' => $this->resource->properties->roundingMinutes, 'rounding_minutes' => $this->resource->properties->roundingMinutes,
], ],
/** @var string $created_at Date when the report was created */ /** Date when the report was created */
'created_at' => $this->formatDateTime($this->resource->created_at), 'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string $updated_at Date when the report was last updated */ /** Date when the report was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at), 'updated_at' => $this->formatDateTime($this->resource->updated_at),
]; ];
} }

View File

@@ -73,13 +73,13 @@ class DetailedWithDataReportResource extends BaseResource
$currencyService = app(CurrencyService::class); $currencyService = app(CurrencyService::class);
return [ return [
/** @var string $name Name */ /** Name */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** @var string|null $email Description */ /** Description */
'description' => $this->resource->description, 'description' => $this->resource->description,
/** @var string|null $public_until Date until the report is public */ /** @var string|null $public_until Date until the report is public */
'public_until' => $this->formatDateTime($this->resource->public_until), 'public_until' => $this->formatDateTime($this->resource->public_until),
/** @var string $currency Currency code (ISO 4217) */ /** Currency code (ISO 4217) */
'currency' => $this->resource->organization->currency, 'currency' => $this->resource->organization->currency,
/** @var NumberFormat $number_format Number format */ /** @var NumberFormat $number_format Number format */
'number_format' => $this->resource->organization->number_format->value, 'number_format' => $this->resource->organization->number_format->value,
@@ -94,15 +94,15 @@ class DetailedWithDataReportResource extends BaseResource
/** @var TimeFormat $time_format Time format */ /** @var TimeFormat $time_format Time format */
'time_format' => $this->resource->organization->time_format->value, 'time_format' => $this->resource->organization->time_format->value,
'properties' => [ 'properties' => [
/** @var string $group Type of first grouping */ /** Type of first grouping */
'group' => $this->resource->properties->group->value, 'group' => $this->resource->properties->group->value,
/** @var string $sub_group Type of second grouping */ /** Type of second grouping */
'sub_group' => $this->resource->properties->subGroup->value, 'sub_group' => $this->resource->properties->subGroup->value,
/** @var string $history_group Type of grouping of the historic aggregation (time chart) */ /** Type of grouping of the historic aggregation (time chart) */
'history_group' => $this->resource->properties->historyGroup->value, 'history_group' => $this->resource->properties->historyGroup->value,
/** @var string $start Start date of the report */ /** Start date of the report */
'start' => $this->formatDateTime($this->resource->properties->start), 'start' => $this->formatDateTime($this->resource->properties->start),
/** @var string $end End date of the report */ /** End date of the report */
'end' => $this->formatDateTime($this->resource->properties->end), 'end' => $this->formatDateTime($this->resource->properties->end),
], ],
/** @var array{ /** @var array{

View File

@@ -21,21 +21,21 @@ class ReportResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** @var string $id ID of the report */ /** ID of the report */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** @var string $name Name */ /** Name */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** @var string|null $email Description */ /** Description */
'description' => $this->resource->description, 'description' => $this->resource->description,
/** @var bool $is_public Whether the report can be accessed via an external link */ /** Whether the report can be accessed via an external link */
'is_public' => $this->resource->is_public, 'is_public' => $this->resource->is_public,
/** @var string|null $public_until Date until the report is public */ /** @var string|null $public_until Date until the report is public */
'public_until' => $this->formatDateTime($this->resource->public_until), 'public_until' => $this->formatDateTime($this->resource->public_until),
/** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */ /** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
'shareable_link' => $this->resource->getShareableLink(), 'shareable_link' => $this->resource->getShareableLink(),
/** @var string $created_at Date when the report was created */ /** Date when the report was created */
'created_at' => $this->formatDateTime($this->resource->created_at), 'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string $updated_at Date when the report was last updated */ /** Date when the report was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at), 'updated_at' => $this->formatDateTime($this->resource->updated_at),
]; ];
} }

View File

@@ -21,13 +21,13 @@ class TagResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** @var string $id ID */ /** ID */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** @var string $name Name */ /** Name */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** @var string $created_at When the tag was created */ /** When the tag was created */
'created_at' => $this->formatDateTime($this->resource->created_at), 'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string $updated_at When the tag was last updated */ /** When the tag was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at), 'updated_at' => $this->formatDateTime($this->resource->updated_at),
]; ];
} }

View File

@@ -22,21 +22,21 @@ class TaskResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** @var string $id ID */ /** ID */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** @var string $name Name */ /** Name */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** @var bool $is_done Whether the task is done */ /** Whether the task is done */
'is_done' => $this->resource->is_done, 'is_done' => $this->resource->is_done,
/** @var string $project_id ID of the project */ /** ID of the project */
'project_id' => $this->resource->project_id, 'project_id' => $this->resource->project_id,
/** @var int|null $estimated_time Estimated time in seconds */ /** Estimated time in seconds */
'estimated_time' => $this->resource->estimated_time, 'estimated_time' => $this->resource->estimated_time,
/** @var int $spent_time Spent time on this task in seconds (sum of the duration of all associated time entries, excl. still running time entries) */ /** Spent time on this task in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
'spent_time' => $this->resource->spent_time, 'spent_time' => $this->resource->spent_time,
/** @var string $created_at When the tag was created */ /** When the tag was created */
'created_at' => $this->formatDateTime($this->resource->created_at), 'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string $updated_at When the tag was last updated */ /** When the tag was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at), 'updated_at' => $this->formatDateTime($this->resource->updated_at),
]; ];
} }

View File

@@ -21,10 +21,10 @@ class TimeEntryResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** @var string $id ID of time entry */ /** ID of time entry */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** /**
* @var string $start Start of time entry (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) * Start of time entry (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z)
*/ */
'start' => $this->formatDateTime($this->resource->start), 'start' => $this->formatDateTime($this->resource->start),
/** /**
@@ -35,19 +35,19 @@ class TimeEntryResource extends BaseResource
'duration' => (int) $this->resource->getDuration()?->totalSeconds, 'duration' => (int) $this->resource->getDuration()?->totalSeconds,
/** @var string|null $description Description of time entry */ /** @var string|null $description Description of time entry */
'description' => $this->resource->description, 'description' => $this->resource->description,
/** @var string|null $task_id ID of task */ /** ID of task */
'task_id' => $this->resource->task_id, 'task_id' => $this->resource->task_id,
/** @var string|null $project_id ID of project */ /** ID of project */
'project_id' => $this->resource->project_id, 'project_id' => $this->resource->project_id,
/** @var string $organization_id ID of organization */ /** ID of organization */
'organization_id' => $this->resource->organization_id, 'organization_id' => $this->resource->organization_id,
/** @var string $user_id ID of user */ /** ID of user */
'user_id' => $this->resource->user_id, 'user_id' => $this->resource->user_id,
/** @var array<string> $tags List of tag IDs */ /** List of tag IDs */
'tags' => $this->resource->tags ?? [], 'tags' => $this->resource->tags ?? [],
/** @var bool $billable Whether time entry is billable */ /** Whether time entry is billable */
'billable' => $this->resource->billable, 'billable' => $this->resource->billable,
/** @var string $type Type of the time entry (`work` time or a `break`) */ /** Type of the time entry (`work` time or a `break`) */
'type' => $this->resource->type->value, 'type' => $this->resource->type->value,
]; ];
} }

View File

@@ -22,21 +22,21 @@ class UserResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** @var string $id ID of user */ /** ID of user */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** @var string $name Name of user */ /** Name of user */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** @var string $email Email of user */ /** Email of user */
'email' => $this->resource->email, 'email' => $this->resource->email,
/** @var string|null $pending_email Email address awaiting verification (set when the user has requested an email change but not yet verified the new address) */ /** Email address awaiting verification (set when the user has requested an email change but not yet verified the new address) */
'pending_email' => $this->resource->pending_email, 'pending_email' => $this->resource->pending_email,
/** @var string $profile_photo_url Profile photo URL */ /** Profile photo URL */
'profile_photo_url' => $this->resource->profile_photo_url, 'profile_photo_url' => $this->resource->profile_photo_url,
/** @var string $timezone Timezone (f.e. Europe/Berlin or America/New_York) */ /** Timezone (f.e. Europe/Berlin or America/New_York) */
'timezone' => $this->resource->timezone, 'timezone' => $this->resource->timezone,
/** @var Weekday $week_start Starting day of the week */ /** @var Weekday $week_start Starting day of the week */
'week_start' => $this->resource->week_start->value, 'week_start' => $this->resource->week_start->value,
/** @var bool $send_time_entry_still_running_email Whether to email the user when a time entry has been running for more than 8 hours */ /** Whether to email the user when a time entry has been running for more than 8 hours */
'send_time_entry_still_running_email' => $this->resource->send_time_entry_still_running_email, 'send_time_entry_still_running_email' => $this->resource->send_time_entry_still_running_email,
]; ];
} }

View File

@@ -9,7 +9,7 @@
}, },
"Invoicing": { "Invoicing": {
"repository": "solidtime-io/extension-invoicing", "repository": "solidtime-io/extension-invoicing",
"ref": "v0.0.8" "ref": "v0.0.9"
}, },
"Auditing": { "Auditing": {
"repository": "solidtime-io/extension-auditing", "repository": "solidtime-io/extension-auditing",

View File

@@ -5,14 +5,17 @@ declare(strict_types=1);
namespace Tests\Unit\ApiDocs; namespace Tests\Unit\ApiDocs;
use App\Http\Controllers\Api\FallbackController; use App\Http\Controllers\Api\FallbackController;
use Dedoc\Scramble\Contracts\Diagnostics\Diagnostic;
use Dedoc\Scramble\Generator;
use Dedoc\Scramble\Infer\Context; use Dedoc\Scramble\Infer\Context;
use Dedoc\Scramble\Scramble;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCase; use Tests\TestCaseWithDatabase;
#[CoversClass(FallbackController::class)] #[CoversClass(FallbackController::class)]
class ApiDocsExportTest extends TestCase class ApiDocsExportTest extends TestCaseWithDatabase
{ {
private string $path; private string $path;
@@ -71,6 +74,25 @@ class ApiDocsExportTest extends TestCase
$this->assertNotEmpty($docs['paths']); $this->assertNotEmpty($docs['paths']);
} }
public function test_api_docs_are_generated_without_diagnostics(): void
{
// Act
$result = app(Generator::class)->generate(Scramble::getGeneratorConfig('default'));
// Assert
$diagnostics = $result->diagnostics()->map(function (Diagnostic $diagnostic): string {
$location = $diagnostic->codeLocation();
return sprintf(
'[%s] %s%s',
$diagnostic->code(),
$diagnostic->message(),
$location !== null ? ' ('.$location->file.':'.$location->line.')' : ''
);
})->all();
$this->assertSame([], $diagnostics);
}
public function test_paginated_endpoints_are_documented_with_pagination(): void public function test_paginated_endpoints_are_documented_with_pagination(): void
{ {
// Act // Act

View File

@@ -245,6 +245,44 @@ class UserEndpointTest extends ApiEndpointTestAbstract
Mail::assertNothingSent(); Mail::assertNothingSent();
} }
public function test_update_fails_if_email_is_already_used_by_another_user(): void
{
// Arrange
Mail::fake();
$data = $this->createUserWithPermission();
User::factory()->create(['email' => 'taken@example.com']);
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
'email' => 'taken@example.com',
]);
// Assert
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['email']);
$this->assertNull($data->user->fresh()->pending_email);
Mail::assertNothingSent();
}
public function test_update_email_succeeds_if_email_is_only_used_by_a_placeholder_user(): void
{
// Arrange
Mail::fake();
$data = $this->createUserWithPermission();
User::factory()->placeholder()->create(['email' => 'placeholder@example.com']);
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
'email' => 'placeholder@example.com',
]);
// Assert
$response->assertSuccessful();
$this->assertSame('placeholder@example.com', $data->user->fresh()->pending_email);
}
public function test_update_fails_if_email_format_is_invalid(): void public function test_update_fails_if_email_format_is_invalid(): void
{ {
// Arrange // Arrange