Added billable and project_id to time entry endpoints; Enhanced api docs; Added pagination

This commit is contained in:
Constantin Graf
2024-03-19 17:19:12 +01:00
parent 7cc686f230
commit 2133eb0c16
22 changed files with 616 additions and 309 deletions

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Extensions\Scramble;
use App\Exceptions\Api\ApiException;
use Dedoc\Scramble\Extensions\ExceptionToResponseExtension;
use Dedoc\Scramble\Support\Generator\Reference;
use Dedoc\Scramble\Support\Generator\Response;
use Dedoc\Scramble\Support\Generator\Schema;
use Dedoc\Scramble\Support\Generator\Types as OpenApiTypes;
use Dedoc\Scramble\Support\Type\ObjectType;
use Dedoc\Scramble\Support\Type\Type;
use Illuminate\Support\Str;
class ApiExceptionTypeToSchema extends ExceptionToResponseExtension
{
public function shouldHandle(Type $type): bool
{
return $type instanceof ObjectType
&& $type->isInstanceOf(ApiException::class);
}
public function toResponse(Type $type): Response
{
$validationResponseBodyType = (new OpenApiTypes\ObjectType())
->addProperty(
'error',
(new OpenApiTypes\BooleanType())
->setDescription('Whether the response is an error.')
)
->addProperty(
'key',
(new OpenApiTypes\StringType())
->setDescription('Error key.')
)
->addProperty(
'message',
(new OpenApiTypes\StringType())
->setDescription('Error message.')
)
->setRequired(['error', 'key', 'message']);
return Response::make(400)
->description('API exception')
->setContent(
'application/json',
Schema::fromType($validationResponseBodyType)
);
}
public function reference(ObjectType $type): Reference
{
return new Reference('responses', Str::start($type->name, '\\'), $this->components);
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Extensions\Scramble;
use App\Http\Resources\PaginatedResourceCollection;
use Dedoc\Scramble\Extensions\TypeToSchemaExtension;
use Dedoc\Scramble\Support\Generator\Response;
use Dedoc\Scramble\Support\Generator\Schema;
use Dedoc\Scramble\Support\Generator\Types\ArrayType;
use Dedoc\Scramble\Support\Generator\Types\BooleanType;
use Dedoc\Scramble\Support\Generator\Types\IntegerType;
use Dedoc\Scramble\Support\Generator\Types\ObjectType as OpenApiObjectType;
use Dedoc\Scramble\Support\Generator\Types\StringType;
use Dedoc\Scramble\Support\Type\Generic;
use Dedoc\Scramble\Support\Type\ObjectType;
use Dedoc\Scramble\Support\Type\Type;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Resources\Json\JsonResource;
class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
{
public function shouldHandle(Type $type): bool
{
return $type instanceof ObjectType
&& $type->isInstanceOf(PaginatedResourceCollection::class);
}
/**
* @param Generic $type
*/
public function toResponse(Type $type): ?Response
{
/** @var Type|null $collectingClassType */
$collectingClassType = $type->templateTypes[0];
if (! $collectingClassType instanceof ObjectType) {
return null;
}
if (! $collectingClassType->isInstanceOf(JsonResource::class) && ! $collectingClassType->isInstanceOf(Model::class)) {
return null;
}
if (! ($collectingType = $this->openApiTransformer->transform($collectingClassType))) {
return null;
}
$type = new OpenApiObjectType;
$type->addProperty('data', (new ArrayType())->setItems($collectingType));
$type->addProperty(
'links',
(new OpenApiObjectType)
->addProperty('first', (new StringType)->nullable(true))
->addProperty('last', (new StringType)->nullable(true))
->addProperty('prev', (new StringType)->nullable(true))
->addProperty('next', (new StringType)->nullable(true))
->setRequired(['first', 'last', 'prev', 'next'])
);
$type->addProperty(
'meta',
(new OpenApiObjectType)
->addProperty('current_page', new IntegerType)
->addProperty('from', (new IntegerType)->nullable(true))
->addProperty('last_page', new IntegerType)
->addProperty('links', (new ArrayType)->setItems(
(new OpenApiObjectType)
->addProperty('url', (new StringType)->nullable(true))
->addProperty('label', new StringType)
->addProperty('active', new BooleanType)
->setRequired(['url', 'label', 'active'])
)->setDescription('Generated paginator links.'))
->addProperty('path', (new StringType)->nullable(true)->setDescription('Base path for paginator generated URLs.'))
->addProperty('per_page', (new IntegerType)->setDescription('Number of items shown per page.'))
->addProperty('to', (new IntegerType)->nullable(true)->setDescription('Number of the last item in the slice.'))
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
->setRequired(['current_page', 'from', 'last_page', 'links', 'path', 'per_page', 'to', 'total'])
);
$type->setRequired(['data', 'links', 'meta']);
return Response::make(200)
->description('Paginated set of `'.$this->components->uniqueSchemaName($collectingClassType->name).'`')
->setContent('application/json', Schema::fromType($type));
}
}

View File

@@ -26,6 +26,8 @@ class ClientController extends Controller
/** /**
* Get clients * Get clients
* *
* @return ClientCollection<ClientResource>
*
* @throws AuthorizationException * @throws AuthorizationException
*/ */
public function index(Organization $organization): ClientCollection public function index(Organization $organization): ClientCollection
@@ -35,7 +37,7 @@ class ClientController extends Controller
$clients = Client::query() $clients = Client::query()
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->get(); ->paginate();
return new ClientCollection($clients); return new ClientCollection($clients);
} }

View File

@@ -6,7 +6,8 @@ namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\UserNotPlaceholderApiException; use App\Exceptions\Api\UserNotPlaceholderApiException;
use App\Http\Requests\V1\User\UserIndexRequest; use App\Http\Requests\V1\User\UserIndexRequest;
use App\Http\Resources\V1\User\UserCollection; use App\Http\Resources\V1\User\MemberCollection;
use App\Http\Resources\V1\User\MemberResource;
use App\Models\Organization; use App\Models\Organization;
use App\Models\User; use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
@@ -14,25 +15,27 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Laravel\Jetstream\Contracts\InvitesTeamMembers; use Laravel\Jetstream\Contracts\InvitesTeamMembers;
class UserController extends Controller class MemberController extends Controller
{ {
/** /**
* List all users in an organization * List all members of an organization
*
* @return MemberCollection<MemberResource>>
* *
* @throws AuthorizationException * @throws AuthorizationException
*/ */
public function index(Organization $organization, UserIndexRequest $request): UserCollection public function index(Organization $organization, UserIndexRequest $request): MemberCollection
{ {
$this->checkPermission($organization, 'users:view'); $this->checkPermission($organization, 'users:view');
$users = $organization->users() $users = $organization->users()
->paginate(); ->paginate();
return UserCollection::make($users); return MemberCollection::make($users);
} }
/** /**
* Invite a placeholder user to become a real user in the organization * Invite a placeholder user to become a member of the organization
* *
* @throws AuthorizationException|UserNotPlaceholderApiException * @throws AuthorizationException|UserNotPlaceholderApiException
*/ */
@@ -51,6 +54,6 @@ class UserController extends Controller
'employee' 'employee'
); );
return response()->json($user); return response()->json(null, 204);
} }
} }

View File

@@ -27,16 +27,18 @@ class ProjectController extends Controller
/** /**
* Get projects * Get projects
* *
* @return ProjectCollection<ProjectResource>
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId getProjects * @operationId getProjects
*/ */
public function index(Organization $organization): JsonResource public function index(Organization $organization): ProjectCollection
{ {
$this->checkPermission($organization, 'projects:view'); $this->checkPermission($organization, 'projects:view');
$projects = Project::query() $projects = Project::query()
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')
->get(); ->paginate();
return new ProjectCollection($projects); return new ProjectCollection($projects);
} }

View File

@@ -26,9 +26,11 @@ class TagController extends Controller
/** /**
* Get tags * Get tags
* *
* @throws AuthorizationException * @return TagCollection<TagResource>
* *
* @operationId getTags * @operationId getTags
*
* @throws AuthorizationException
*/ */
public function index(Organization $organization): TagCollection public function index(Organization $organization): TagCollection
{ {
@@ -37,7 +39,7 @@ class TagController extends Controller
$tags = Tag::query() $tags = Tag::query()
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->get(); ->paginate();
return new TagCollection($tags); return new TagCollection($tags);
} }

View File

@@ -104,7 +104,8 @@ class TimeEntryController extends Controller
/** /**
* Create time entry * Create time entry
* *
* @throws AuthorizationException|TimeEntryStillRunningApiException * @throws AuthorizationException
* @throws TimeEntryStillRunningApiException
* *
* @operationId createTimeEntry * @operationId createTimeEntry
*/ */
@@ -117,7 +118,6 @@ class TimeEntryController extends Controller
} }
if ($request->get('end') === null && TimeEntry::query()->where('user_id', $request->get('user_id'))->where('end', null)->exists()) { if ($request->get('end') === null && TimeEntry::query()->where('user_id', $request->get('user_id'))->where('end', null)->exists()) {
// TODO: API documentation
throw new TimeEntryStillRunningApiException(); throw new TimeEntryStillRunningApiException();
} }
@@ -145,6 +145,8 @@ class TimeEntryController extends Controller
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry); $this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
} }
// TODO: TimeEntryStillRunningApiException
$timeEntry->fill($request->validated()); $timeEntry->fill($request->validated());
$timeEntry->description = $request->get('description', $timeEntry->description) ?? ''; $timeEntry->description = $request->get('description', $timeEntry->description) ?? '';
$timeEntry->save(); $timeEntry->save();

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry; namespace App\Http\Requests\V1\TimeEntry;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag; use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use App\Models\User; use App\Models\User;
@@ -36,6 +37,16 @@ class TimeEntryStoreRequest extends FormRequest
return $builder->belongsToOrganization($this->organization); return $builder->belongsToOrganization($this->organization);
}), }),
], ],
'project_id' => [
'nullable',
'string',
'uuid',
'required_with:task_id',
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// ID of the task that the time entry should belong to // ID of the task that the time entry should belong to
'task_id' => [ 'task_id' => [
'nullable', 'nullable',
@@ -45,6 +56,11 @@ class TimeEntryStoreRequest extends FormRequest
/** @var Builder<Task> $builder */ /** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), }),
(new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization')
->where('project_id', $this->input('project_id'));
}))->withMessage(__('validation.task_belongs_to_project')),
], ],
// Start of time entry (ISO 8601 format, UTC timezone) // Start of time entry (ISO 8601 format, UTC timezone)
'start' => [ 'start' => [
@@ -57,6 +73,11 @@ class TimeEntryStoreRequest extends FormRequest
'date_format:Y-m-d\TH:i:s\Z', 'date_format:Y-m-d\TH:i:s\Z',
'after:start', 'after:start',
], ],
// Whether time entry is billable
'billable' => [
'required',
'boolean',
],
// Description of time entry // Description of time entry
'description' => [ 'description' => [
'nullable', 'nullable',

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry; namespace App\Http\Requests\V1\TimeEntry;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag; use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
@@ -25,6 +26,16 @@ class TimeEntryUpdateRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'project_id' => [
'nullable',
'string',
'uuid',
'required_with:task_id',
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// ID of the task that the time entry should belong to // ID of the task that the time entry should belong to
'task_id' => [ 'task_id' => [
'nullable', 'nullable',
@@ -34,19 +45,28 @@ class TimeEntryUpdateRequest extends FormRequest
/** @var Builder<Task> $builder */ /** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), }),
(new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization')
->where('project_id', $this->input('project_id'));
}))->withMessage(__('validation.task_belongs_to_project')),
], ],
// Start of time entry (ISO 8601 format, UTC timezone) // Start of time entry (ISO 8601 format, UTC timezone)
'start' => [ 'start' => [
'required', 'required',
'date', // TODO 'date_format:Y-m-d\TH:i:s\Z',
], ],
// End of time entry (ISO 8601 format, UTC timezone) // End of time entry (ISO 8601 format, UTC timezone)
'end' => [ 'end' => [
'present', 'present',
'nullable', 'nullable',
'date', // TODO 'date_format:Y-m-d\TH:i:s\Z',
'after:start', 'after:start',
], ],
// Whether time entry is billable
'billable' => [
'boolean',
],
// Description of time entry // Description of time entry
'description' => [ 'description' => [
'nullable', 'nullable',

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
interface PaginatedResourceCollection
{
}

View File

@@ -4,9 +4,10 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Project; namespace App\Http\Resources\V1\Project;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
class ProjectCollection extends ResourceCollection class ProjectCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
/** /**
* The resource that this resource collects. * The resource that this resource collects.

View File

@@ -43,6 +43,8 @@ class TimeEntryResource extends BaseResource
'user_id' => $this->resource->user_id, 'user_id' => $this->resource->user_id,
/** @var array<string> $tags List of tag IDs */ /** @var array<string> $tags List of tag IDs */
'tags' => $this->resource->tags ?? [], 'tags' => $this->resource->tags ?? [],
/** @var bool $billable Whether time entry is billable */
'billable' => $this->resource->billable,
]; ];
} }
} }

View File

@@ -6,12 +6,12 @@ namespace App\Http\Resources\V1\User;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
class UserCollection extends ResourceCollection class MemberCollection extends ResourceCollection
{ {
/** /**
* The resource that this resource collects. * The resource that this resource collects.
* *
* @var string * @var string
*/ */
public $collects = UserResource::class; public $collects = MemberResource::class;
} }

View File

@@ -12,7 +12,7 @@ use Illuminate\Http\Request;
/** /**
* @property User $resource * @property User $resource
*/ */
class UserResource extends BaseResource class MemberResource extends BaseResource
{ {
/** /**
* Transform the resource into an array. * Transform the resource into an array.

View File

@@ -7,7 +7,7 @@
"require": { "require": {
"php": "8.3.*", "php": "8.3.*",
"ext-zip": "*", "ext-zip": "*",
"dedoc/scramble": "^0.8.5", "dedoc/scramble": "dev-main",
"filament/filament": "^3.2", "filament/filament": "^3.2",
"guzzlehttp/guzzle": "^7.2", "guzzlehttp/guzzle": "^7.2",
"inertiajs/inertia-laravel": "^0.6.8", "inertiajs/inertia-laravel": "^0.6.8",
@@ -19,7 +19,7 @@
"pxlrbt/filament-environment-indicator": "^2.0", "pxlrbt/filament-environment-indicator": "^2.0",
"spatie/temporary-directory": "^2.2", "spatie/temporary-directory": "^2.2",
"tightenco/ziggy": "^1.0", "tightenco/ziggy": "^1.0",
"tpetry/laravel-postgresql-enhanced": "^0.33.0" "tpetry/laravel-postgresql-enhanced": "^0.36.0"
}, },
"require-dev": { "require-dev": {
"brianium/paratest": "^7.3", "brianium/paratest": "^7.3",
@@ -91,6 +91,12 @@
] ]
} }
}, },
"repositories": [
{
"type": "vcs",
"url": "https://github.com/korridor/scramble"
}
],
"config": { "config": {
"optimize-autoloader": true, "optimize-autoloader": true,
"preferred-install": "dist", "preferred-install": "dist",

591
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -34,10 +34,25 @@ class TimeEntryFactory extends Factory
'billable' => $this->faker->boolean(), 'billable' => $this->faker->boolean(),
'tags' => [], 'tags' => [],
'user_id' => User::factory(), 'user_id' => User::factory(),
'task_id' => null,
'project_id' => null,
'organization_id' => Organization::factory(), 'organization_id' => Organization::factory(),
]; ];
} }
public function withTask(Organization $organization): self
{
return $this->state(function (array $attributes) use (&$organization): array {
$project = Project::factory()->forOrganization($organization)->create();
$task = Task::factory()->forProject($project)->forOrganization($organization)->create();
return [
'task_id' => $task->getKey(),
'project_id' => $task->project->getKey(),
];
});
}
public function withTags(Organization $organization): self public function withTags(Organization $organization): self
{ {
return $this->state(function (array $attributes) use ($organization): array { return $this->state(function (array $attributes) use ($organization): array {

View File

@@ -188,12 +188,17 @@ return [
| |
*/ */
'attributes' => [], 'attributes' => [
'task_id' => 'task',
'project_id' => 'project',
'organization_id' => 'organization',
],
/* /*
* Custom validation rules * Custom validation rules
*/ */
'color' => 'The :attribute field must be a valid color.', 'color' => 'The :attribute field must be a valid color.',
'organization' => 'The :attribute does not exist.',
'task_belongs_to_project' => 'The :attribute is not part of the given project.',
]; ];

View File

@@ -4,11 +4,11 @@ declare(strict_types=1);
use App\Http\Controllers\Api\V1\ClientController; use App\Http\Controllers\Api\V1\ClientController;
use App\Http\Controllers\Api\V1\ImportController; use App\Http\Controllers\Api\V1\ImportController;
use App\Http\Controllers\Api\V1\MemberController;
use App\Http\Controllers\Api\V1\OrganizationController; use App\Http\Controllers\Api\V1\OrganizationController;
use App\Http\Controllers\Api\V1\ProjectController; use App\Http\Controllers\Api\V1\ProjectController;
use App\Http\Controllers\Api\V1\TagController; use App\Http\Controllers\Api\V1\TagController;
use App\Http\Controllers\Api\V1\TimeEntryController; use App\Http\Controllers\Api\V1\TimeEntryController;
use App\Http\Controllers\Api\V1\UserController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -32,8 +32,8 @@ Route::middleware('auth:api')->prefix('v1')->name('v1.')->group(static function
// User routes // User routes
Route::name('users.')->group(static function () { Route::name('users.')->group(static function () {
Route::get('/organizations/{organization}/users', [UserController::class, 'index'])->name('index'); Route::get('/organizations/{organization}/members', [MemberController::class, 'index'])->name('index');
Route::post('/organizations/{organization}/users/{user}/invite-placeholder', [UserController::class, 'invitePlaceholder'])->name('invite-placeholder'); Route::post('/organizations/{organization}/members/{user}/invite-placeholder', [MemberController::class, 'invitePlaceholder'])->name('invite-placeholder');
}); });
// Project routes // Project routes

View File

@@ -43,6 +43,8 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonCount(4, 'data'); $response->assertJsonCount(4, 'data');
$response->assertJson(fn (AssertableJson $json) => $json $response->assertJson(fn (AssertableJson $json) => $json
->has('data') ->has('data')
->has('links')
->has('meta')
->count('data', 4) ->count('data', 4)
->where('data.0.id', $clients->sortByDesc('created_at')->get(0)->getKey()) ->where('data.0.id', $clients->sortByDesc('created_at')->get(0)->getKey())
->where('data.1.id', $clients->sortByDesc('created_at')->get(1)->getKey()) ->where('data.1.id', $clients->sortByDesc('created_at')->get(1)->getKey())

View File

@@ -43,6 +43,8 @@ class TagEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonCount(4, 'data'); $response->assertJsonCount(4, 'data');
$response->assertJson(fn (AssertableJson $json) => $json $response->assertJson(fn (AssertableJson $json) => $json
->has('data') ->has('data')
->has('links')
->has('meta')
->count('data', 4) ->count('data', 4)
->where('data.0.id', $tags->sortByDesc('created_at')->get(0)->getKey()) ->where('data.0.id', $tags->sortByDesc('created_at')->get(0)->getKey())
->where('data.1.id', $tags->sortByDesc('created_at')->get(1)->getKey()) ->where('data.1.id', $tags->sortByDesc('created_at')->get(1)->getKey())

View File

@@ -314,6 +314,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Act // Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [ $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'description' => $timeEntryFake->description, 'description' => $timeEntryFake->description,
'billable' => $timeEntryFake->billable,
'start' => $timeEntryFake->start->toIso8601ZuluString(), 'start' => $timeEntryFake->start->toIso8601ZuluString(),
'end' => $timeEntryFake->end->toIso8601ZuluString(), 'end' => $timeEntryFake->end->toIso8601ZuluString(),
'tags' => $timeEntryFake->tags, 'tags' => $timeEntryFake->tags,
@@ -332,16 +333,18 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
'time-entries:create:own', 'time-entries:create:own',
]); ]);
$activeTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->active()->create(); $activeTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->active()->create();
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTags($data->organization)->make(); $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->withTags($data->organization)->make();
Passport::actingAs($data->user); Passport::actingAs($data->user);
// Act // Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [ $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'description' => $timeEntryFake->description, 'description' => $timeEntryFake->description,
'billable' => $timeEntryFake->billable,
'start' => $timeEntryFake->start->toIso8601ZuluString(), 'start' => $timeEntryFake->start->toIso8601ZuluString(),
'end' => null, 'end' => null,
'tags' => $timeEntryFake->tags, 'tags' => $timeEntryFake->tags,
'user_id' => $data->user->getKey(), 'user_id' => $data->user->getKey(),
'project_id' => $timeEntryFake->project_id,
'task_id' => $timeEntryFake->task_id, 'task_id' => $timeEntryFake->task_id,
]); ]);
@@ -350,22 +353,53 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonPath('error', true); $response->assertJsonPath('error', true);
} }
public function test_store_endpoint_validation_fails_if_task_id_does_not_belong_to_project_id(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();
$timeEntryFake2 = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'description' => $timeEntryFake->description,
'billable' => $timeEntryFake->billable,
'start' => $timeEntryFake->start->toIso8601ZuluString(),
'end' => $timeEntryFake->end->toIso8601ZuluString(),
'tags' => $timeEntryFake->tags,
'user_id' => $data->user->getKey(),
'project_id' => $timeEntryFake->project_id,
'task_id' => $timeEntryFake2->task_id,
]);
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrors([
'task_id' => 'The task is not part of the given project.',
]);
}
public function test_store_endpoint_creates_new_time_entry_for_current_user(): void public function test_store_endpoint_creates_new_time_entry_for_current_user(): void
{ {
// Arrange // Arrange
$data = $this->createUserWithPermission([ $data = $this->createUserWithPermission([
'time-entries:create:own', 'time-entries:create:own',
]); ]);
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->make(); $timeEntryFake = TimeEntry::factory()->withTask($data->organization)->forOrganization($data->organization)->make();
Passport::actingAs($data->user); Passport::actingAs($data->user);
// Act // Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [ $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'description' => $timeEntryFake->description, 'description' => $timeEntryFake->description,
'billable' => $timeEntryFake->billable,
'start' => $timeEntryFake->start->toIso8601ZuluString(), 'start' => $timeEntryFake->start->toIso8601ZuluString(),
'end' => $timeEntryFake->end->toIso8601ZuluString(), 'end' => $timeEntryFake->end->toIso8601ZuluString(),
'tags' => $timeEntryFake->tags, 'tags' => $timeEntryFake->tags,
'user_id' => $data->user->getKey(), 'user_id' => $data->user->getKey(),
'project_id' => $timeEntryFake->project_id,
'task_id' => $timeEntryFake->task_id, 'task_id' => $timeEntryFake->task_id,
]); ]);
@@ -389,6 +423,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Act // Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [ $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'billable' => $timeEntryFake->billable,
'start' => $timeEntryFake->start->toIso8601ZuluString(), 'start' => $timeEntryFake->start->toIso8601ZuluString(),
'user_id' => $data->user->getKey(), 'user_id' => $data->user->getKey(),
]); ]);
@@ -418,10 +453,12 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Act // Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [ $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'description' => $timeEntryFake->description, 'description' => $timeEntryFake->description,
'billable' => $timeEntryFake->billable,
'start' => $timeEntryFake->start->toIso8601ZuluString(), 'start' => $timeEntryFake->start->toIso8601ZuluString(),
'end' => $timeEntryFake->end->toIso8601ZuluString(), 'end' => $timeEntryFake->end->toIso8601ZuluString(),
'tags' => $timeEntryFake->tags, 'tags' => $timeEntryFake->tags,
'user_id' => $otherUser->getKey(), 'user_id' => $otherUser->getKey(),
'project_id' => $timeEntryFake->project_id,
'task_id' => $timeEntryFake->task_id, 'task_id' => $timeEntryFake->task_id,
]); ]);
@@ -445,6 +482,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Act // Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [ $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'description' => $timeEntryFake->description, 'description' => $timeEntryFake->description,
'billable' => $timeEntryFake->billable,
'start' => $timeEntryFake->start->toIso8601ZuluString(), 'start' => $timeEntryFake->start->toIso8601ZuluString(),
'end' => $timeEntryFake->end->toIso8601ZuluString(), 'end' => $timeEntryFake->end->toIso8601ZuluString(),
'tags' => $timeEntryFake->tags, 'tags' => $timeEntryFake->tags,
@@ -473,6 +511,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Act // Act
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [ $response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [
'description' => $timeEntryFake->description, 'description' => $timeEntryFake->description,
'billable' => $timeEntryFake->billable,
'start' => $timeEntryFake->start->toIso8601ZuluString(), 'start' => $timeEntryFake->start->toIso8601ZuluString(),
'end' => $timeEntryFake->end->toIso8601ZuluString(), 'end' => $timeEntryFake->end->toIso8601ZuluString(),
'tags' => $timeEntryFake->tags, 'tags' => $timeEntryFake->tags,