Added tag endpoints

This commit is contained in:
Constantin Graf
2024-02-27 22:12:48 +01:00
parent 524dc0727d
commit a86e72f655
19 changed files with 824 additions and 138 deletions

1
.gitignore vendored
View File

@@ -25,3 +25,4 @@ yarn-error.log
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
/coverage

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Requests\V1\Tag\TagStoreRequest;
use App\Http\Requests\V1\Tag\TagUpdateRequest;
use App\Http\Resources\V1\Tag\TagCollection;
use App\Http\Resources\V1\Tag\TagResource;
use App\Models\Organization;
use App\Models\Tag;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
class TagController extends Controller
{
protected function checkPermission(Organization $organization, string $permission, ?Tag $tag = null): void
{
parent::checkPermission($organization, $permission);
if ($tag !== null && $tag->organization_id !== $organization->getKey()) {
throw new AuthorizationException('Tag does not belong to organization');
}
}
/**
* Get tags
*
* @throws AuthorizationException
*/
public function index(Organization $organization): TagCollection
{
$this->checkPermission($organization, 'tags:view');
$tags = Tag::query()
->whereBelongsTo($organization, 'organization')
->orderBy('created_at', 'desc')
->get();
return new TagCollection($tags);
}
/**
* Create tag
*
* @throws AuthorizationException
*/
public function store(Organization $organization, TagStoreRequest $request): TagResource
{
$this->checkPermission($organization, 'tags:create');
$tag = new Tag();
$tag->name = $request->input('name');
$tag->organization()->associate($organization);
$tag->save();
return new TagResource($tag);
}
/**
* Update tag
*
* @throws AuthorizationException
*/
public function update(Organization $organization, Tag $tag, TagUpdateRequest $request): TagResource
{
$this->checkPermission($organization, 'tags:update', $tag);
$tag->name = $request->input('name');
$tag->save();
return new TagResource($tag);
}
/**
* Delete tag
*
* @throws AuthorizationException
*/
public function destroy(Organization $organization, Tag $tag): JsonResponse
{
$this->checkPermission($organization, 'tags:delete', $tag);
$tag->delete();
return response()->json(null, 204);
}
}

View File

@@ -114,11 +114,13 @@ 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 // TODO: API documentation
// TODO: Create concept for api exceptions
throw new TimeEntryStillRunning('User already has an active time entry'); throw new TimeEntryStillRunning('User already has an active time entry');
} }
$timeEntry = new TimeEntry(); $timeEntry = new TimeEntry();
$timeEntry->fill($request->validated()); $timeEntry->fill($request->validated());
$timeEntry->description = $request->get('description', '');
$timeEntry->organization()->associate($organization); $timeEntry->organization()->associate($organization);
$timeEntry->save(); $timeEntry->save();

View File

@@ -20,6 +20,7 @@ class ProjectStoreRequest extends FormRequest
'name' => [ 'name' => [
'required', 'required',
'string', 'string',
'min:1',
'max:255', 'max:255',
], ],
'color' => [ 'color' => [

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Tag;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class TagStoreRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'name' => [
'required',
'string',
'min:1',
'max:255',
],
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Tag;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class TagUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'name' => [
'required',
'string',
'min:1',
'max:255',
],
];
}
}

View File

@@ -52,13 +52,12 @@ class TimeEntryStoreRequest extends FormRequest
// 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' => [
'required',
'nullable', 'nullable',
'date', // TODO 'date_format:Y-m-d\TH:i:s\Z',
'after:start', 'after:start',
], ],
// Description of time entry // Description of time entry

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\Tag;
use Illuminate\Http\Resources\Json\ResourceCollection;
class TagCollection extends ResourceCollection
{
/**
* The resource that this resource collects.
*
* @var string
*/
public $collects = TagResource::class;
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\Tag;
use App\Http\Resources\V1\BaseResource;
use App\Models\Tag;
use Illuminate\Http\Request;
/**
* @property Tag $resource
*/
class TagResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, string|bool|int|null>
*/
public function toArray(Request $request): array
{
return [
/** @var string $id ID */
'id' => $this->resource->id,
/** @var string $name Name */
'name' => $this->resource->name,
/** @var string $created_at When the tag was created */
'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string $updated_at When the tag was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at),
];
}
}

View File

@@ -9,11 +9,14 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/** /**
* @property string $id * @property string $id
* @property string $name * @property string $name
* @property string $organization_id * @property string $organization_id
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read Organization $organization * @property-read Organization $organization
* *
* @method static TagFactory factory() * @method static TagFactory factory()

View File

@@ -64,6 +64,10 @@ class JetstreamServiceProvider extends ServiceProvider
'time-entries:create:own', 'time-entries:create:own',
'time-entries:update:own', 'time-entries:update:own',
'time-entries:delete:own', 'time-entries:delete:own',
'tags:view',
'tags:create',
'tags:update',
'tags:delete',
])->description('Administrator users can perform any action.'); ])->description('Administrator users can perform any action.');
Jetstream::role('manager', 'Manager', [ Jetstream::role('manager', 'Manager', [
@@ -79,10 +83,15 @@ class JetstreamServiceProvider extends ServiceProvider
'time-entries:create:own', 'time-entries:create:own',
'time-entries:update:own', 'time-entries:update:own',
'time-entries:delete:own', 'time-entries:delete:own',
'tags:view',
'tags:create',
'tags:update',
'tags:delete',
])->description('Editor users have the ability to read, create, and update.'); ])->description('Editor users have the ability to read, create, and update.');
Jetstream::role('employee', 'Employee', [ Jetstream::role('employee', 'Employee', [
'projects:view', 'projects:view',
'tags:view',
'time-entries:view:own', 'time-entries:view:own',
'time-entries:create:own', 'time-entries:create:own',
'time-entries:update:own', 'time-entries:update:own',

View File

@@ -67,13 +67,16 @@
"@php artisan model:typer > ./resources/js/types/models.ts" "@php artisan model:typer > ./resources/js/types/models.ts"
], ],
"ptest": [ "ptest": [
"@php artisan test --parallel --colors=always --stop-on-failure" "@php artisan test --parallel --stop-on-failure"
], ],
"test": [ "test": [
"@php artisan test --colors=always --stop-on-failure" "@php artisan test --stop-on-failure"
], ],
"test:coverage": [ "test:coverage": [
"@php artisan test --coverage --colors=always --stop-on-failure" "@php artisan test --coverage --stop-on-failure"
],
"test:coverage:report": [
"@php vendor/bin/phpunit --coverage-html=coverage"
], ],
"fix": [ "fix": [
"@php pint" "@php pint"

221
composer.lock generated
View File

@@ -1414,16 +1414,16 @@
}, },
{ {
"name": "filament/actions", "name": "filament/actions",
"version": "v3.2.38", "version": "v3.2.39",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filamentphp/actions.git", "url": "https://github.com/filamentphp/actions.git",
"reference": "3e369b846363b990a12b70eb364a37c16100f3cb" "reference": "dc87d0954eb05e04ffd7c94c2fec624fc0fb8cb5"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/filamentphp/actions/zipball/3e369b846363b990a12b70eb364a37c16100f3cb", "url": "https://api.github.com/repos/filamentphp/actions/zipball/dc87d0954eb05e04ffd7c94c2fec624fc0fb8cb5",
"reference": "3e369b846363b990a12b70eb364a37c16100f3cb", "reference": "dc87d0954eb05e04ffd7c94c2fec624fc0fb8cb5",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1432,9 +1432,9 @@
"filament/infolists": "self.version", "filament/infolists": "self.version",
"filament/notifications": "self.version", "filament/notifications": "self.version",
"filament/support": "self.version", "filament/support": "self.version",
"illuminate/contracts": "^10.45", "illuminate/contracts": "^10.45|^11.0",
"illuminate/database": "^10.45", "illuminate/database": "^10.45|^11.0",
"illuminate/support": "^10.45", "illuminate/support": "^10.45|^11.0",
"league/csv": "^9.14", "league/csv": "^9.14",
"openspout/openspout": "^4.23", "openspout/openspout": "^4.23",
"php": "^8.1", "php": "^8.1",
@@ -1463,20 +1463,20 @@
"issues": "https://github.com/filamentphp/filament/issues", "issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament" "source": "https://github.com/filamentphp/filament"
}, },
"time": "2024-02-23T22:25:58+00:00" "time": "2024-02-27T15:32:43+00:00"
}, },
{ {
"name": "filament/filament", "name": "filament/filament",
"version": "v3.2.38", "version": "v3.2.39",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filamentphp/panels.git", "url": "https://github.com/filamentphp/panels.git",
"reference": "038ccc8f9f9b7f2599799d35498571bb555b27cb" "reference": "58b62998897e45017bab2beb1d3d27eba4e21cee"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/filamentphp/panels/zipball/038ccc8f9f9b7f2599799d35498571bb555b27cb", "url": "https://api.github.com/repos/filamentphp/panels/zipball/58b62998897e45017bab2beb1d3d27eba4e21cee",
"reference": "038ccc8f9f9b7f2599799d35498571bb555b27cb", "reference": "58b62998897e45017bab2beb1d3d27eba4e21cee",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1488,16 +1488,16 @@
"filament/support": "self.version", "filament/support": "self.version",
"filament/tables": "self.version", "filament/tables": "self.version",
"filament/widgets": "self.version", "filament/widgets": "self.version",
"illuminate/auth": "^10.45", "illuminate/auth": "^10.45|^11.0",
"illuminate/console": "^10.45", "illuminate/console": "^10.45|^11.0",
"illuminate/contracts": "^10.45", "illuminate/contracts": "^10.45|^11.0",
"illuminate/cookie": "^10.45", "illuminate/cookie": "^10.45|^11.0",
"illuminate/database": "^10.45", "illuminate/database": "^10.45|^11.0",
"illuminate/http": "^10.45", "illuminate/http": "^10.45|^11.0",
"illuminate/routing": "^10.45", "illuminate/routing": "^10.45|^11.0",
"illuminate/session": "^10.45", "illuminate/session": "^10.45|^11.0",
"illuminate/support": "^10.45", "illuminate/support": "^10.45|^11.0",
"illuminate/view": "^10.45", "illuminate/view": "^10.45|^11.0",
"php": "^8.1", "php": "^8.1",
"spatie/laravel-package-tools": "^1.9" "spatie/laravel-package-tools": "^1.9"
}, },
@@ -1528,33 +1528,33 @@
"issues": "https://github.com/filamentphp/filament/issues", "issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament" "source": "https://github.com/filamentphp/filament"
}, },
"time": "2024-02-26T20:23:05+00:00" "time": "2024-02-27T15:32:50+00:00"
}, },
{ {
"name": "filament/forms", "name": "filament/forms",
"version": "v3.2.38", "version": "v3.2.39",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filamentphp/forms.git", "url": "https://github.com/filamentphp/forms.git",
"reference": "dddd0e14ccd91e98168051aac80899069a27e4ad" "reference": "cb06d6e6f26d7b1837825fce3a4e77764fd346ad"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/filamentphp/forms/zipball/dddd0e14ccd91e98168051aac80899069a27e4ad", "url": "https://api.github.com/repos/filamentphp/forms/zipball/cb06d6e6f26d7b1837825fce3a4e77764fd346ad",
"reference": "dddd0e14ccd91e98168051aac80899069a27e4ad", "reference": "cb06d6e6f26d7b1837825fce3a4e77764fd346ad",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"danharrin/date-format-converter": "^0.3", "danharrin/date-format-converter": "^0.3",
"filament/actions": "self.version", "filament/actions": "self.version",
"filament/support": "self.version", "filament/support": "self.version",
"illuminate/console": "^10.45", "illuminate/console": "^10.45|^11.0",
"illuminate/contracts": "^10.45", "illuminate/contracts": "^10.45|^11.0",
"illuminate/database": "^10.45", "illuminate/database": "^10.45|^11.0",
"illuminate/filesystem": "^10.45", "illuminate/filesystem": "^10.45|^11.0",
"illuminate/support": "^10.45", "illuminate/support": "^10.45|^11.0",
"illuminate/validation": "^10.45", "illuminate/validation": "^10.45|^11.0",
"illuminate/view": "^10.45", "illuminate/view": "^10.45|^11.0",
"php": "^8.1", "php": "^8.1",
"spatie/laravel-package-tools": "^1.9" "spatie/laravel-package-tools": "^1.9"
}, },
@@ -1584,31 +1584,31 @@
"issues": "https://github.com/filamentphp/filament/issues", "issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament" "source": "https://github.com/filamentphp/filament"
}, },
"time": "2024-02-26T20:23:03+00:00" "time": "2024-02-27T15:32:42+00:00"
}, },
{ {
"name": "filament/infolists", "name": "filament/infolists",
"version": "v3.2.38", "version": "v3.2.39",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filamentphp/infolists.git", "url": "https://github.com/filamentphp/infolists.git",
"reference": "049e214811d0511a3b7649376d82683fd710ea1f" "reference": "9c5748d4c5278c7854c53f283e16585002006920"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/filamentphp/infolists/zipball/049e214811d0511a3b7649376d82683fd710ea1f", "url": "https://api.github.com/repos/filamentphp/infolists/zipball/9c5748d4c5278c7854c53f283e16585002006920",
"reference": "049e214811d0511a3b7649376d82683fd710ea1f", "reference": "9c5748d4c5278c7854c53f283e16585002006920",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"filament/actions": "self.version", "filament/actions": "self.version",
"filament/support": "self.version", "filament/support": "self.version",
"illuminate/console": "^10.45", "illuminate/console": "^10.45|^11.0",
"illuminate/contracts": "^10.45", "illuminate/contracts": "^10.45|^11.0",
"illuminate/database": "^10.45", "illuminate/database": "^10.45|^11.0",
"illuminate/filesystem": "^10.45", "illuminate/filesystem": "^10.45|^11.0",
"illuminate/support": "^10.45", "illuminate/support": "^10.45|^11.0",
"illuminate/view": "^10.45", "illuminate/view": "^10.45|^11.0",
"php": "^8.1", "php": "^8.1",
"spatie/laravel-package-tools": "^1.9" "spatie/laravel-package-tools": "^1.9"
}, },
@@ -1635,29 +1635,29 @@
"issues": "https://github.com/filamentphp/filament/issues", "issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament" "source": "https://github.com/filamentphp/filament"
}, },
"time": "2024-02-26T20:22:57+00:00" "time": "2024-02-27T15:32:44+00:00"
}, },
{ {
"name": "filament/notifications", "name": "filament/notifications",
"version": "v3.2.38", "version": "v3.2.39",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filamentphp/notifications.git", "url": "https://github.com/filamentphp/notifications.git",
"reference": "ae0f5c20df16f5ba28d524f3b2b286753db42cf7" "reference": "e6809dd500ce6b061c3bfb7d19dc03248572611e"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/filamentphp/notifications/zipball/ae0f5c20df16f5ba28d524f3b2b286753db42cf7", "url": "https://api.github.com/repos/filamentphp/notifications/zipball/e6809dd500ce6b061c3bfb7d19dc03248572611e",
"reference": "ae0f5c20df16f5ba28d524f3b2b286753db42cf7", "reference": "e6809dd500ce6b061c3bfb7d19dc03248572611e",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"filament/actions": "self.version", "filament/actions": "self.version",
"filament/support": "self.version", "filament/support": "self.version",
"illuminate/contracts": "^10.45", "illuminate/contracts": "^10.45|^11.0",
"illuminate/filesystem": "^10.45", "illuminate/filesystem": "^10.45|^11.0",
"illuminate/notifications": "^10.45", "illuminate/notifications": "^10.45|^11.0",
"illuminate/support": "^10.45", "illuminate/support": "^10.45|^11.0",
"php": "^8.1", "php": "^8.1",
"spatie/laravel-package-tools": "^1.9" "spatie/laravel-package-tools": "^1.9"
}, },
@@ -1687,32 +1687,31 @@
"issues": "https://github.com/filamentphp/filament/issues", "issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament" "source": "https://github.com/filamentphp/filament"
}, },
"time": "2024-02-26T20:23:03+00:00" "time": "2024-02-27T15:32:43+00:00"
}, },
{ {
"name": "filament/support", "name": "filament/support",
"version": "v3.2.38", "version": "v3.2.39",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filamentphp/support.git", "url": "https://github.com/filamentphp/support.git",
"reference": "2d2c788d36fea3c115628fac12d3638d52059cd6" "reference": "9f375682575c9669ccd24a47617b50308cc1a684"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/filamentphp/support/zipball/2d2c788d36fea3c115628fac12d3638d52059cd6", "url": "https://api.github.com/repos/filamentphp/support/zipball/9f375682575c9669ccd24a47617b50308cc1a684",
"reference": "2d2c788d36fea3c115628fac12d3638d52059cd6", "reference": "9f375682575c9669ccd24a47617b50308cc1a684",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"blade-ui-kit/blade-heroicons": "^2.2.1", "blade-ui-kit/blade-heroicons": "^2.2.1",
"doctrine/dbal": "^3.2",
"ext-intl": "*", "ext-intl": "*",
"illuminate/contracts": "^10.45", "illuminate/contracts": "^10.45|^11.0",
"illuminate/support": "^10.45", "illuminate/support": "^10.45|^11.0",
"illuminate/view": "^10.45", "illuminate/view": "^10.45|^11.0",
"livewire/livewire": "^3.2.3", "livewire/livewire": "^3.2.3",
"php": "^8.1", "php": "^8.1",
"ryangjchandler/blade-capture-directive": "^0.2|^0.3", "ryangjchandler/blade-capture-directive": "^0.2|^0.3|^1.0",
"spatie/color": "^1.5", "spatie/color": "^1.5",
"spatie/invade": "^1.0|^2.0", "spatie/invade": "^1.0|^2.0",
"spatie/laravel-package-tools": "^1.9", "spatie/laravel-package-tools": "^1.9",
@@ -1744,32 +1743,32 @@
"issues": "https://github.com/filamentphp/filament/issues", "issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament" "source": "https://github.com/filamentphp/filament"
}, },
"time": "2024-02-26T20:23:16+00:00" "time": "2024-02-27T15:33:06+00:00"
}, },
{ {
"name": "filament/tables", "name": "filament/tables",
"version": "v3.2.38", "version": "v3.2.39",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filamentphp/tables.git", "url": "https://github.com/filamentphp/tables.git",
"reference": "242a3d6e99bc095b225a145e362e07a3fb92fed1" "reference": "2515119b88b68339b64fb4e256375dfb2af5dd4c"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/filamentphp/tables/zipball/242a3d6e99bc095b225a145e362e07a3fb92fed1", "url": "https://api.github.com/repos/filamentphp/tables/zipball/2515119b88b68339b64fb4e256375dfb2af5dd4c",
"reference": "242a3d6e99bc095b225a145e362e07a3fb92fed1", "reference": "2515119b88b68339b64fb4e256375dfb2af5dd4c",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"filament/actions": "self.version", "filament/actions": "self.version",
"filament/forms": "self.version", "filament/forms": "self.version",
"filament/support": "self.version", "filament/support": "self.version",
"illuminate/console": "^10.45", "illuminate/console": "^10.45|^11.0",
"illuminate/contracts": "^10.45", "illuminate/contracts": "^10.45|^11.0",
"illuminate/database": "^10.45", "illuminate/database": "^10.45|^11.0",
"illuminate/filesystem": "^10.45", "illuminate/filesystem": "^10.45|^11.0",
"illuminate/support": "^10.45", "illuminate/support": "^10.45|^11.0",
"illuminate/view": "^10.45", "illuminate/view": "^10.45|^11.0",
"kirschbaum-development/eloquent-power-joins": "^3.0", "kirschbaum-development/eloquent-power-joins": "^3.0",
"php": "^8.1", "php": "^8.1",
"spatie/laravel-package-tools": "^1.9" "spatie/laravel-package-tools": "^1.9"
@@ -1797,11 +1796,11 @@
"issues": "https://github.com/filamentphp/filament/issues", "issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament" "source": "https://github.com/filamentphp/filament"
}, },
"time": "2024-02-23T22:26:31+00:00" "time": "2024-02-27T15:33:09+00:00"
}, },
{ {
"name": "filament/widgets", "name": "filament/widgets",
"version": "v3.2.38", "version": "v3.2.39",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filamentphp/widgets.git", "url": "https://github.com/filamentphp/widgets.git",
@@ -2714,16 +2713,16 @@
}, },
{ {
"name": "laravel/framework", "name": "laravel/framework",
"version": "v10.45.1", "version": "v10.46.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/framework.git", "url": "https://github.com/laravel/framework.git",
"reference": "dcf5d1d722b84ad38a5e053289130b6962f830bd" "reference": "5e95946a8283a8d5c015035793f9c61c297e937f"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/dcf5d1d722b84ad38a5e053289130b6962f830bd", "url": "https://api.github.com/repos/laravel/framework/zipball/5e95946a8283a8d5c015035793f9c61c297e937f",
"reference": "dcf5d1d722b84ad38a5e053289130b6962f830bd", "reference": "5e95946a8283a8d5c015035793f9c61c297e937f",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2916,20 +2915,20 @@
"issues": "https://github.com/laravel/framework/issues", "issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework" "source": "https://github.com/laravel/framework"
}, },
"time": "2024-02-21T14:07:36+00:00" "time": "2024-02-27T16:46:54+00:00"
}, },
{ {
"name": "laravel/jetstream", "name": "laravel/jetstream",
"version": "v4.2.2", "version": "v4.3.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/jetstream.git", "url": "https://github.com/laravel/jetstream.git",
"reference": "7a11a4fb1426855b7132900af5c113a684b820cc" "reference": "c0e19cad88ec5e014746f860bb1559d50474f590"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/jetstream/zipball/7a11a4fb1426855b7132900af5c113a684b820cc", "url": "https://api.github.com/repos/laravel/jetstream/zipball/c0e19cad88ec5e014746f860bb1559d50474f590",
"reference": "7a11a4fb1426855b7132900af5c113a684b820cc", "reference": "c0e19cad88ec5e014746f860bb1559d50474f590",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2985,7 +2984,7 @@
"issues": "https://github.com/laravel/jetstream/issues", "issues": "https://github.com/laravel/jetstream/issues",
"source": "https://github.com/laravel/jetstream" "source": "https://github.com/laravel/jetstream"
}, },
"time": "2024-01-17T00:49:40+00:00" "time": "2024-02-23T15:35:22+00:00"
}, },
{ {
"name": "laravel/passport", "name": "laravel/passport",
@@ -3067,16 +3066,16 @@
}, },
{ {
"name": "laravel/prompts", "name": "laravel/prompts",
"version": "v0.1.15", "version": "v0.1.16",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/prompts.git", "url": "https://github.com/laravel/prompts.git",
"reference": "d814a27514d99b03c85aa42b22cfd946568636c1" "reference": "ca6872ab6aec3ab61db3a61f83a6caf764ec7781"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/prompts/zipball/d814a27514d99b03c85aa42b22cfd946568636c1", "url": "https://api.github.com/repos/laravel/prompts/zipball/ca6872ab6aec3ab61db3a61f83a6caf764ec7781",
"reference": "d814a27514d99b03c85aa42b22cfd946568636c1", "reference": "ca6872ab6aec3ab61db3a61f83a6caf764ec7781",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -3118,9 +3117,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/laravel/prompts/issues", "issues": "https://github.com/laravel/prompts/issues",
"source": "https://github.com/laravel/prompts/tree/v0.1.15" "source": "https://github.com/laravel/prompts/tree/v0.1.16"
}, },
"time": "2023-12-29T22:37:42+00:00" "time": "2024-02-21T19:25:27+00:00"
}, },
{ {
"name": "laravel/serializable-closure", "name": "laravel/serializable-closure",
@@ -6289,33 +6288,33 @@
}, },
{ {
"name": "ryangjchandler/blade-capture-directive", "name": "ryangjchandler/blade-capture-directive",
"version": "v0.3.0", "version": "v1.0.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/ryangjchandler/blade-capture-directive.git", "url": "https://github.com/ryangjchandler/blade-capture-directive.git",
"reference": "62fd2ecb50b938a46025093bcb64fcaddd531f89" "reference": "cb6f58663d97f17bece176295240b740835e14f1"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/ryangjchandler/blade-capture-directive/zipball/62fd2ecb50b938a46025093bcb64fcaddd531f89", "url": "https://api.github.com/repos/ryangjchandler/blade-capture-directive/zipball/cb6f58663d97f17bece176295240b740835e14f1",
"reference": "62fd2ecb50b938a46025093bcb64fcaddd531f89", "reference": "cb6f58663d97f17bece176295240b740835e14f1",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"illuminate/contracts": "^9.0|^10.0", "illuminate/contracts": "^10.0|^11.0",
"php": "^8.0", "php": "^8.1",
"spatie/laravel-package-tools": "^1.9.2" "spatie/laravel-package-tools": "^1.9.2"
}, },
"require-dev": { "require-dev": {
"nunomaduro/collision": "^6.0|^7.0", "nunomaduro/collision": "^7.0|^8.0",
"nunomaduro/larastan": "^2.0", "nunomaduro/larastan": "^2.0",
"orchestra/testbench": "^7.22|^8.0", "orchestra/testbench": "^8.0|^9.0",
"pestphp/pest": "^1.21", "pestphp/pest": "^2.0",
"pestphp/pest-plugin-laravel": "^1.1", "pestphp/pest-plugin-laravel": "^2.0",
"phpstan/extension-installer": "^1.1", "phpstan/extension-installer": "^1.1",
"phpstan/phpstan-deprecation-rules": "^1.0", "phpstan/phpstan-deprecation-rules": "^1.0",
"phpstan/phpstan-phpunit": "^1.0", "phpstan/phpstan-phpunit": "^1.0",
"phpunit/phpunit": "^9.5", "phpunit/phpunit": "^10.0",
"spatie/laravel-ray": "^1.26" "spatie/laravel-ray": "^1.26"
}, },
"type": "library", "type": "library",
@@ -6355,7 +6354,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/ryangjchandler/blade-capture-directive/issues", "issues": "https://github.com/ryangjchandler/blade-capture-directive/issues",
"source": "https://github.com/ryangjchandler/blade-capture-directive/tree/v0.3.0" "source": "https://github.com/ryangjchandler/blade-capture-directive/tree/v1.0.0"
}, },
"funding": [ "funding": [
{ {
@@ -6363,7 +6362,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2023-02-14T16:54:54+00:00" "time": "2024-02-26T18:08:49+00:00"
}, },
{ {
"name": "spatie/color", "name": "spatie/color",
@@ -9962,16 +9961,16 @@
}, },
{ {
"name": "laravel/sail", "name": "laravel/sail",
"version": "v1.28.0", "version": "v1.28.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/sail.git", "url": "https://github.com/laravel/sail.git",
"reference": "a05861ca9b04558b1ec1f36cff521a271a259b6c" "reference": "f84e444a3dbc1811803cd2a050bdd54ff6f5eef8"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/sail/zipball/a05861ca9b04558b1ec1f36cff521a271a259b6c", "url": "https://api.github.com/repos/laravel/sail/zipball/f84e444a3dbc1811803cd2a050bdd54ff6f5eef8",
"reference": "a05861ca9b04558b1ec1f36cff521a271a259b6c", "reference": "f84e444a3dbc1811803cd2a050bdd54ff6f5eef8",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -10020,7 +10019,7 @@
"issues": "https://github.com/laravel/sail/issues", "issues": "https://github.com/laravel/sail/issues",
"source": "https://github.com/laravel/sail" "source": "https://github.com/laravel/sail"
}, },
"time": "2024-02-20T15:11:00+00:00" "time": "2024-02-23T00:24:47+00:00"
}, },
{ {
"name": "laravel/telescope", "name": "laravel/telescope",

View File

@@ -23,6 +23,8 @@ return new class extends Migration
->cascadeOnUpdate() ->cascadeOnUpdate()
->restrictOnDelete(); ->restrictOnDelete();
$table->timestamps(); $table->timestamps();
$table->index('created_at');
}); });
} }

View File

@@ -45,6 +45,10 @@ return new class extends Migration
->restrictOnDelete(); ->restrictOnDelete();
$table->jsonb('tags')->nullable(); $table->jsonb('tags')->nullable();
$table->timestamps(); $table->timestamps();
$table->index('start');
$table->index('end');
$table->index('billable');
}); });
} }

View File

@@ -3,6 +3,7 @@
declare(strict_types=1); declare(strict_types=1);
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\TimeEntryController; use App\Http\Controllers\Api\V1\TimeEntryController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -19,6 +20,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
*/ */
Route::middleware('auth:api')->prefix('v1')->name('v1.')->group(static function () { Route::middleware('auth:api')->prefix('v1')->name('v1.')->group(static function () {
// Project routes
Route::name('projects.')->group(static function () { Route::name('projects.')->group(static function () {
Route::get('/organization/{organization}/projects', [ProjectController::class, 'index'])->name('index'); Route::get('/organization/{organization}/projects', [ProjectController::class, 'index'])->name('index');
Route::get('/organization/{organization}/projects/{project}', [ProjectController::class, 'show'])->name('show'); Route::get('/organization/{organization}/projects/{project}', [ProjectController::class, 'show'])->name('show');
@@ -27,12 +29,21 @@ Route::middleware('auth:api')->prefix('v1')->name('v1.')->group(static function
Route::delete('/organization/{organization}/projects/{project}', [ProjectController::class, 'destroy'])->name('destroy'); Route::delete('/organization/{organization}/projects/{project}', [ProjectController::class, 'destroy'])->name('destroy');
}); });
// Time entry routes
Route::name('time-entries.')->group(static function () { Route::name('time-entries.')->group(static function () {
Route::get('/organization/{organization}/time-entries', [TimeEntryController::class, 'index'])->name('index'); Route::get('/organization/{organization}/time-entries', [TimeEntryController::class, 'index'])->name('index');
Route::post('/organization/{organization}/time-entries', [TimeEntryController::class, 'store'])->name('store'); Route::post('/organization/{organization}/time-entries', [TimeEntryController::class, 'store'])->name('store');
Route::put('/organization/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'update'])->name('update'); Route::put('/organization/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'update'])->name('update');
Route::delete('/organization/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'destroy'])->name('destroy'); Route::delete('/organization/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'destroy'])->name('destroy');
}); });
// Tag routes
Route::name('tags.')->group(static function () {
Route::get('/organization/{organization}/tags', [TagController::class, 'index'])->name('index');
Route::post('/organization/{organization}/tags', [TagController::class, 'store'])->name('store');
Route::put('/organization/{organization}/tags/{tag}', [TagController::class, 'update'])->name('update');
Route::delete('/organization/{organization}/tags/{tag}', [TagController::class, 'destroy'])->name('destroy');
});
}); });
/** /**

View File

@@ -243,6 +243,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
// Assert // Assert
$response->assertStatus(204); $response->assertStatus(204);
$response->assertNoContent();
$this->assertDatabaseMissing(Project::class, [ $this->assertDatabaseMissing(Project::class, [
'id' => $project->getKey(), 'id' => $project->getKey(),
]); ]);

View File

@@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1;
use App\Models\Organization;
use App\Models\Tag;
use Illuminate\Testing\Fluent\AssertableJson;
use Laravel\Passport\Passport;
class TagEndpointTest extends ApiEndpointTestAbstract
{
public function test_index_endpoint_fails_if_user_has_no_permission_to_view_tags(): void
{
// Arrange
$data = $this->createUserWithPermission([
]);
$tags = Tag::factory()->forOrganization($data->organization)->createMany(4);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.tags.index', [$data->organization->getKey()]));
// Assert
$response->assertStatus(403);
}
public function test_index_endpoint_returns_list_of_all_tags_of_organization_ordered_by_created_at_desc_per_default(): void
{
// Arrange
$data = $this->createUserWithPermission([
'tags:view',
]);
$tags = Tag::factory()->forOrganization($data->organization)->createMany(4);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.tags.index', [$data->organization->getKey()]));
// Assert
$response->assertStatus(200);
$response->assertJsonCount(4, 'data');
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->count('data', 4)
->where('data.0.id', $tags->sortByDesc('created_at')->get(0)->getKey())
->where('data.1.id', $tags->sortByDesc('created_at')->get(1)->getKey())
->where('data.2.id', $tags->sortByDesc('created_at')->get(2)->getKey())
->where('data.3.id', $tags->sortByDesc('created_at')->get(3)->getKey())
);
}
public function test_store_endpoint_fails_if_user_has_no_permission_to_create_tags(): void
{
// Arrange
$data = $this->createUserWithPermission([
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.tags.store', [$data->organization->getKey()]), [
'name' => 'Test Tag',
]);
// Assert
$response->assertStatus(403);
}
public function test_store_endpoint_creates_new_tag(): void
{
// Arrange
$data = $this->createUserWithPermission([
'tags:create',
]);
$tagFake = Tag::factory()->make();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.tags.store', [$data->organization->getKey()]), [
'name' => $tagFake->name,
]);
// Assert
$response->assertStatus(201);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.name', $tagFake->name)
);
}
public function test_update_endpoint_fails_if_user_has_no_permission_to_update_tags(): void
{
// Arrange
$data = $this->createUserWithPermission([
]);
$tag = Tag::factory()->forOrganization($data->organization)->create();
$tagFake = Tag::factory()->make();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.tags.update', [$data->organization->getKey(), $tag->getKey()]), [
'name' => $tagFake->name,
]);
// Assert
$response->assertStatus(403);
}
public function test_update_endpoint_fails_if_user_is_not_part_of_tag_organization(): void
{
// Arrange
$data = $this->createUserWithPermission([
'tags:update',
]);
$otherOrganization = Organization::factory()->create();
$tag = Tag::factory()->forOrganization($otherOrganization)->create();
$tagFake = Tag::factory()->make();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.tags.update', [$data->organization->getKey(), $tag->getKey()]), [
'name' => $tagFake->name,
]);
// Assert
$response->assertStatus(403);
$this->assertDatabaseHas(Tag::class, [
'id' => $tag->getKey(),
'name' => $tag->name,
'organization_id' => $otherOrganization->getKey(),
]);
}
public function test_update_endpoint_updates_tag(): void
{
// Arrange
$data = $this->createUserWithPermission([
'tags:update',
]);
$tag = Tag::factory()->forOrganization($data->organization)->create();
$tagFake = Tag::factory()->make();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.tags.update', [$data->organization->getKey(), $tag->getKey()]), [
'name' => $tagFake->name,
]);
// Assert
$response->assertStatus(200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.name', $tagFake->name)
);
$this->assertDatabaseHas(Tag::class, [
'name' => $tagFake->name,
'organization_id' => $data->organization->getKey(),
]);
}
public function test_destroy_endpoint_fails_if_user_has_no_permission_to_delete_tags(): void
{
// Arrange
$data = $this->createUserWithPermission([
]);
$tag = Tag::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.tags.destroy', [$data->organization->getKey(), $tag->getKey()]));
// Assert
$response->assertStatus(403);
}
public function test_destroy_endpoint_fails_if_user_is_not_part_of_tag_organization(): void
{
// Arrange
$data = $this->createUserWithPermission([
'tags:delete',
]);
$otherOrganization = Organization::factory()->create();
$tag = Tag::factory()->forOrganization($otherOrganization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.tags.destroy', [$data->organization->getKey(), $tag->getKey()]));
// Assert
$response->assertStatus(403);
$this->assertDatabaseHas(Tag::class, [
'id' => $tag->getKey(),
'name' => $tag->name,
'organization_id' => $otherOrganization->getKey(),
]);
}
public function test_destroy_endpoint_deletes_tag(): void
{
// Arrange
$data = $this->createUserWithPermission([
'tags:delete',
]);
$tag = Tag::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.tags.destroy', [$data->organization->getKey(), $tag->getKey()]));
// Assert
$response->assertStatus(204);
$response->assertNoContent();
$this->assertDatabaseMissing(Tag::class, [
'id' => $tag->getKey(),
]);
}
}

View File

@@ -6,8 +6,12 @@ namespace Tests\Unit\Endpoint\Api\V1;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Models\User; use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Testing\Fluent\AssertableJson;
use Laravel\Passport\Passport; use Laravel\Passport\Passport;
use TiMacDonald\Log\LogEntry;
class TimeEntryEndpointTest extends ApiEndpointTestAbstract class TimeEntryEndpointTest extends ApiEndpointTestAbstract
{ {
@@ -95,7 +99,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonPath('data.0.id', $timeEntry->getKey()); $response->assertJsonPath('data.0.id', $timeEntry->getKey());
} }
public function test_index_endpoint_returns_time_entries_for_all_users_in_organization(): void public function test_index_endpoint_returns_time_entries_for_all_users_in_organization_default_sort_by_start_date_desc(): void
{ {
// Arrange // Arrange
$data = $this->createUserWithPermission([ $data = $this->createUserWithPermission([
@@ -105,7 +109,15 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$data->organization->users()->attach($user, [ $data->organization->users()->attach($user, [
'role' => 'employee', 'role' => 'employee',
]); ]);
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forUser($user)->create(); $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->create([
'start' => Carbon::now()->subDay(),
]);
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forUser($user)->create([
'start' => Carbon::now()->subDays(2),
]);
$timeEntry3 = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->create([
'start' => Carbon::now()->subDays(3),
]);
Passport::actingAs($data->user); Passport::actingAs($data->user);
// Act // Act
@@ -113,7 +125,182 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Assert // Assert
$response->assertStatus(200); $response->assertStatus(200);
$response->assertJsonPath('data.0.id', $timeEntry->getKey()); $response->assertJsonPath('data.0.id', $timeEntry1->getKey());
$response->assertJsonPath('data.1.id', $timeEntry2->getKey());
$response->assertJsonPath('data.2.id', $timeEntry3->getKey());
}
public function test_index_endpoint_returns_only_active_time_entries(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:own',
]);
$activeTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->active()->create();
$nonActiveTimeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->createMany(3);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'active' => true,
'user_id' => $data->user->getKey(),
]));
// Assert
$response->assertStatus(200);
$response->assertJsonCount(1, 'data');
$response->assertJsonPath('data.0.id', $activeTimeEntry->getKey());
}
public function test_index_endpoint_filter_only_full_dates_returns_time_entries_for_the_whole_day_case_less_time_entries_than_limit(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:own',
]);
$timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->createMany(3);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'only_full_dates' => true,
'limit' => 5,
'user_id' => $data->user->getKey(),
]));
// Assert
$response->assertStatus(200);
$response->assertJsonCount(3, 'data');
}
public function test_index_endpoint_filter_only_full_dates_returns_time_entries_for_the_whole_day_case_more_time_entries_than_limit(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:own',
]);
$timeEntriesDay1 = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)
->startBetween(Carbon::now()->subDay()->startOfDay(), Carbon::now()->subDay()->endOfDay())
->createMany(3);
$timeEntriesDay2 = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)
->startBetween(Carbon::now()->subDays(2)->startOfDay(), Carbon::now()->subDays(2)->endOfDay())
->createMany(3);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'only_full_dates' => true,
'limit' => 5,
'user_id' => $data->user->getKey(),
]));
// Assert
$response->assertStatus(200);
$response->assertJsonCount(3, 'data');
}
public function test_index_endpoint_filter_only_full_dates_returns_time_entries_for_the_whole_day_case_more_time_entries_in_latest_day_than_limit(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:own',
]);
$timeEntriesDay1 = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)
->startBetween(Carbon::now()->subDay()->startOfDay(), Carbon::now()->subDay()->endOfDay())
->createMany(7);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'only_full_dates' => true,
'limit' => 5,
'user_id' => $data->user->getKey(),
]));
// Assert
Log::assertLogged(fn (LogEntry $log) => $log->level === 'warning'
&& $log->message === 'User has has more than 5 time entries on one date'
);
$response->assertStatus(200);
$response->assertJsonCount(7, 'data');
}
public function test_index_endpoint_before_filter_returns_time_entries_before_date(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:own',
]);
$timeEntriesAfter = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)
->startBetween(Carbon::now()->subDay()->startOfDay(), Carbon::now())
->createMany(3);
$timeEntriesBefore = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)
->startBetween(Carbon::now()->subDays(2)->startOfDay(), Carbon::now()->subDays(2)->endOfDay())
->createMany(3);
$timeEntriesDirectlyBeforeLimit = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)
->create([
'start' => Carbon::now()->subDays(2)->endOfDay(),
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'before' => Carbon::now()->subDay()->startOfDay()->toIso8601ZuluString(),
'user_id' => $data->user->getKey(),
]));
// Assert
$response->assertStatus(200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->count('data', 4)
->where('data.0.id', $timeEntriesDirectlyBeforeLimit->getKey())
->where('data.1.id', $timeEntriesBefore->sortByDesc('start')->get(0)->getKey())
->where('data.2.id', $timeEntriesBefore->sortByDesc('start')->get(1)->getKey())
->where('data.3.id', $timeEntriesBefore->sortByDesc('start')->get(2)->getKey())
);
}
public function test_index_endpoint_after_filter_returns_time_entries_after_date(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:own',
]);
$timeEntriesAfter = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)
->startBetween(Carbon::now()->startOfDay(), Carbon::now())
->createMany(3);
$timeEntriesBefore = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)
->startBetween(Carbon::now()->subDay()->startOfDay(), Carbon::now()->subDay()->endOfDay())
->createMany(3);
$timeEntriesDirectlyAfterLimit = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)
->create([
'start' => Carbon::now()->startOfDay(),
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'after' => Carbon::now()->subDay()->endOfDay()->toIso8601ZuluString(), // yesterday
'user_id' => $data->user->getKey(),
]));
// Assert
$response->assertStatus(200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->count('data', 4)
->where('data.0.id', $timeEntriesAfter->sortByDesc('start')->get(0)->getKey())
->where('data.1.id', $timeEntriesAfter->sortByDesc('start')->get(1)->getKey())
->where('data.2.id', $timeEntriesAfter->sortByDesc('start')->get(2)->getKey())
->where('data.3.id', $timeEntriesDirectlyAfterLimit->getKey())
);
} }
public function test_store_endpoint_fails_if_user_has_no_permission_to_create_time_entries(): void public function test_store_endpoint_fails_if_user_has_no_permission_to_create_time_entries(): void
@@ -127,8 +314,8 @@ 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,
'start' => $timeEntryFake->start->toIso8601String(), 'start' => $timeEntryFake->start->toIso8601ZuluString(),
'end' => $timeEntryFake->end->toIso8601String(), 'end' => $timeEntryFake->end->toIso8601ZuluString(),
'tags' => $timeEntryFake->tags, 'tags' => $timeEntryFake->tags,
'user_id' => $data->user->getKey(), 'user_id' => $data->user->getKey(),
'task_id' => $timeEntryFake->task_id, 'task_id' => $timeEntryFake->task_id,
@@ -138,6 +325,31 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$response->assertStatus(403); $response->assertStatus(403);
} }
public function test_store_endpoint_fails_if_user_already_has_active_time_entry_and_tries_to_start_new_one(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$activeTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->active()->create();
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTags($data->organization)->make();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'description' => $timeEntryFake->description,
'start' => $timeEntryFake->start->toIso8601ZuluString(),
'end' => null,
'tags' => $timeEntryFake->tags,
'user_id' => $data->user->getKey(),
'task_id' => $timeEntryFake->task_id,
]);
// Assert
$response->assertStatus(400);
$response->assertJsonPath('error', true);
}
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
@@ -150,8 +362,8 @@ 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,
'start' => $timeEntryFake->start->toIso8601String(), 'start' => $timeEntryFake->start->toIso8601ZuluString(),
'end' => $timeEntryFake->end->toIso8601String(), 'end' => $timeEntryFake->end->toIso8601ZuluString(),
'tags' => $timeEntryFake->tags, 'tags' => $timeEntryFake->tags,
'user_id' => $data->user->getKey(), 'user_id' => $data->user->getKey(),
'task_id' => $timeEntryFake->task_id, 'task_id' => $timeEntryFake->task_id,
@@ -166,6 +378,30 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
]); ]);
} }
public function test_store_endpoint_creates_new_time_entry_with_minimal_fields(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->make();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'start' => $timeEntryFake->start->toIso8601ZuluString(),
'user_id' => $data->user->getKey(),
]);
// Assert
$response->assertStatus(201);
$this->assertDatabaseHas(TimeEntry::class, [
'id' => $response->json('data.id'),
'user_id' => $data->user->getKey(),
'task_id' => null,
]);
}
public function test_store_endpoint_fails_if_user_has_no_permission_to_create_time_entries_for_others(): void public function test_store_endpoint_fails_if_user_has_no_permission_to_create_time_entries_for_others(): void
{ {
// Arrange // Arrange
@@ -182,8 +418,8 @@ 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,
'start' => $timeEntryFake->start->toIso8601String(), 'start' => $timeEntryFake->start->toIso8601ZuluString(),
'end' => $timeEntryFake->end->toIso8601String(), 'end' => $timeEntryFake->end->toIso8601ZuluString(),
'tags' => $timeEntryFake->tags, 'tags' => $timeEntryFake->tags,
'user_id' => $otherUser->getKey(), 'user_id' => $otherUser->getKey(),
'task_id' => $timeEntryFake->task_id, 'task_id' => $timeEntryFake->task_id,
@@ -209,8 +445,8 @@ 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,
'start' => $timeEntryFake->start->toIso8601String(), 'start' => $timeEntryFake->start->toIso8601ZuluString(),
'end' => $timeEntryFake->end->toIso8601String(), 'end' => $timeEntryFake->end->toIso8601ZuluString(),
'tags' => $timeEntryFake->tags, 'tags' => $timeEntryFake->tags,
'user_id' => $otherUser->getKey(), 'user_id' => $otherUser->getKey(),
'task_id' => $timeEntryFake->task_id, 'task_id' => $timeEntryFake->task_id,
@@ -237,8 +473,8 @@ 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,
'start' => $timeEntryFake->start->toIso8601String(), 'start' => $timeEntryFake->start->toIso8601ZuluString(),
'end' => $timeEntryFake->end->toIso8601String(), 'end' => $timeEntryFake->end->toIso8601ZuluString(),
'tags' => $timeEntryFake->tags, 'tags' => $timeEntryFake->tags,
'user_id' => $data->user->getKey(), 'user_id' => $data->user->getKey(),
'task_id' => $timeEntryFake->task_id, 'task_id' => $timeEntryFake->task_id,
@@ -264,8 +500,8 @@ 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,
'start' => $timeEntryFake->start->toIso8601String(), 'start' => $timeEntryFake->start->toIso8601ZuluString(),
'end' => $timeEntryFake->end->toIso8601String(), 'end' => $timeEntryFake->end->toIso8601ZuluString(),
'tags' => $timeEntryFake->tags, 'tags' => $timeEntryFake->tags,
'user_id' => $data->user->getKey(), 'user_id' => $data->user->getKey(),
'task_id' => $timeEntryFake->task_id, 'task_id' => $timeEntryFake->task_id,
@@ -292,8 +528,8 @@ 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,
'start' => $timeEntryFake->start->toIso8601String(), 'start' => $timeEntryFake->start->toIso8601ZuluString(),
'end' => $timeEntryFake->end->toIso8601String(), 'end' => $timeEntryFake->end->toIso8601ZuluString(),
'tags' => $timeEntryFake->tags, 'tags' => $timeEntryFake->tags,
'user_id' => $user->getKey(), 'user_id' => $user->getKey(),
'task_id' => $timeEntryFake->task_id, 'task_id' => $timeEntryFake->task_id,
@@ -316,8 +552,8 @@ 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,
'start' => $timeEntryFake->start->toIso8601String(), 'start' => $timeEntryFake->start->toIso8601ZuluString(),
'end' => $timeEntryFake->end->toIso8601String(), 'end' => $timeEntryFake->end->toIso8601ZuluString(),
'tags' => $timeEntryFake->tags, 'tags' => $timeEntryFake->tags,
'user_id' => $data->user->getKey(), 'user_id' => $data->user->getKey(),
'task_id' => $timeEntryFake->task_id, 'task_id' => $timeEntryFake->task_id,
@@ -349,8 +585,8 @@ 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,
'start' => $timeEntryFake->start->toIso8601String(), 'start' => $timeEntryFake->start->toIso8601ZuluString(),
'end' => $timeEntryFake->end->toIso8601String(), 'end' => $timeEntryFake->end->toIso8601ZuluString(),
'tags' => $timeEntryFake->tags, 'tags' => $timeEntryFake->tags,
'user_id' => $user->getKey(), 'user_id' => $user->getKey(),
'task_id' => $timeEntryFake->task_id, 'task_id' => $timeEntryFake->task_id,
@@ -448,6 +684,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Assert // Assert
$response->assertStatus(204); $response->assertStatus(204);
$response->assertNoContent();
$this->assertDatabaseMissing(TimeEntry::class, [ $this->assertDatabaseMissing(TimeEntry::class, [
'id' => $timeEntry->getKey(), 'id' => $timeEntry->getKey(),
]); ]);
@@ -471,6 +708,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Assert // Assert
$response->assertStatus(204); $response->assertStatus(204);
$response->assertNoContent();
$this->assertDatabaseMissing(TimeEntry::class, [ $this->assertDatabaseMissing(TimeEntry::class, [
'id' => $timeEntry->getKey(), 'id' => $timeEntry->getKey(),
]); ]);