From a86e72f655e0aaeab2e7b36b5439a0d477b26576 Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Tue, 27 Feb 2024 22:12:48 +0100 Subject: [PATCH] Added tag endpoints --- .gitignore | 1 + app/Http/Controllers/Api/V1/TagController.php | 88 ++++++ .../Api/V1/TimeEntryController.php | 2 + .../V1/Project/ProjectStoreRequest.php | 1 + app/Http/Requests/V1/Tag/TagStoreRequest.php | 28 ++ app/Http/Requests/V1/Tag/TagUpdateRequest.php | 28 ++ .../V1/TimeEntry/TimeEntryStoreRequest.php | 5 +- app/Http/Resources/V1/Tag/TagCollection.php | 17 ++ app/Http/Resources/V1/Tag/TagResource.php | 34 +++ app/Models/Tag.php | 3 + app/Providers/JetstreamServiceProvider.php | 9 + composer.json | 9 +- composer.lock | 221 +++++++------- .../2024_01_20_110452_create_tags_table.php | 2 + ...01_20_110837_create_time_entries_table.php | 4 + routes/api.php | 11 + .../Endpoint/Api/V1/ProjectEndpointTest.php | 1 + .../Unit/Endpoint/Api/V1/TagEndpointTest.php | 218 ++++++++++++++ .../Endpoint/Api/V1/TimeEntryEndpointTest.php | 280 ++++++++++++++++-- 19 files changed, 824 insertions(+), 138 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/TagController.php create mode 100644 app/Http/Requests/V1/Tag/TagStoreRequest.php create mode 100644 app/Http/Requests/V1/Tag/TagUpdateRequest.php create mode 100644 app/Http/Resources/V1/Tag/TagCollection.php create mode 100644 app/Http/Resources/V1/Tag/TagResource.php create mode 100644 tests/Unit/Endpoint/Api/V1/TagEndpointTest.php diff --git a/.gitignore b/.gitignore index 085d45fc..e6028879 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ yarn-error.log /playwright-report/ /blob-report/ /playwright/.cache/ +/coverage diff --git a/app/Http/Controllers/Api/V1/TagController.php b/app/Http/Controllers/Api/V1/TagController.php new file mode 100644 index 00000000..58db6784 --- /dev/null +++ b/app/Http/Controllers/Api/V1/TagController.php @@ -0,0 +1,88 @@ +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); + } +} diff --git a/app/Http/Controllers/Api/V1/TimeEntryController.php b/app/Http/Controllers/Api/V1/TimeEntryController.php index cd250595..0b627b1d 100644 --- a/app/Http/Controllers/Api/V1/TimeEntryController.php +++ b/app/Http/Controllers/Api/V1/TimeEntryController.php @@ -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()) { // TODO: API documentation + // TODO: Create concept for api exceptions throw new TimeEntryStillRunning('User already has an active time entry'); } $timeEntry = new TimeEntry(); $timeEntry->fill($request->validated()); + $timeEntry->description = $request->get('description', ''); $timeEntry->organization()->associate($organization); $timeEntry->save(); diff --git a/app/Http/Requests/V1/Project/ProjectStoreRequest.php b/app/Http/Requests/V1/Project/ProjectStoreRequest.php index 28521784..8cb3486b 100644 --- a/app/Http/Requests/V1/Project/ProjectStoreRequest.php +++ b/app/Http/Requests/V1/Project/ProjectStoreRequest.php @@ -20,6 +20,7 @@ class ProjectStoreRequest extends FormRequest 'name' => [ 'required', 'string', + 'min:1', 'max:255', ], 'color' => [ diff --git a/app/Http/Requests/V1/Tag/TagStoreRequest.php b/app/Http/Requests/V1/Tag/TagStoreRequest.php new file mode 100644 index 00000000..27955e19 --- /dev/null +++ b/app/Http/Requests/V1/Tag/TagStoreRequest.php @@ -0,0 +1,28 @@ +> + */ + public function rules(): array + { + return [ + 'name' => [ + 'required', + 'string', + 'min:1', + 'max:255', + ], + ]; + } +} diff --git a/app/Http/Requests/V1/Tag/TagUpdateRequest.php b/app/Http/Requests/V1/Tag/TagUpdateRequest.php new file mode 100644 index 00000000..a0f5a1db --- /dev/null +++ b/app/Http/Requests/V1/Tag/TagUpdateRequest.php @@ -0,0 +1,28 @@ +> + */ + public function rules(): array + { + return [ + 'name' => [ + 'required', + 'string', + 'min:1', + 'max:255', + ], + ]; + } +} diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php index 0cbaeaf9..b2441819 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php @@ -52,13 +52,12 @@ class TimeEntryStoreRequest extends FormRequest // Start of time entry (ISO 8601 format, UTC timezone) 'start' => [ 'required', - 'date', // TODO + 'date_format:Y-m-d\TH:i:s\Z', ], // End of time entry (ISO 8601 format, UTC timezone) 'end' => [ - 'required', 'nullable', - 'date', // TODO + 'date_format:Y-m-d\TH:i:s\Z', 'after:start', ], // Description of time entry diff --git a/app/Http/Resources/V1/Tag/TagCollection.php b/app/Http/Resources/V1/Tag/TagCollection.php new file mode 100644 index 00000000..67c33977 --- /dev/null +++ b/app/Http/Resources/V1/Tag/TagCollection.php @@ -0,0 +1,17 @@ + + */ + 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), + ]; + } +} diff --git a/app/Models/Tag.php b/app/Models/Tag.php index 9934f22d..465ec856 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -9,11 +9,14 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Carbon; /** * @property string $id * @property string $name * @property string $organization_id + * @property Carbon|null $created_at + * @property Carbon|null $updated_at * @property-read Organization $organization * * @method static TagFactory factory() diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index 08d2db1e..fe6b1edb 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -64,6 +64,10 @@ class JetstreamServiceProvider extends ServiceProvider 'time-entries:create:own', 'time-entries:update:own', 'time-entries:delete:own', + 'tags:view', + 'tags:create', + 'tags:update', + 'tags:delete', ])->description('Administrator users can perform any action.'); Jetstream::role('manager', 'Manager', [ @@ -79,10 +83,15 @@ class JetstreamServiceProvider extends ServiceProvider 'time-entries:create:own', 'time-entries:update:own', 'time-entries:delete:own', + 'tags:view', + 'tags:create', + 'tags:update', + 'tags:delete', ])->description('Editor users have the ability to read, create, and update.'); Jetstream::role('employee', 'Employee', [ 'projects:view', + 'tags:view', 'time-entries:view:own', 'time-entries:create:own', 'time-entries:update:own', diff --git a/composer.json b/composer.json index 79487081..8b895a38 100644 --- a/composer.json +++ b/composer.json @@ -67,13 +67,16 @@ "@php artisan model:typer > ./resources/js/types/models.ts" ], "ptest": [ - "@php artisan test --parallel --colors=always --stop-on-failure" + "@php artisan test --parallel --stop-on-failure" ], "test": [ - "@php artisan test --colors=always --stop-on-failure" + "@php artisan test --stop-on-failure" ], "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": [ "@php pint" diff --git a/composer.lock b/composer.lock index 2db656b4..d39541ae 100644 --- a/composer.lock +++ b/composer.lock @@ -1414,16 +1414,16 @@ }, { "name": "filament/actions", - "version": "v3.2.38", + "version": "v3.2.39", "source": { "type": "git", "url": "https://github.com/filamentphp/actions.git", - "reference": "3e369b846363b990a12b70eb364a37c16100f3cb" + "reference": "dc87d0954eb05e04ffd7c94c2fec624fc0fb8cb5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/actions/zipball/3e369b846363b990a12b70eb364a37c16100f3cb", - "reference": "3e369b846363b990a12b70eb364a37c16100f3cb", + "url": "https://api.github.com/repos/filamentphp/actions/zipball/dc87d0954eb05e04ffd7c94c2fec624fc0fb8cb5", + "reference": "dc87d0954eb05e04ffd7c94c2fec624fc0fb8cb5", "shasum": "" }, "require": { @@ -1432,9 +1432,9 @@ "filament/infolists": "self.version", "filament/notifications": "self.version", "filament/support": "self.version", - "illuminate/contracts": "^10.45", - "illuminate/database": "^10.45", - "illuminate/support": "^10.45", + "illuminate/contracts": "^10.45|^11.0", + "illuminate/database": "^10.45|^11.0", + "illuminate/support": "^10.45|^11.0", "league/csv": "^9.14", "openspout/openspout": "^4.23", "php": "^8.1", @@ -1463,20 +1463,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "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", - "version": "v3.2.38", + "version": "v3.2.39", "source": { "type": "git", "url": "https://github.com/filamentphp/panels.git", - "reference": "038ccc8f9f9b7f2599799d35498571bb555b27cb" + "reference": "58b62998897e45017bab2beb1d3d27eba4e21cee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/panels/zipball/038ccc8f9f9b7f2599799d35498571bb555b27cb", - "reference": "038ccc8f9f9b7f2599799d35498571bb555b27cb", + "url": "https://api.github.com/repos/filamentphp/panels/zipball/58b62998897e45017bab2beb1d3d27eba4e21cee", + "reference": "58b62998897e45017bab2beb1d3d27eba4e21cee", "shasum": "" }, "require": { @@ -1488,16 +1488,16 @@ "filament/support": "self.version", "filament/tables": "self.version", "filament/widgets": "self.version", - "illuminate/auth": "^10.45", - "illuminate/console": "^10.45", - "illuminate/contracts": "^10.45", - "illuminate/cookie": "^10.45", - "illuminate/database": "^10.45", - "illuminate/http": "^10.45", - "illuminate/routing": "^10.45", - "illuminate/session": "^10.45", - "illuminate/support": "^10.45", - "illuminate/view": "^10.45", + "illuminate/auth": "^10.45|^11.0", + "illuminate/console": "^10.45|^11.0", + "illuminate/contracts": "^10.45|^11.0", + "illuminate/cookie": "^10.45|^11.0", + "illuminate/database": "^10.45|^11.0", + "illuminate/http": "^10.45|^11.0", + "illuminate/routing": "^10.45|^11.0", + "illuminate/session": "^10.45|^11.0", + "illuminate/support": "^10.45|^11.0", + "illuminate/view": "^10.45|^11.0", "php": "^8.1", "spatie/laravel-package-tools": "^1.9" }, @@ -1528,33 +1528,33 @@ "issues": "https://github.com/filamentphp/filament/issues", "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", - "version": "v3.2.38", + "version": "v3.2.39", "source": { "type": "git", "url": "https://github.com/filamentphp/forms.git", - "reference": "dddd0e14ccd91e98168051aac80899069a27e4ad" + "reference": "cb06d6e6f26d7b1837825fce3a4e77764fd346ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/forms/zipball/dddd0e14ccd91e98168051aac80899069a27e4ad", - "reference": "dddd0e14ccd91e98168051aac80899069a27e4ad", + "url": "https://api.github.com/repos/filamentphp/forms/zipball/cb06d6e6f26d7b1837825fce3a4e77764fd346ad", + "reference": "cb06d6e6f26d7b1837825fce3a4e77764fd346ad", "shasum": "" }, "require": { "danharrin/date-format-converter": "^0.3", "filament/actions": "self.version", "filament/support": "self.version", - "illuminate/console": "^10.45", - "illuminate/contracts": "^10.45", - "illuminate/database": "^10.45", - "illuminate/filesystem": "^10.45", - "illuminate/support": "^10.45", - "illuminate/validation": "^10.45", - "illuminate/view": "^10.45", + "illuminate/console": "^10.45|^11.0", + "illuminate/contracts": "^10.45|^11.0", + "illuminate/database": "^10.45|^11.0", + "illuminate/filesystem": "^10.45|^11.0", + "illuminate/support": "^10.45|^11.0", + "illuminate/validation": "^10.45|^11.0", + "illuminate/view": "^10.45|^11.0", "php": "^8.1", "spatie/laravel-package-tools": "^1.9" }, @@ -1584,31 +1584,31 @@ "issues": "https://github.com/filamentphp/filament/issues", "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", - "version": "v3.2.38", + "version": "v3.2.39", "source": { "type": "git", "url": "https://github.com/filamentphp/infolists.git", - "reference": "049e214811d0511a3b7649376d82683fd710ea1f" + "reference": "9c5748d4c5278c7854c53f283e16585002006920" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/infolists/zipball/049e214811d0511a3b7649376d82683fd710ea1f", - "reference": "049e214811d0511a3b7649376d82683fd710ea1f", + "url": "https://api.github.com/repos/filamentphp/infolists/zipball/9c5748d4c5278c7854c53f283e16585002006920", + "reference": "9c5748d4c5278c7854c53f283e16585002006920", "shasum": "" }, "require": { "filament/actions": "self.version", "filament/support": "self.version", - "illuminate/console": "^10.45", - "illuminate/contracts": "^10.45", - "illuminate/database": "^10.45", - "illuminate/filesystem": "^10.45", - "illuminate/support": "^10.45", - "illuminate/view": "^10.45", + "illuminate/console": "^10.45|^11.0", + "illuminate/contracts": "^10.45|^11.0", + "illuminate/database": "^10.45|^11.0", + "illuminate/filesystem": "^10.45|^11.0", + "illuminate/support": "^10.45|^11.0", + "illuminate/view": "^10.45|^11.0", "php": "^8.1", "spatie/laravel-package-tools": "^1.9" }, @@ -1635,29 +1635,29 @@ "issues": "https://github.com/filamentphp/filament/issues", "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", - "version": "v3.2.38", + "version": "v3.2.39", "source": { "type": "git", "url": "https://github.com/filamentphp/notifications.git", - "reference": "ae0f5c20df16f5ba28d524f3b2b286753db42cf7" + "reference": "e6809dd500ce6b061c3bfb7d19dc03248572611e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/notifications/zipball/ae0f5c20df16f5ba28d524f3b2b286753db42cf7", - "reference": "ae0f5c20df16f5ba28d524f3b2b286753db42cf7", + "url": "https://api.github.com/repos/filamentphp/notifications/zipball/e6809dd500ce6b061c3bfb7d19dc03248572611e", + "reference": "e6809dd500ce6b061c3bfb7d19dc03248572611e", "shasum": "" }, "require": { "filament/actions": "self.version", "filament/support": "self.version", - "illuminate/contracts": "^10.45", - "illuminate/filesystem": "^10.45", - "illuminate/notifications": "^10.45", - "illuminate/support": "^10.45", + "illuminate/contracts": "^10.45|^11.0", + "illuminate/filesystem": "^10.45|^11.0", + "illuminate/notifications": "^10.45|^11.0", + "illuminate/support": "^10.45|^11.0", "php": "^8.1", "spatie/laravel-package-tools": "^1.9" }, @@ -1687,32 +1687,31 @@ "issues": "https://github.com/filamentphp/filament/issues", "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", - "version": "v3.2.38", + "version": "v3.2.39", "source": { "type": "git", "url": "https://github.com/filamentphp/support.git", - "reference": "2d2c788d36fea3c115628fac12d3638d52059cd6" + "reference": "9f375682575c9669ccd24a47617b50308cc1a684" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/support/zipball/2d2c788d36fea3c115628fac12d3638d52059cd6", - "reference": "2d2c788d36fea3c115628fac12d3638d52059cd6", + "url": "https://api.github.com/repos/filamentphp/support/zipball/9f375682575c9669ccd24a47617b50308cc1a684", + "reference": "9f375682575c9669ccd24a47617b50308cc1a684", "shasum": "" }, "require": { "blade-ui-kit/blade-heroicons": "^2.2.1", - "doctrine/dbal": "^3.2", "ext-intl": "*", - "illuminate/contracts": "^10.45", - "illuminate/support": "^10.45", - "illuminate/view": "^10.45", + "illuminate/contracts": "^10.45|^11.0", + "illuminate/support": "^10.45|^11.0", + "illuminate/view": "^10.45|^11.0", "livewire/livewire": "^3.2.3", "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/invade": "^1.0|^2.0", "spatie/laravel-package-tools": "^1.9", @@ -1744,32 +1743,32 @@ "issues": "https://github.com/filamentphp/filament/issues", "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", - "version": "v3.2.38", + "version": "v3.2.39", "source": { "type": "git", "url": "https://github.com/filamentphp/tables.git", - "reference": "242a3d6e99bc095b225a145e362e07a3fb92fed1" + "reference": "2515119b88b68339b64fb4e256375dfb2af5dd4c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/tables/zipball/242a3d6e99bc095b225a145e362e07a3fb92fed1", - "reference": "242a3d6e99bc095b225a145e362e07a3fb92fed1", + "url": "https://api.github.com/repos/filamentphp/tables/zipball/2515119b88b68339b64fb4e256375dfb2af5dd4c", + "reference": "2515119b88b68339b64fb4e256375dfb2af5dd4c", "shasum": "" }, "require": { "filament/actions": "self.version", "filament/forms": "self.version", "filament/support": "self.version", - "illuminate/console": "^10.45", - "illuminate/contracts": "^10.45", - "illuminate/database": "^10.45", - "illuminate/filesystem": "^10.45", - "illuminate/support": "^10.45", - "illuminate/view": "^10.45", + "illuminate/console": "^10.45|^11.0", + "illuminate/contracts": "^10.45|^11.0", + "illuminate/database": "^10.45|^11.0", + "illuminate/filesystem": "^10.45|^11.0", + "illuminate/support": "^10.45|^11.0", + "illuminate/view": "^10.45|^11.0", "kirschbaum-development/eloquent-power-joins": "^3.0", "php": "^8.1", "spatie/laravel-package-tools": "^1.9" @@ -1797,11 +1796,11 @@ "issues": "https://github.com/filamentphp/filament/issues", "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", - "version": "v3.2.38", + "version": "v3.2.39", "source": { "type": "git", "url": "https://github.com/filamentphp/widgets.git", @@ -2714,16 +2713,16 @@ }, { "name": "laravel/framework", - "version": "v10.45.1", + "version": "v10.46.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "dcf5d1d722b84ad38a5e053289130b6962f830bd" + "reference": "5e95946a8283a8d5c015035793f9c61c297e937f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/dcf5d1d722b84ad38a5e053289130b6962f830bd", - "reference": "dcf5d1d722b84ad38a5e053289130b6962f830bd", + "url": "https://api.github.com/repos/laravel/framework/zipball/5e95946a8283a8d5c015035793f9c61c297e937f", + "reference": "5e95946a8283a8d5c015035793f9c61c297e937f", "shasum": "" }, "require": { @@ -2916,20 +2915,20 @@ "issues": "https://github.com/laravel/framework/issues", "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", - "version": "v4.2.2", + "version": "v4.3.0", "source": { "type": "git", "url": "https://github.com/laravel/jetstream.git", - "reference": "7a11a4fb1426855b7132900af5c113a684b820cc" + "reference": "c0e19cad88ec5e014746f860bb1559d50474f590" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/jetstream/zipball/7a11a4fb1426855b7132900af5c113a684b820cc", - "reference": "7a11a4fb1426855b7132900af5c113a684b820cc", + "url": "https://api.github.com/repos/laravel/jetstream/zipball/c0e19cad88ec5e014746f860bb1559d50474f590", + "reference": "c0e19cad88ec5e014746f860bb1559d50474f590", "shasum": "" }, "require": { @@ -2985,7 +2984,7 @@ "issues": "https://github.com/laravel/jetstream/issues", "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", @@ -3067,16 +3066,16 @@ }, { "name": "laravel/prompts", - "version": "v0.1.15", + "version": "v0.1.16", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "d814a27514d99b03c85aa42b22cfd946568636c1" + "reference": "ca6872ab6aec3ab61db3a61f83a6caf764ec7781" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/d814a27514d99b03c85aa42b22cfd946568636c1", - "reference": "d814a27514d99b03c85aa42b22cfd946568636c1", + "url": "https://api.github.com/repos/laravel/prompts/zipball/ca6872ab6aec3ab61db3a61f83a6caf764ec7781", + "reference": "ca6872ab6aec3ab61db3a61f83a6caf764ec7781", "shasum": "" }, "require": { @@ -3118,9 +3117,9 @@ ], "support": { "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", @@ -6289,33 +6288,33 @@ }, { "name": "ryangjchandler/blade-capture-directive", - "version": "v0.3.0", + "version": "v1.0.0", "source": { "type": "git", "url": "https://github.com/ryangjchandler/blade-capture-directive.git", - "reference": "62fd2ecb50b938a46025093bcb64fcaddd531f89" + "reference": "cb6f58663d97f17bece176295240b740835e14f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ryangjchandler/blade-capture-directive/zipball/62fd2ecb50b938a46025093bcb64fcaddd531f89", - "reference": "62fd2ecb50b938a46025093bcb64fcaddd531f89", + "url": "https://api.github.com/repos/ryangjchandler/blade-capture-directive/zipball/cb6f58663d97f17bece176295240b740835e14f1", + "reference": "cb6f58663d97f17bece176295240b740835e14f1", "shasum": "" }, "require": { - "illuminate/contracts": "^9.0|^10.0", - "php": "^8.0", + "illuminate/contracts": "^10.0|^11.0", + "php": "^8.1", "spatie/laravel-package-tools": "^1.9.2" }, "require-dev": { - "nunomaduro/collision": "^6.0|^7.0", + "nunomaduro/collision": "^7.0|^8.0", "nunomaduro/larastan": "^2.0", - "orchestra/testbench": "^7.22|^8.0", - "pestphp/pest": "^1.21", - "pestphp/pest-plugin-laravel": "^1.1", + "orchestra/testbench": "^8.0|^9.0", + "pestphp/pest": "^2.0", + "pestphp/pest-plugin-laravel": "^2.0", "phpstan/extension-installer": "^1.1", "phpstan/phpstan-deprecation-rules": "^1.0", "phpstan/phpstan-phpunit": "^1.0", - "phpunit/phpunit": "^9.5", + "phpunit/phpunit": "^10.0", "spatie/laravel-ray": "^1.26" }, "type": "library", @@ -6355,7 +6354,7 @@ ], "support": { "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": [ { @@ -6363,7 +6362,7 @@ "type": "github" } ], - "time": "2023-02-14T16:54:54+00:00" + "time": "2024-02-26T18:08:49+00:00" }, { "name": "spatie/color", @@ -9962,16 +9961,16 @@ }, { "name": "laravel/sail", - "version": "v1.28.0", + "version": "v1.28.1", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "a05861ca9b04558b1ec1f36cff521a271a259b6c" + "reference": "f84e444a3dbc1811803cd2a050bdd54ff6f5eef8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/a05861ca9b04558b1ec1f36cff521a271a259b6c", - "reference": "a05861ca9b04558b1ec1f36cff521a271a259b6c", + "url": "https://api.github.com/repos/laravel/sail/zipball/f84e444a3dbc1811803cd2a050bdd54ff6f5eef8", + "reference": "f84e444a3dbc1811803cd2a050bdd54ff6f5eef8", "shasum": "" }, "require": { @@ -10020,7 +10019,7 @@ "issues": "https://github.com/laravel/sail/issues", "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", diff --git a/database/migrations/2024_01_20_110452_create_tags_table.php b/database/migrations/2024_01_20_110452_create_tags_table.php index a1edb629..96c8b0bc 100644 --- a/database/migrations/2024_01_20_110452_create_tags_table.php +++ b/database/migrations/2024_01_20_110452_create_tags_table.php @@ -23,6 +23,8 @@ return new class extends Migration ->cascadeOnUpdate() ->restrictOnDelete(); $table->timestamps(); + + $table->index('created_at'); }); } diff --git a/database/migrations/2024_01_20_110837_create_time_entries_table.php b/database/migrations/2024_01_20_110837_create_time_entries_table.php index 91c5dcda..ed5ef5fb 100644 --- a/database/migrations/2024_01_20_110837_create_time_entries_table.php +++ b/database/migrations/2024_01_20_110837_create_time_entries_table.php @@ -45,6 +45,10 @@ return new class extends Migration ->restrictOnDelete(); $table->jsonb('tags')->nullable(); $table->timestamps(); + + $table->index('start'); + $table->index('end'); + $table->index('billable'); }); } diff --git a/routes/api.php b/routes/api.php index 6970f86c..05892c15 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Http\Controllers\Api\V1\ProjectController; +use App\Http\Controllers\Api\V1\TagController; use App\Http\Controllers\Api\V1\TimeEntryController; use Illuminate\Support\Facades\Route; 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 () { + // Project routes Route::name('projects.')->group(static function () { Route::get('/organization/{organization}/projects', [ProjectController::class, 'index'])->name('index'); 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'); }); + // Time entry routes Route::name('time-entries.')->group(static function () { Route::get('/organization/{organization}/time-entries', [TimeEntryController::class, 'index'])->name('index'); Route::post('/organization/{organization}/time-entries', [TimeEntryController::class, 'store'])->name('store'); Route::put('/organization/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'update'])->name('update'); 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'); + }); }); /** diff --git a/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php index 189aeed7..f7758ad6 100644 --- a/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php @@ -243,6 +243,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract // Assert $response->assertStatus(204); + $response->assertNoContent(); $this->assertDatabaseMissing(Project::class, [ 'id' => $project->getKey(), ]); diff --git a/tests/Unit/Endpoint/Api/V1/TagEndpointTest.php b/tests/Unit/Endpoint/Api/V1/TagEndpointTest.php new file mode 100644 index 00000000..e2c15603 --- /dev/null +++ b/tests/Unit/Endpoint/Api/V1/TagEndpointTest.php @@ -0,0 +1,218 @@ +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(), + ]); + } +} diff --git a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php index 0e692b30..b4d30d26 100644 --- a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php @@ -6,8 +6,12 @@ namespace Tests\Unit\Endpoint\Api\V1; use App\Models\TimeEntry; use App\Models\User; +use Carbon\Carbon; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; +use Illuminate\Testing\Fluent\AssertableJson; use Laravel\Passport\Passport; +use TiMacDonald\Log\LogEntry; class TimeEntryEndpointTest extends ApiEndpointTestAbstract { @@ -95,7 +99,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract $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 $data = $this->createUserWithPermission([ @@ -105,7 +109,15 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract $data->organization->users()->attach($user, [ '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); // Act @@ -113,7 +125,182 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract // Assert $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 @@ -127,8 +314,8 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract // Act $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [ 'description' => $timeEntryFake->description, - 'start' => $timeEntryFake->start->toIso8601String(), - 'end' => $timeEntryFake->end->toIso8601String(), + 'start' => $timeEntryFake->start->toIso8601ZuluString(), + 'end' => $timeEntryFake->end->toIso8601ZuluString(), 'tags' => $timeEntryFake->tags, 'user_id' => $data->user->getKey(), 'task_id' => $timeEntryFake->task_id, @@ -138,6 +325,31 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract $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 { // Arrange @@ -150,8 +362,8 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract // Act $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [ 'description' => $timeEntryFake->description, - 'start' => $timeEntryFake->start->toIso8601String(), - 'end' => $timeEntryFake->end->toIso8601String(), + 'start' => $timeEntryFake->start->toIso8601ZuluString(), + 'end' => $timeEntryFake->end->toIso8601ZuluString(), 'tags' => $timeEntryFake->tags, 'user_id' => $data->user->getKey(), '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 { // Arrange @@ -182,8 +418,8 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract // Act $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [ 'description' => $timeEntryFake->description, - 'start' => $timeEntryFake->start->toIso8601String(), - 'end' => $timeEntryFake->end->toIso8601String(), + 'start' => $timeEntryFake->start->toIso8601ZuluString(), + 'end' => $timeEntryFake->end->toIso8601ZuluString(), 'tags' => $timeEntryFake->tags, 'user_id' => $otherUser->getKey(), 'task_id' => $timeEntryFake->task_id, @@ -209,8 +445,8 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract // Act $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [ 'description' => $timeEntryFake->description, - 'start' => $timeEntryFake->start->toIso8601String(), - 'end' => $timeEntryFake->end->toIso8601String(), + 'start' => $timeEntryFake->start->toIso8601ZuluString(), + 'end' => $timeEntryFake->end->toIso8601ZuluString(), 'tags' => $timeEntryFake->tags, 'user_id' => $otherUser->getKey(), 'task_id' => $timeEntryFake->task_id, @@ -237,8 +473,8 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract // Act $response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [ 'description' => $timeEntryFake->description, - 'start' => $timeEntryFake->start->toIso8601String(), - 'end' => $timeEntryFake->end->toIso8601String(), + 'start' => $timeEntryFake->start->toIso8601ZuluString(), + 'end' => $timeEntryFake->end->toIso8601ZuluString(), 'tags' => $timeEntryFake->tags, 'user_id' => $data->user->getKey(), 'task_id' => $timeEntryFake->task_id, @@ -264,8 +500,8 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract // Act $response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [ 'description' => $timeEntryFake->description, - 'start' => $timeEntryFake->start->toIso8601String(), - 'end' => $timeEntryFake->end->toIso8601String(), + 'start' => $timeEntryFake->start->toIso8601ZuluString(), + 'end' => $timeEntryFake->end->toIso8601ZuluString(), 'tags' => $timeEntryFake->tags, 'user_id' => $data->user->getKey(), 'task_id' => $timeEntryFake->task_id, @@ -292,8 +528,8 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract // Act $response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [ 'description' => $timeEntryFake->description, - 'start' => $timeEntryFake->start->toIso8601String(), - 'end' => $timeEntryFake->end->toIso8601String(), + 'start' => $timeEntryFake->start->toIso8601ZuluString(), + 'end' => $timeEntryFake->end->toIso8601ZuluString(), 'tags' => $timeEntryFake->tags, 'user_id' => $user->getKey(), 'task_id' => $timeEntryFake->task_id, @@ -316,8 +552,8 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract // Act $response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [ 'description' => $timeEntryFake->description, - 'start' => $timeEntryFake->start->toIso8601String(), - 'end' => $timeEntryFake->end->toIso8601String(), + 'start' => $timeEntryFake->start->toIso8601ZuluString(), + 'end' => $timeEntryFake->end->toIso8601ZuluString(), 'tags' => $timeEntryFake->tags, 'user_id' => $data->user->getKey(), 'task_id' => $timeEntryFake->task_id, @@ -349,8 +585,8 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract // Act $response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [ 'description' => $timeEntryFake->description, - 'start' => $timeEntryFake->start->toIso8601String(), - 'end' => $timeEntryFake->end->toIso8601String(), + 'start' => $timeEntryFake->start->toIso8601ZuluString(), + 'end' => $timeEntryFake->end->toIso8601ZuluString(), 'tags' => $timeEntryFake->tags, 'user_id' => $user->getKey(), 'task_id' => $timeEntryFake->task_id, @@ -448,6 +684,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract // Assert $response->assertStatus(204); + $response->assertNoContent(); $this->assertDatabaseMissing(TimeEntry::class, [ 'id' => $timeEntry->getKey(), ]); @@ -471,6 +708,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract // Assert $response->assertStatus(204); + $response->assertNoContent(); $this->assertDatabaseMissing(TimeEntry::class, [ 'id' => $timeEntry->getKey(), ]);