mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Add tests for export endpoints
This commit is contained in:
committed by
Constantin Graf
parent
5a1e05374c
commit
9a60e2b911
22
.env.ci
22
.env.ci
@@ -6,12 +6,13 @@ APP_URL=http://localhost
|
|||||||
APP_FORCE_HTTPS=false
|
APP_FORCE_HTTPS=false
|
||||||
SESSION_SECURE_COOKIE=false
|
SESSION_SECURE_COOKIE=false
|
||||||
|
|
||||||
|
# Logging
|
||||||
LOG_CHANNEL=stack
|
LOG_CHANNEL=stack
|
||||||
LOG_DEPRECATIONS_CHANNEL=null
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
# Database
|
||||||
DB_CONNECTION=pgsql_test
|
DB_CONNECTION=pgsql_test
|
||||||
|
|
||||||
DB_TEST_HOST=127.0.0.1
|
DB_TEST_HOST=127.0.0.1
|
||||||
DB_TEST_PORT=5432
|
DB_TEST_PORT=5432
|
||||||
DB_TEST_DATABASE=laravel
|
DB_TEST_DATABASE=laravel
|
||||||
@@ -20,26 +21,21 @@ DB_TEST_PASSWORD=root
|
|||||||
|
|
||||||
BROADCAST_DRIVER=log
|
BROADCAST_DRIVER=log
|
||||||
CACHE_DRIVER=file
|
CACHE_DRIVER=file
|
||||||
FILESYSTEM_DISK=local
|
|
||||||
QUEUE_CONNECTION=sync
|
QUEUE_CONNECTION=sync
|
||||||
SESSION_DRIVER=database
|
SESSION_DRIVER=database
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
|
|
||||||
MEMCACHED_HOST=127.0.0.1
|
# Mail
|
||||||
|
|
||||||
REDIS_HOST=127.0.0.1
|
|
||||||
REDIS_PASSWORD=null
|
|
||||||
REDIS_PORT=6379
|
|
||||||
|
|
||||||
MAIL_MAILER=log
|
MAIL_MAILER=log
|
||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
S3_ACCESS_KEY_ID=
|
# Filesystems
|
||||||
S3_SECRET_ACCESS_KEY=
|
FILESYSTEM_DISK=local
|
||||||
S3_REGION=us-east-1
|
PUBLIC_FILESYSTEM_DISK=public
|
||||||
S3_BUCKET=
|
|
||||||
S3_USE_PATH_STYLE_ENDPOINT=false
|
# Services
|
||||||
|
GOTENBERG_URL=http://0.0.0.0:3000
|
||||||
|
|
||||||
PUSHER_APP_ID=
|
PUSHER_APP_ID=
|
||||||
PUSHER_APP_KEY=
|
PUSHER_APP_KEY=
|
||||||
|
|||||||
18
.env.example
18
.env.example
@@ -4,15 +4,15 @@ APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
|
|||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=https://solidtime.test
|
APP_URL=https://solidtime.test
|
||||||
AUDITING_ENABLED=true
|
AUDITING_ENABLED=true
|
||||||
|
|
||||||
SUPER_ADMINS=admin@example.com
|
SUPER_ADMINS=admin@example.com
|
||||||
|
|
||||||
|
# Logging
|
||||||
LOG_CHANNEL=single
|
LOG_CHANNEL=single
|
||||||
LOG_DEPRECATIONS_CHANNEL=deprecation
|
LOG_DEPRECATIONS_CHANNEL=deprecation
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
# Database
|
||||||
DB_CONNECTION=pgsql
|
DB_CONNECTION=pgsql
|
||||||
|
|
||||||
DB_HOST=pgsql
|
DB_HOST=pgsql
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
DB_DATABASE=laravel
|
DB_DATABASE=laravel
|
||||||
@@ -31,14 +31,7 @@ QUEUE_CONNECTION=sync
|
|||||||
SESSION_DRIVER=database
|
SESSION_DRIVER=database
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
|
|
||||||
GOTENBERG_URL=http://gotenberg:3000
|
# Mail
|
||||||
|
|
||||||
MEMCACHED_HOST=127.0.0.1
|
|
||||||
|
|
||||||
REDIS_HOST=127.0.0.1
|
|
||||||
REDIS_PASSWORD=null
|
|
||||||
REDIS_PORT=6379
|
|
||||||
|
|
||||||
MAIL_MAILER=smtp
|
MAIL_MAILER=smtp
|
||||||
MAIL_HOST=mailpit
|
MAIL_HOST=mailpit
|
||||||
MAIL_PORT=1025
|
MAIL_PORT=1025
|
||||||
@@ -56,7 +49,7 @@ PUSHER_PORT=443
|
|||||||
PUSHER_SCHEME=https
|
PUSHER_SCHEME=https
|
||||||
PUSHER_APP_CLUSTER=mt1
|
PUSHER_APP_CLUSTER=mt1
|
||||||
|
|
||||||
# Storage
|
# Filesystems
|
||||||
FILESYSTEM_DISK=s3
|
FILESYSTEM_DISK=s3
|
||||||
PUBLIC_FILESYSTEM_DISK=s3
|
PUBLIC_FILESYSTEM_DISK=s3
|
||||||
S3_ACCESS_KEY_ID=sail
|
S3_ACCESS_KEY_ID=sail
|
||||||
@@ -67,6 +60,9 @@ S3_URL=http://storage.solidtime.test/local
|
|||||||
S3_ENDPOINT=http://storage.solidtime.test
|
S3_ENDPOINT=http://storage.solidtime.test
|
||||||
S3_USE_PATH_STYLE_ENDPOINT=true
|
S3_USE_PATH_STYLE_ENDPOINT=true
|
||||||
|
|
||||||
|
# Services
|
||||||
|
GOTENBERG_URL=http://gotenberg:3000
|
||||||
|
|
||||||
VITE_HOST_NAME=vite.solidtime.test
|
VITE_HOST_NAME=vite.solidtime.test
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
|
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
|
||||||
|
|||||||
10
.github/workflows/phpunit.yml
vendored
10
.github/workflows/phpunit.yml
vendored
@@ -20,7 +20,15 @@ jobs:
|
|||||||
--health-interval 10s
|
--health-interval 10s
|
||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 5
|
--health-retries 5
|
||||||
|
gotenberg:
|
||||||
|
image: gotenberg/gotenberg:8
|
||||||
|
ports:
|
||||||
|
- 3000:3000
|
||||||
|
options: >-
|
||||||
|
--health-cmd "curl --silent --fail http://localhost:3000/health"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions\Api;
|
||||||
|
|
||||||
|
class FeatureIsNotAvailableInFreePlanApiException extends ApiException
|
||||||
|
{
|
||||||
|
public const string KEY = 'feature_is_not_available_in_free_plan';
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Enums\ExportFormat;
|
use App\Enums\ExportFormat;
|
||||||
|
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
||||||
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
||||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||||
@@ -35,6 +36,7 @@ use Gotenberg\Exceptions\GotenbergApiErrored;
|
|||||||
use Gotenberg\Exceptions\NoOutputFileInResponse;
|
use Gotenberg\Exceptions\NoOutputFileInResponse;
|
||||||
use Gotenberg\Gotenberg;
|
use Gotenberg\Gotenberg;
|
||||||
use Gotenberg\Stream;
|
use Gotenberg\Stream;
|
||||||
|
use GuzzleHttp\Client;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Http\File;
|
use Illuminate\Http\File;
|
||||||
@@ -158,7 +160,7 @@ class TimeEntryController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Export time entries in organization
|
* Export time entries in organization
|
||||||
*
|
*
|
||||||
* @throws AuthorizationException|PdfRendererIsNotConfiguredException
|
* @throws AuthorizationException|PdfRendererIsNotConfiguredException|FeatureIsNotAvailableInFreePlanApiException
|
||||||
*
|
*
|
||||||
* @operationId exportTimeEntries
|
* @operationId exportTimeEntries
|
||||||
*/
|
*/
|
||||||
@@ -171,6 +173,10 @@ class TimeEntryController extends Controller
|
|||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
}
|
}
|
||||||
|
$format = $request->getFormatValue();
|
||||||
|
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
|
||||||
|
throw new FeatureIsNotAvailableInFreePlanApiException;
|
||||||
|
}
|
||||||
|
|
||||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
|
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
|
||||||
$timeEntriesQuery->with([
|
$timeEntriesQuery->with([
|
||||||
@@ -180,7 +186,6 @@ class TimeEntryController extends Controller
|
|||||||
'user',
|
'user',
|
||||||
'tagsRelation',
|
'tagsRelation',
|
||||||
]);
|
]);
|
||||||
$format = $request->getFormatValue();
|
|
||||||
$filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
|
$filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
|
||||||
$folderPath = 'exports';
|
$folderPath = 'exports';
|
||||||
$path = $folderPath.'/'.$filename;
|
$path = $folderPath.'/'.$filename;
|
||||||
@@ -201,6 +206,12 @@ class TimeEntryController extends Controller
|
|||||||
throw new \LogicException('View file not found');
|
throw new \LogicException('View file not found');
|
||||||
}
|
}
|
||||||
$footerHtml = Blade::render($footerViewFile);
|
$footerHtml = Blade::render($footerViewFile);
|
||||||
|
$client = new Client([
|
||||||
|
'auth' => config('services.gotenberg.basic_auth_username') !== null && config('services.gotenberg.basic_auth_password') !== null ? [
|
||||||
|
config('services.gotenberg.basic_auth_username'),
|
||||||
|
config('services.gotenberg.basic_auth_password'),
|
||||||
|
] : null,
|
||||||
|
]);
|
||||||
$request = Gotenberg::chromium(config('services.gotenberg.url'))
|
$request = Gotenberg::chromium(config('services.gotenberg.url'))
|
||||||
->pdf()
|
->pdf()
|
||||||
->pdfa('PDF/A-3b')
|
->pdfa('PDF/A-3b')
|
||||||
@@ -208,7 +219,7 @@ class TimeEntryController extends Controller
|
|||||||
->footer(Stream::string('footer', $footerHtml))
|
->footer(Stream::string('footer', $footerHtml))
|
||||||
->html(Stream::string('body', $html));
|
->html(Stream::string('body', $html));
|
||||||
$tempFolder = TemporaryDirectory::make();
|
$tempFolder = TemporaryDirectory::make();
|
||||||
$filenameTemp = Gotenberg::save($request, $tempFolder->path());
|
$filenameTemp = Gotenberg::save($request, $tempFolder->path(), $client);
|
||||||
Storage::disk(config('filesystems.private'))
|
Storage::disk(config('filesystems.private'))
|
||||||
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
|
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
|
||||||
} else {
|
} else {
|
||||||
@@ -301,6 +312,7 @@ class TimeEntryController extends Controller
|
|||||||
* @throws PdfRendererIsNotConfiguredException
|
* @throws PdfRendererIsNotConfiguredException
|
||||||
* @throws GotenbergApiErrored
|
* @throws GotenbergApiErrored
|
||||||
* @throws NoOutputFileInResponse
|
* @throws NoOutputFileInResponse
|
||||||
|
* @throws FeatureIsNotAvailableInFreePlanApiException
|
||||||
*/
|
*/
|
||||||
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -311,6 +323,10 @@ class TimeEntryController extends Controller
|
|||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
}
|
}
|
||||||
|
$format = $request->getFormatValue();
|
||||||
|
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
|
||||||
|
throw new FeatureIsNotAvailableInFreePlanApiException;
|
||||||
|
}
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
|
|
||||||
$group = $request->getGroup();
|
$group = $request->getGroup();
|
||||||
@@ -340,7 +356,6 @@ class TimeEntryController extends Controller
|
|||||||
$currency = $organization->currency;
|
$currency = $organization->currency;
|
||||||
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
|
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
|
||||||
|
|
||||||
$format = $request->getFormatValue();
|
|
||||||
$filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
|
$filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
|
||||||
$folderPath = 'exports';
|
$folderPath = 'exports';
|
||||||
$path = $folderPath.'/'.$filename;
|
$path = $folderPath.'/'.$filename;
|
||||||
@@ -349,6 +364,12 @@ class TimeEntryController extends Controller
|
|||||||
if (config('services.gotenberg.url') === null) {
|
if (config('services.gotenberg.url') === null) {
|
||||||
throw new PdfRendererIsNotConfiguredException;
|
throw new PdfRendererIsNotConfiguredException;
|
||||||
}
|
}
|
||||||
|
$client = new Client([
|
||||||
|
'auth' => config('services.gotenberg.basic_auth_username') !== null && config('services.gotenberg.basic_auth_password') !== null ? [
|
||||||
|
config('services.gotenberg.basic_auth_username'),
|
||||||
|
config('services.gotenberg.basic_auth_password'),
|
||||||
|
] : null,
|
||||||
|
]);
|
||||||
$viewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate-index.blade.php'));
|
$viewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate-index.blade.php'));
|
||||||
if ($viewFile === false) {
|
if ($viewFile === false) {
|
||||||
throw new \LogicException('View file not found');
|
throw new \LogicException('View file not found');
|
||||||
@@ -374,7 +395,7 @@ class TimeEntryController extends Controller
|
|||||||
->footer(Stream::string('footer', $footerHtml))
|
->footer(Stream::string('footer', $footerHtml))
|
||||||
->html(Stream::string('body', $html));
|
->html(Stream::string('body', $html));
|
||||||
$tempFolder = TemporaryDirectory::make();
|
$tempFolder = TemporaryDirectory::make();
|
||||||
$filenameTemp = Gotenberg::save($request, $tempFolder->path());
|
$filenameTemp = Gotenberg::save($request, $tempFolder->path(), $client);
|
||||||
Storage::disk(config('filesystems.private'))
|
Storage::disk(config('filesystems.private'))
|
||||||
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
|
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Models;
|
|||||||
use App\Models\Concerns\CustomAuditable;
|
use App\Models\Concerns\CustomAuditable;
|
||||||
use App\Models\Concerns\HasUuids;
|
use App\Models\Concerns\HasUuids;
|
||||||
use Database\Factories\TagFactory;
|
use Database\Factories\TagFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
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;
|
||||||
@@ -21,6 +22,7 @@ use Staudenmeir\EloquentJsonRelations\Relations\HasManyJson;
|
|||||||
* @property string $organization_id
|
* @property string $organization_id
|
||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
|
* @property-read Collection<TimeEntry> $timeEntries
|
||||||
* @property-read Organization $organization
|
* @property-read Organization $organization
|
||||||
*
|
*
|
||||||
* @method static TagFactory factory()
|
* @method static TagFactory factory()
|
||||||
|
|||||||
@@ -184,20 +184,6 @@ class TimeEntryAggregationService
|
|||||||
$descriptionMapGroup2 = $group2Type !== null ? $this->loadDescriptionMap($keysGroup2, $group2Type) : [];
|
$descriptionMapGroup2 = $group2Type !== null ? $this->loadDescriptionMap($keysGroup2, $group2Type) : [];
|
||||||
|
|
||||||
if ($aggregatedTimeEntries['grouped_data'] !== null) {
|
if ($aggregatedTimeEntries['grouped_data'] !== null) {
|
||||||
/*
|
|
||||||
$aggregatedTimeEntries['grouped_data'] = array_map(function (array $value) use ($descriptionMapGroup1, $descriptionMapGroup2): array {
|
|
||||||
$value['description'] = $value['key'] !== null ? ($descriptionMapGroup1[$value['key']] ?? null) : null;
|
|
||||||
if ($value['grouped_data'] !== null) {
|
|
||||||
$value['grouped_data'] = array_map(function (array $value) use ($descriptionMapGroup2): array {
|
|
||||||
$value['description'] = $value['key'] !== null ? ($descriptionMapGroup2[$value['key']] ?? null) : null;
|
|
||||||
|
|
||||||
return $value;
|
|
||||||
}, $value['grouped_data']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $value;
|
|
||||||
}, $aggregatedTimeEntries['grouped_data']);
|
|
||||||
*/
|
|
||||||
foreach ($aggregatedTimeEntries['grouped_data'] as $keyGroup1 => $group1) {
|
foreach ($aggregatedTimeEntries['grouped_data'] as $keyGroup1 => $group1) {
|
||||||
$aggregatedTimeEntries['grouped_data'][$keyGroup1]['description'] = $group1['key'] !== null ? ($descriptionMapGroup1[$group1['key']] ?? null) : null;
|
$aggregatedTimeEntries['grouped_data'][$keyGroup1]['description'] = $group1['key'] !== null ? ($descriptionMapGroup1[$group1['key']] ?? null) : null;
|
||||||
if ($aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'] !== null) {
|
if ($aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'] !== null) {
|
||||||
@@ -208,6 +194,29 @@ class TimeEntryAggregationService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array{
|
||||||
|
* grouped_type: string|null,
|
||||||
|
* grouped_data: null|array<array{
|
||||||
|
* key: string|null,
|
||||||
|
* description: string|null,
|
||||||
|
* seconds: int,
|
||||||
|
* cost: int,
|
||||||
|
* grouped_type: string|null,
|
||||||
|
* grouped_data: null|array<array{
|
||||||
|
* key: string|null,
|
||||||
|
* description: string|null,
|
||||||
|
* seconds: int,
|
||||||
|
* cost: int,
|
||||||
|
* grouped_type: null,
|
||||||
|
* grouped_data: null
|
||||||
|
* }>
|
||||||
|
* }>,
|
||||||
|
* seconds: int,
|
||||||
|
* cost: int
|
||||||
|
* } $aggregatedTimeEntries
|
||||||
|
*/
|
||||||
|
|
||||||
return $aggregatedTimeEntries;
|
return $aggregatedTimeEntries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,5 +5,7 @@ declare(strict_types=1);
|
|||||||
return [
|
return [
|
||||||
'gotenberg' => [
|
'gotenberg' => [
|
||||||
'url' => env('GOTENBERG_URL'),
|
'url' => env('GOTENBERG_URL'),
|
||||||
|
'basic_auth_username' => env('GOTENBERG_BASIC_AUTH_USERNAME'),
|
||||||
|
'basic_auth_password' => env('GOTENBERG_BASIC_AUTH_PASSWORD'),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembe
|
|||||||
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
||||||
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
||||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||||
|
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
||||||
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
|
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
|
||||||
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
||||||
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
|
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
|
||||||
@@ -35,6 +36,7 @@ return [
|
|||||||
ExportException::KEY => 'Export failed, please try again later or contact support',
|
ExportException::KEY => 'Export failed, please try again later or contact support',
|
||||||
OrganizationHasNoSubscriptionButMultipleMembersException::KEY => 'Organization has no subscription but multiple members',
|
OrganizationHasNoSubscriptionButMultipleMembersException::KEY => 'Organization has no subscription but multiple members',
|
||||||
PdfRendererIsNotConfiguredException::KEY => 'PDF renderer is not configured',
|
PdfRendererIsNotConfiguredException::KEY => 'PDF renderer is not configured',
|
||||||
|
FeatureIsNotAvailableInFreePlanApiException::KEY => 'Feature is not available in free plan',
|
||||||
],
|
],
|
||||||
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
|
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -25,12 +25,7 @@ abstract class TestCase extends BaseTestCase
|
|||||||
parent::setUp();
|
parent::setUp();
|
||||||
Mail::fake();
|
Mail::fake();
|
||||||
LogFake::bind();
|
LogFake::bind();
|
||||||
$this->mock(BillingContract::class, function (MockInterface $mock): void {
|
$this->actAsOrganizationWithoutSubscriptionAndWithoutTrial();
|
||||||
$mock->shouldReceive('hasSubscription')->andReturn(false);
|
|
||||||
$mock->shouldReceive('hasTrial')->andReturn(false);
|
|
||||||
$mock->shouldReceive('getTrialUntil')->andReturn(null);
|
|
||||||
$mock->shouldReceive('isBlocked')->andReturn(false);
|
|
||||||
});
|
|
||||||
// Note: The following line can be used to test timezone edge cases.
|
// Note: The following line can be used to test timezone edge cases.
|
||||||
// $this->travelTo(Carbon::now()->timezone('Europe/Vienna')->setHour(0)->setMinute(59)->setSecond(0));
|
// $this->travelTo(Carbon::now()->timezone('Europe/Vienna')->setHour(0)->setMinute(59)->setSecond(0));
|
||||||
}
|
}
|
||||||
@@ -89,4 +84,14 @@ abstract class TestCase extends BaseTestCase
|
|||||||
$mock->shouldReceive('isBlocked')->andReturn(false);
|
$mock->shouldReceive('isBlocked')->andReturn(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function actAsOrganizationWithoutSubscriptionAndWithoutTrial(): void
|
||||||
|
{
|
||||||
|
$this->mock(BillingContract::class, function (MockInterface $mock): void {
|
||||||
|
$mock->shouldReceive('hasSubscription')->andReturn(false);
|
||||||
|
$mock->shouldReceive('hasTrial')->andReturn(false);
|
||||||
|
$mock->shouldReceive('getTrialUntil')->andReturn(null);
|
||||||
|
$mock->shouldReceive('isBlocked')->andReturn(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tests\Unit\Endpoint\Api\V1;
|
namespace Tests\Unit\Endpoint\Api\V1;
|
||||||
|
|
||||||
|
use Illuminate\Testing\TestResponse;
|
||||||
use Tests\TestCaseWithDatabase;
|
use Tests\TestCaseWithDatabase;
|
||||||
|
|
||||||
class ApiEndpointTestAbstract extends TestCaseWithDatabase {}
|
class ApiEndpointTestAbstract extends TestCaseWithDatabase
|
||||||
|
{
|
||||||
|
protected function assertResponseCode(TestResponse $response, int $statusCode): void
|
||||||
|
{
|
||||||
|
if ($response->getStatusCode() !== $statusCode) {
|
||||||
|
dump($response->getContent());
|
||||||
|
}
|
||||||
|
$response->assertStatus($statusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tests\Unit\Endpoint\Api\V1;
|
namespace Tests\Unit\Endpoint\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\ExportFormat;
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
|
use App\Enums\TimeEntryAggregationType;
|
||||||
|
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||||
use App\Http\Controllers\Api\V1\TimeEntryController;
|
use App\Http\Controllers\Api\V1\TimeEntryController;
|
||||||
use App\Jobs\RecalculateSpentTimeForProject;
|
use App\Jobs\RecalculateSpentTimeForProject;
|
||||||
@@ -17,8 +20,10 @@ use App\Models\Task;
|
|||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Config;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Testing\Fluent\AssertableJson;
|
use Illuminate\Testing\Fluent\AssertableJson;
|
||||||
use Laravel\Passport\Passport;
|
use Laravel\Passport\Passport;
|
||||||
@@ -29,6 +34,12 @@ use TiMacDonald\Log\LogEntry;
|
|||||||
#[UsesClass(TimeEntryController::class)]
|
#[UsesClass(TimeEntryController::class)]
|
||||||
class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||||
{
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
Storage::fake('local');
|
||||||
|
}
|
||||||
|
|
||||||
public function test_index_endpoint_fails_if_user_has_no_permission_to_view_time_entries(): void
|
public function test_index_endpoint_fails_if_user_has_no_permission_to_view_time_entries(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -73,7 +84,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]));
|
]));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$response->assertJsonPath('data.0.id', $timeEntry->getKey());
|
$response->assertJsonPath('data.0.id', $timeEntry->getKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +125,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response = $this->getJson(route('api.v1.time-entries.index', [$data->organization->getKey(), 'user_id' => $user->getKey()]));
|
$response = $this->getJson(route('api.v1.time-entries.index', [$data->organization->getKey(), 'user_id' => $user->getKey()]));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$response->assertJsonPath('data.0.id', $timeEntry->getKey());
|
$response->assertJsonPath('data.0.id', $timeEntry->getKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +152,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response = $this->getJson(route('api.v1.time-entries.index', [$data->organization->getKey()]));
|
$response = $this->getJson(route('api.v1.time-entries.index', [$data->organization->getKey()]));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$response->assertJsonPath('data.0.id', $timeEntry1->getKey());
|
$response->assertJsonPath('data.0.id', $timeEntry1->getKey());
|
||||||
$response->assertJsonPath('data.1.id', $timeEntry2->getKey());
|
$response->assertJsonPath('data.1.id', $timeEntry2->getKey());
|
||||||
$response->assertJsonPath('data.2.id', $timeEntry3->getKey());
|
$response->assertJsonPath('data.2.id', $timeEntry3->getKey());
|
||||||
@@ -165,7 +176,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]));
|
]));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$response->assertJsonCount(1, 'data');
|
$response->assertJsonCount(1, 'data');
|
||||||
$response->assertJsonPath('meta.total', 1);
|
$response->assertJsonPath('meta.total', 1);
|
||||||
$response->assertJsonPath('data.0.id', $activeTimeEntry->getKey());
|
$response->assertJsonPath('data.0.id', $activeTimeEntry->getKey());
|
||||||
@@ -189,7 +200,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]));
|
]));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$response->assertJsonCount(1, 'data');
|
$response->assertJsonCount(1, 'data');
|
||||||
$response->assertJsonPath('meta.total', 1);
|
$response->assertJsonPath('meta.total', 1);
|
||||||
$response->assertJsonPath('data.0.id', $nonActiveTimeEntries->getKey());
|
$response->assertJsonPath('data.0.id', $nonActiveTimeEntries->getKey());
|
||||||
@@ -213,7 +224,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]));
|
]));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$response->assertJsonCount(3, 'data');
|
$response->assertJsonCount(3, 'data');
|
||||||
$response->assertJsonPath('meta.total', 3);
|
$response->assertJsonPath('meta.total', 3);
|
||||||
}
|
}
|
||||||
@@ -241,7 +252,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]));
|
]));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$response->assertJsonCount(3, 'data');
|
$response->assertJsonCount(3, 'data');
|
||||||
$response->assertJsonPath('meta.total', 6);
|
$response->assertJsonPath('meta.total', 6);
|
||||||
}
|
}
|
||||||
@@ -290,7 +301,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]));
|
]));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$response->assertJsonCount(2, 'data');
|
$response->assertJsonCount(2, 'data');
|
||||||
$response->assertJsonPath('meta.total', 7);
|
$response->assertJsonPath('meta.total', 7);
|
||||||
}
|
}
|
||||||
@@ -324,7 +335,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]));
|
]));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$response->assertJsonCount(7, 'data');
|
$response->assertJsonCount(7, 'data');
|
||||||
$response->assertJsonPath('meta.total', 10);
|
$response->assertJsonPath('meta.total', 10);
|
||||||
Log::assertLogged(fn (LogEntry $log) => $log->level === 'warning'
|
Log::assertLogged(fn (LogEntry $log) => $log->level === 'warning'
|
||||||
@@ -365,7 +376,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]));
|
]));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$response->assertJson(fn (AssertableJson $json) => $json
|
$response->assertJson(fn (AssertableJson $json) => $json
|
||||||
->has('data')
|
->has('data')
|
||||||
->has('meta')
|
->has('meta')
|
||||||
@@ -405,7 +416,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]));
|
]));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$response->assertJson(fn (AssertableJson $json) => $json
|
$response->assertJson(fn (AssertableJson $json) => $json
|
||||||
->has('data')
|
->has('data')
|
||||||
->has('meta')
|
->has('meta')
|
||||||
@@ -458,7 +469,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertValid();
|
$response->assertValid();
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$response->assertJson(fn (AssertableJson $json) => $json
|
$response->assertJson(fn (AssertableJson $json) => $json
|
||||||
->has('data')
|
->has('data')
|
||||||
->has('meta')
|
->has('meta')
|
||||||
@@ -499,12 +510,201 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]));
|
]));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$response->assertJsonCount(1, 'data');
|
$response->assertJsonCount(1, 'data');
|
||||||
$response->assertJsonPath('meta.total', 3);
|
$response->assertJsonPath('meta.total', 3);
|
||||||
$response->assertJsonPath('data.*.id', [$timeEntry->getKey()]);
|
$response->assertJsonPath('data.*.id', [$timeEntry->getKey()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_index_export_endpoint_fails_if_user_has_no_permission_to_view_time_entries(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.time-entries.index-export', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
'format' => ExportFormat::CSV,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_index_export_endpoint_fails_if_pdf_renderer_is_not_configured_but_a_user_want_a_pdf_report(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:view:all',
|
||||||
|
]);
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
Config::set('services.gotenberg.url', null);
|
||||||
|
$this->actAsOrganizationWithSubscription();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.time-entries.index-export', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
'format' => ExportFormat::PDF,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(400);
|
||||||
|
$response->assertExactJson([
|
||||||
|
'error' => true,
|
||||||
|
'key' => 'pdf_renderer_is_not_configured',
|
||||||
|
'message' => 'PDF renderer is not configured',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_index_export_endpoint_fails_if_user_wants_a_pdf_export_but_has_no_subscription(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:view:all',
|
||||||
|
]);
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
$this->actAsOrganizationWithoutSubscriptionAndWithoutTrial();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.time-entries.index-export', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
'format' => ExportFormat::PDF,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(400);
|
||||||
|
$response->assertExactJson([
|
||||||
|
'error' => true,
|
||||||
|
'key' => 'feature_is_not_available_in_free_plan',
|
||||||
|
'message' => 'Feature is not available in free plan',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_index_export_endpoint_fails_if_user_has_only_access_to_own_time_entries_but_does_not_filter_for_this(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:view:own',
|
||||||
|
]);
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.time-entries.index-export', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
'format' => ExportFormat::CSV,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_csv(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:view:all',
|
||||||
|
]);
|
||||||
|
$client = Client::factory()->forOrganization($data->organization)->create();
|
||||||
|
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
|
||||||
|
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
|
||||||
|
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.time-entries.index-export', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
'format' => ExportFormat::CSV,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertResponseCode($response, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_ods(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:view:all',
|
||||||
|
]);
|
||||||
|
$client = Client::factory()->forOrganization($data->organization)->create();
|
||||||
|
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
|
||||||
|
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
|
||||||
|
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.time-entries.index-export', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
'format' => ExportFormat::ODS,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertResponseCode($response, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_xlxs(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:view:all',
|
||||||
|
]);
|
||||||
|
$client = Client::factory()->forOrganization($data->organization)->create();
|
||||||
|
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
|
||||||
|
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
|
||||||
|
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.time-entries.index-export', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
'format' => ExportFormat::XLSX,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertResponseCode($response, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_pdf(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:view:all',
|
||||||
|
]);
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
$this->actAsOrganizationWithSubscription();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.time-entries.index-export', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
'format' => ExportFormat::PDF,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertResponseCode($response, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_aggregate_export_endpoints_fails_if_user_no_permission_to_view_time_entries(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
'format' => ExportFormat::CSV,
|
||||||
|
'group' => TimeEntryAggregationType::Client,
|
||||||
|
'sub_group' => TimeEntryAggregationType::Project,
|
||||||
|
'history_group' => TimeEntryAggregationTypeInterval::Month,
|
||||||
|
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
|
||||||
|
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
public function test_aggregate_endpoint_fails_if_user_has_no_permission_to_view_time_entries(): void
|
public function test_aggregate_endpoint_fails_if_user_has_no_permission_to_view_time_entries(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -512,12 +712,266 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
Passport::actingAs($data->user);
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
$response = $this->getJson(route('api.v1.time-entries.aggregate', [$data->organization->getKey()]));
|
$response = $this->getJson(route('api.v1.time-entries.aggregate', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
]));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertForbidden();
|
$response->assertForbidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_aggregate_export_endpoint_fails_if_user_wants_a_pdf_export_but_has_no_subscription(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:view:all',
|
||||||
|
]);
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
$this->actAsOrganizationWithoutSubscriptionAndWithoutTrial();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
'format' => ExportFormat::PDF,
|
||||||
|
'group' => TimeEntryAggregationType::Client,
|
||||||
|
'sub_group' => TimeEntryAggregationType::Project,
|
||||||
|
'history_group' => TimeEntryAggregationTypeInterval::Month,
|
||||||
|
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
|
||||||
|
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(400);
|
||||||
|
$response->assertExactJson([
|
||||||
|
'error' => true,
|
||||||
|
'key' => 'feature_is_not_available_in_free_plan',
|
||||||
|
'message' => 'Feature is not available in free plan',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_aggregate_export_endpoint_fails_if_user_has_only_access_to_own_time_entries_but_does_not_filter_for_this(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:view:own',
|
||||||
|
]);
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
'format' => ExportFormat::CSV,
|
||||||
|
'group' => TimeEntryAggregationType::Client,
|
||||||
|
'sub_group' => TimeEntryAggregationType::Project,
|
||||||
|
'history_group' => TimeEntryAggregationTypeInterval::Month,
|
||||||
|
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
|
||||||
|
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_aggregate_export_endpoints_can_create_a_csv_report(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:view:all',
|
||||||
|
]);
|
||||||
|
$client = Client::factory()->forOrganization($data->organization)->create();
|
||||||
|
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
|
||||||
|
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
|
||||||
|
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
'format' => ExportFormat::CSV,
|
||||||
|
'group' => TimeEntryAggregationType::Client,
|
||||||
|
'sub_group' => TimeEntryAggregationType::Project,
|
||||||
|
'history_group' => TimeEntryAggregationTypeInterval::Month,
|
||||||
|
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
|
||||||
|
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertResponseCode($response, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_aggregate_export_endpoints_can_create_a_xlsx_report(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:view:all',
|
||||||
|
]);
|
||||||
|
$client = Client::factory()->forOrganization($data->organization)->create();
|
||||||
|
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
|
||||||
|
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
|
||||||
|
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
'format' => ExportFormat::XLSX,
|
||||||
|
'group' => TimeEntryAggregationType::Client,
|
||||||
|
'sub_group' => TimeEntryAggregationType::Project,
|
||||||
|
'history_group' => TimeEntryAggregationTypeInterval::Month,
|
||||||
|
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
|
||||||
|
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertResponseCode($response, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_aggregate_export_endpoints_can_create_a_ods_report(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:view:all',
|
||||||
|
]);
|
||||||
|
$client = Client::factory()->forOrganization($data->organization)->create();
|
||||||
|
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
|
||||||
|
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
|
||||||
|
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
'format' => ExportFormat::ODS,
|
||||||
|
'group' => TimeEntryAggregationType::User,
|
||||||
|
'sub_group' => TimeEntryAggregationType::Project,
|
||||||
|
'history_group' => TimeEntryAggregationTypeInterval::Month,
|
||||||
|
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
|
||||||
|
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertResponseCode($response, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_aggregate_export_endpoint_fails_if_pdf_renderer_is_not_configured_but_a_user_want_a_pdf_report(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:view:all',
|
||||||
|
]);
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
$this->actAsOrganizationWithSubscription();
|
||||||
|
Config::set('services.gotenberg.url', null);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
'format' => ExportFormat::PDF,
|
||||||
|
'group' => TimeEntryAggregationType::User,
|
||||||
|
'sub_group' => TimeEntryAggregationType::Project,
|
||||||
|
'history_group' => TimeEntryAggregationTypeInterval::Month,
|
||||||
|
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
|
||||||
|
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(400);
|
||||||
|
$response->assertExactJson([
|
||||||
|
'error' => true,
|
||||||
|
'key' => 'pdf_renderer_is_not_configured',
|
||||||
|
'message' => 'PDF renderer is not configured',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_aggregate_export_endpoints_can_create_a_pdf_report(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:view:all',
|
||||||
|
]);
|
||||||
|
$client = Client::factory()->forOrganization($data->organization)->create();
|
||||||
|
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
|
||||||
|
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
|
||||||
|
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
$this->actAsOrganizationWithSubscription();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
'format' => ExportFormat::PDF,
|
||||||
|
'group' => TimeEntryAggregationType::User,
|
||||||
|
'sub_group' => TimeEntryAggregationType::Project,
|
||||||
|
'history_group' => TimeEntryAggregationTypeInterval::Month,
|
||||||
|
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
|
||||||
|
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertResponseCode($response, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_aggregate_endpoint_fails_if_user_has_only_access_to_own_time_entries_but_does_not_filter_for_this(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:view:own',
|
||||||
|
]);
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.time-entries.aggregate', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
'group' => 'day',
|
||||||
|
'sub_group' => 'project',
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_aggregate_endpoint_works_for_user_with_only_access_to_own_time_entries(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:view:own',
|
||||||
|
]);
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->create();
|
||||||
|
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||||
|
$start = Carbon::now()->timezone($data->user->timezone)->subDays(2);
|
||||||
|
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->forProject($project)->startWithDuration($start, 100)->create();
|
||||||
|
$timeEntryOtherMember = TimeEntry::factory()->forOrganization($data->organization)->forMember($otherMember)->forProject($project)->startWithDuration($start, 100)->create();
|
||||||
|
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.time-entries.aggregate', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
'member_id' => $data->member->getKey(),
|
||||||
|
'group' => 'project',
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertSuccessful();
|
||||||
|
$response->assertExactJson([
|
||||||
|
'data' => [
|
||||||
|
'seconds' => 100,
|
||||||
|
'cost' => 0,
|
||||||
|
'grouped_data' => [
|
||||||
|
0 => [
|
||||||
|
'key' => $project->getKey(),
|
||||||
|
'seconds' => 100,
|
||||||
|
'cost' => 0,
|
||||||
|
'grouped_type' => null,
|
||||||
|
'grouped_data' => null,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'grouped_type' => 'project',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_aggregate_endpoint_groups_by_two_groups(): void
|
public function test_aggregate_endpoint_groups_by_two_groups(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -1314,7 +1768,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$this->assertDatabaseHas(TimeEntry::class, [
|
$this->assertDatabaseHas(TimeEntry::class, [
|
||||||
'id' => $timeEntry->getKey(),
|
'id' => $timeEntry->getKey(),
|
||||||
'member_id' => $data->member->getKey(),
|
'member_id' => $data->member->getKey(),
|
||||||
@@ -1343,7 +1797,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$this->assertDatabaseHas(TimeEntry::class, [
|
$this->assertDatabaseHas(TimeEntry::class, [
|
||||||
'id' => $timeEntry->getKey(),
|
'id' => $timeEntry->getKey(),
|
||||||
'member_id' => $data->member->getKey(),
|
'member_id' => $data->member->getKey(),
|
||||||
@@ -1372,7 +1826,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$this->assertDatabaseHas(TimeEntry::class, [
|
$this->assertDatabaseHas(TimeEntry::class, [
|
||||||
'id' => $timeEntry->getKey(),
|
'id' => $timeEntry->getKey(),
|
||||||
'member_id' => $data->member->getKey(),
|
'member_id' => $data->member->getKey(),
|
||||||
@@ -1429,7 +1883,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$this->assertDatabaseHas(TimeEntry::class, [
|
$this->assertDatabaseHas(TimeEntry::class, [
|
||||||
'id' => $timeEntry->getKey(),
|
'id' => $timeEntry->getKey(),
|
||||||
'member_id' => $member->getKey(),
|
'member_id' => $member->getKey(),
|
||||||
@@ -1457,7 +1911,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertValid();
|
$response->assertValid();
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$this->assertDatabaseHas(TimeEntry::class, [
|
$this->assertDatabaseHas(TimeEntry::class, [
|
||||||
'id' => $timeEntry->getKey(),
|
'id' => $timeEntry->getKey(),
|
||||||
'member_id' => $member->getKey(),
|
'member_id' => $member->getKey(),
|
||||||
@@ -1487,7 +1941,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertValid();
|
$response->assertValid();
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$this->assertDatabaseHas(TimeEntry::class, [
|
$this->assertDatabaseHas(TimeEntry::class, [
|
||||||
'id' => $timeEntry->getKey(),
|
'id' => $timeEntry->getKey(),
|
||||||
'member_id' => $member->getKey(),
|
'member_id' => $member->getKey(),
|
||||||
@@ -1520,7 +1974,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
Queue::assertPushed(RecalculateSpentTimeForProject::class, 1);
|
Queue::assertPushed(RecalculateSpentTimeForProject::class, 1);
|
||||||
Queue::assertPushed(RecalculateSpentTimeForTask::class, 1);
|
Queue::assertPushed(RecalculateSpentTimeForTask::class, 1);
|
||||||
Queue::assertPushed(function (RecalculateSpentTimeForProject $job) use ($project): bool {
|
Queue::assertPushed(function (RecalculateSpentTimeForProject $job) use ($project): bool {
|
||||||
@@ -1556,7 +2010,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
Queue::assertPushed(RecalculateSpentTimeForProject::class, 2);
|
Queue::assertPushed(RecalculateSpentTimeForProject::class, 2);
|
||||||
Queue::assertPushed(RecalculateSpentTimeForTask::class, 2);
|
Queue::assertPushed(RecalculateSpentTimeForTask::class, 2);
|
||||||
Queue::assertPushed(function (RecalculateSpentTimeForProject $job) use ($project): bool {
|
Queue::assertPushed(function (RecalculateSpentTimeForProject $job) use ($project): bool {
|
||||||
@@ -1749,7 +2203,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertValid();
|
$response->assertValid();
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$response->assertExactJson([
|
$response->assertExactJson([
|
||||||
'success' => [
|
'success' => [
|
||||||
$ownTimeEntry->getKey(),
|
$ownTimeEntry->getKey(),
|
||||||
@@ -1799,7 +2253,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertValid();
|
$response->assertValid();
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$response->assertExactJson([
|
$response->assertExactJson([
|
||||||
'success' => [
|
'success' => [
|
||||||
$ownTimeEntry->getKey(),
|
$ownTimeEntry->getKey(),
|
||||||
@@ -1851,7 +2305,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertValid();
|
$response->assertValid();
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$response->assertExactJson([
|
$response->assertExactJson([
|
||||||
'success' => [
|
'success' => [
|
||||||
$timeEntryWithProject->getKey(),
|
$timeEntryWithProject->getKey(),
|
||||||
@@ -1982,7 +2436,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertValid();
|
$response->assertValid();
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$response->assertExactJson([
|
$response->assertExactJson([
|
||||||
'success' => [
|
'success' => [
|
||||||
$timeEntry1->getKey(),
|
$timeEntry1->getKey(),
|
||||||
@@ -2034,7 +2488,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertValid();
|
$response->assertValid();
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$response->assertExactJson([
|
$response->assertExactJson([
|
||||||
'success' => [
|
'success' => [
|
||||||
$ownTimeEntry->getKey(),
|
$ownTimeEntry->getKey(),
|
||||||
@@ -2098,7 +2552,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertValid();
|
$response->assertValid();
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$response->assertExactJson([
|
$response->assertExactJson([
|
||||||
'success' => [
|
'success' => [
|
||||||
$ownTimeEntry->getKey(),
|
$ownTimeEntry->getKey(),
|
||||||
@@ -2215,7 +2669,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertValid();
|
$response->assertValid();
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$response->assertExactJson([
|
$response->assertExactJson([
|
||||||
'success' => [
|
'success' => [
|
||||||
$ownTimeEntry->getKey(),
|
$ownTimeEntry->getKey(),
|
||||||
@@ -2279,7 +2733,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertValid();
|
$response->assertValid();
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$response->assertExactJson([
|
$response->assertExactJson([
|
||||||
'success' => [
|
'success' => [
|
||||||
$ownTimeEntry->getKey(),
|
$ownTimeEntry->getKey(),
|
||||||
@@ -2345,7 +2799,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertValid();
|
$response->assertValid();
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$response->assertExactJson([
|
$response->assertExactJson([
|
||||||
'success' => [
|
'success' => [
|
||||||
$timeEntry1->getKey(),
|
$timeEntry1->getKey(),
|
||||||
@@ -2393,7 +2847,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertValid();
|
$response->assertValid();
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$response->assertExactJson([
|
$response->assertExactJson([
|
||||||
'success' => [
|
'success' => [
|
||||||
$timeEntry1->getKey(),
|
$timeEntry1->getKey(),
|
||||||
@@ -2432,7 +2886,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertValid();
|
$response->assertValid();
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$response->assertExactJson([
|
$response->assertExactJson([
|
||||||
'success' => [
|
'success' => [
|
||||||
$timeEntry1->getKey(),
|
$timeEntry1->getKey(),
|
||||||
@@ -2483,7 +2937,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertValid();
|
$response->assertValid();
|
||||||
$response->assertStatus(200);
|
$this->assertResponseCode($response, 200);
|
||||||
$response->assertExactJson([
|
$response->assertExactJson([
|
||||||
'success' => [
|
'success' => [
|
||||||
$timeEntry1->getKey(),
|
$timeEntry1->getKey(),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace Tests\Unit\Model;
|
|||||||
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
|
use App\Models\TimeEntry;
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
use PHPUnit\Framework\Attributes\UsesClass;
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
|
|
||||||
@@ -17,14 +18,39 @@ class TagModelTest extends ModelTestAbstract
|
|||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
$organization = Organization::factory()->create();
|
$organization = Organization::factory()->create();
|
||||||
$task = Tag::factory()->forOrganization($organization)->create();
|
$tag = Tag::factory()->forOrganization($organization)->create();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
$task->refresh();
|
$tag->refresh();
|
||||||
$organizationRel = $task->organization;
|
$organizationRel = $tag->organization;
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$this->assertNotNull($organizationRel);
|
$this->assertNotNull($organizationRel);
|
||||||
$this->assertTrue($organizationRel->is($organization));
|
$this->assertTrue($organizationRel->is($organization));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_has_many_time_entries_via_json_field(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$tag1 = Tag::factory()->forOrganization($organization)->create();
|
||||||
|
$tag2 = Tag::factory()->forOrganization($organization)->create();
|
||||||
|
$timeEntry1 = TimeEntry::factory()->forOrganization($organization)->create([
|
||||||
|
'tags' => [$tag1->id, $tag2->id],
|
||||||
|
]);
|
||||||
|
$timeEntry2 = TimeEntry::factory()->forOrganization($organization)->create([
|
||||||
|
'tags' => [$tag1->id],
|
||||||
|
]);
|
||||||
|
$timeEntry3 = TimeEntry::factory()->forOrganization($organization)->create([
|
||||||
|
'tags' => [$tag2->id],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$tag1->refresh();
|
||||||
|
$timeEntries = $tag1->timeEntries;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertCount(2, $timeEntries);
|
||||||
|
$this->assertEqualsCanonicalizing([$timeEntry1->getKey(), $timeEntry2->getKey()], $timeEntries->pluck('id')->toArray());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,4 +179,50 @@ class TimeEntryModelTest extends ModelTestAbstract
|
|||||||
// Assert
|
// Assert
|
||||||
$this->assertSame($project->client_id, $clientId);
|
$this->assertSame($project->client_id, $clientId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_has_many_tags_via_json_relation(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$tag1 = Tag::factory()->create();
|
||||||
|
$tag2 = Tag::factory()->create();
|
||||||
|
$timeEntry = TimeEntry::factory()->create([
|
||||||
|
'tags' => [$tag1->getKey(), $tag2->getKey()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$timeEntry->refresh();
|
||||||
|
$tags = $timeEntry->tagsRelation;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertCount(2, $tags);
|
||||||
|
$this->assertTrue($tags->contains($tag1));
|
||||||
|
$this->assertTrue($tags->contains($tag2));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_has_many_tags_via_json_relation_eager_loaded(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$tag1 = Tag::factory()->create();
|
||||||
|
$tag2 = Tag::factory()->create();
|
||||||
|
$timeEntry1 = TimeEntry::factory()->create([
|
||||||
|
'tags' => [$tag1->getKey(), $tag2->getKey()],
|
||||||
|
'created_at' => Carbon::now()->subDay(),
|
||||||
|
]);
|
||||||
|
$timeEntry2 = TimeEntry::factory()->create([
|
||||||
|
'tags' => [$tag1->getKey()],
|
||||||
|
'created_at' => Carbon::now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$timeEntries = TimeEntry::with('tagsRelation')->orderBy('created_at', 'desc')->get();
|
||||||
|
$tags1 = $timeEntries->get(0)->tagsRelation;
|
||||||
|
$tags2 = $timeEntries->get(1)->tagsRelation;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertCount(2, $tags1);
|
||||||
|
$this->assertTrue($tags1->contains($tag1));
|
||||||
|
$this->assertTrue($tags1->contains($tag2));
|
||||||
|
$this->assertCount(1, $tags2);
|
||||||
|
$this->assertTrue($tags2->contains($tag1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user