mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-06 20:37:32 +00:00
Merge branch 'main' of github.com:Timetracking-Project/timetracking into feature/add_time_overview
# Conflicts: # docker-compose.yml
This commit is contained in:
@@ -62,6 +62,7 @@ PUSHER_PORT=443
|
||||
PUSHER_SCHEME=https
|
||||
PUSHER_APP_CLUSTER=mt1
|
||||
|
||||
VITE_HOST_NAME=vite.solidtime.test
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
|
||||
VITE_PUSHER_HOST="${PUSHER_HOST}"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
target-branch: "develop"
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
target-branch: "develop"
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
extensions: intl, zip
|
||||
- run: composer install
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
- run: npm ci
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
- run: npm ci
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
- run: npm ci
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
- name: "Run composer install"
|
||||
run: composer install -n --prefer-dist
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
@@ -50,5 +50,5 @@ jobs:
|
||||
php artisan key:generate
|
||||
php artisan passport:keys
|
||||
|
||||
- name: "Run PHPUnit"
|
||||
run: composer test
|
||||
- name: "Run PHPUnit in parallel"
|
||||
run: composer ptest
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
@@ -24,6 +25,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
|
||||
'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'],
|
||||
'timezone' => ['required', 'timezone:all'],
|
||||
'week_start' => ['required', Rule::enum(Weekday::class)],
|
||||
])->validateWithBag('updateProfileInformation');
|
||||
|
||||
if (isset($input['photo'])) {
|
||||
@@ -38,6 +40,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'timezone' => $input['timezone'],
|
||||
'week_start' => $input['week_start'],
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
*/
|
||||
public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
|
||||
{
|
||||
Gate::forUser($owner)->authorize('addTeamMember', $organization);
|
||||
Gate::forUser($owner)->authorize('addTeamMember', $organization); // TODO: refactor after owner refactoring
|
||||
|
||||
$this->validate($organization, $email, $role);
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
enum Weekday: string
|
||||
{
|
||||
case Monday = 'monday';
|
||||
case Tuesday = 'tuesday';
|
||||
case Wednesday = 'wednesday';
|
||||
case Thursday = 'thursday';
|
||||
case Friday = 'friday';
|
||||
case Saturday = 'saturday';
|
||||
case Sunday = 'sunday';
|
||||
|
||||
public function carbonWeekDay(): int
|
||||
{
|
||||
return match ($this) {
|
||||
Weekday::Monday => Carbon::MONDAY,
|
||||
Weekday::Tuesday => Carbon::TUESDAY,
|
||||
Weekday::Wednesday => Carbon::WEDNESDAY,
|
||||
Weekday::Thursday => Carbon::THURSDAY,
|
||||
Weekday::Friday => Carbon::FRIDAY,
|
||||
Weekday::Saturday => Carbon::SATURDAY,
|
||||
Weekday::Sunday => Carbon::SUNDAY,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function toSelectArray(): array
|
||||
{
|
||||
return [
|
||||
Weekday::Monday->value => __('enum.weekday.'.Weekday::Monday->value),
|
||||
Weekday::Tuesday->value => __('enum.weekday.'.Weekday::Tuesday->value),
|
||||
Weekday::Wednesday->value => __('enum.weekday.'.Weekday::Wednesday->value),
|
||||
Weekday::Thursday->value => __('enum.weekday.'.Weekday::Thursday->value),
|
||||
Weekday::Friday->value => __('enum.weekday.'.Weekday::Friday->value),
|
||||
Weekday::Saturday->value => __('enum.weekday.'.Weekday::Saturday->value),
|
||||
Weekday::Sunday->value => __('enum.weekday.'.Weekday::Sunday->value),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,7 @@ class ProjectController extends Controller
|
||||
$this->checkPermission($organization, 'projects:update', $project);
|
||||
$project->name = $request->input('name');
|
||||
$project->color = $request->input('color');
|
||||
$project->client_id = $request->input('project_id');
|
||||
$project->save();
|
||||
|
||||
return new ProjectResource($project);
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Requests\V1\Task\TaskIndexRequest;
|
||||
use App\Http\Requests\V1\Task\TaskStoreRequest;
|
||||
use App\Http\Requests\V1\Task\TaskUpdateRequest;
|
||||
use App\Http\Resources\V1\Task\TaskCollection;
|
||||
use App\Http\Resources\V1\Task\TaskResource;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Task;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class TaskController extends Controller
|
||||
{
|
||||
protected function checkPermission(Organization $organization, string $permission, ?Task $task = null): void
|
||||
{
|
||||
parent::checkPermission($organization, $permission);
|
||||
if ($task !== null && $task->organization_id !== $organization->id) {
|
||||
throw new AuthorizationException('Task does not belong to organization');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tasks
|
||||
*
|
||||
* @return TaskCollection<TaskResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getTasks
|
||||
*/
|
||||
public function index(Organization $organization, TaskIndexRequest $request): TaskCollection
|
||||
{
|
||||
$this->checkPermission($organization, 'tasks:view');
|
||||
|
||||
$projectId = $request->input('project_id');
|
||||
|
||||
$query = Task::query()
|
||||
->whereBelongsTo($organization, 'organization');
|
||||
|
||||
if ($projectId !== null) {
|
||||
$query->where('project_id', '=', $projectId);
|
||||
}
|
||||
|
||||
$tasks = $query->paginate();
|
||||
|
||||
return new TaskCollection($tasks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create task
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId createTask
|
||||
*/
|
||||
public function store(Organization $organization, TaskStoreRequest $request): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'tasks:create');
|
||||
$task = new Task();
|
||||
$task->name = $request->input('name');
|
||||
$task->project_id = $request->input('project_id');
|
||||
$task->organization()->associate($organization);
|
||||
$task->save();
|
||||
|
||||
return new TaskResource($task);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update task
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId updateTask
|
||||
*/
|
||||
public function update(Organization $organization, Task $task, TaskUpdateRequest $request): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'tasks:update', $task);
|
||||
$task->name = $request->input('name');
|
||||
$task->save();
|
||||
|
||||
return new TaskResource($task);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete task
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId deleteTask
|
||||
*/
|
||||
public function destroy(Organization $organization, Task $task): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'tasks:delete', $task);
|
||||
|
||||
$task->delete();
|
||||
|
||||
return response()
|
||||
->json(null, 204);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Task;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
*/
|
||||
class TaskIndexRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'project_id' => [
|
||||
'uuid',
|
||||
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Task;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
*/
|
||||
class TaskStoreRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
// TODO: unique
|
||||
'required',
|
||||
'string',
|
||||
'min:1',
|
||||
'max:255',
|
||||
],
|
||||
'project_id' => [
|
||||
'required',
|
||||
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Task;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
*/
|
||||
class TaskUpdateRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
// TODO: unique
|
||||
'required',
|
||||
'string',
|
||||
'min:1',
|
||||
'max:255',
|
||||
],
|
||||
'project_id' => [
|
||||
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Task;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class TaskCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $collects = TaskResource::class;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Task;
|
||||
|
||||
use App\Http\Resources\V1\BaseResource;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* @property Task $resource
|
||||
*/
|
||||
class TaskResource extends BaseResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, string|bool|int|null>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** @var string $id ID */
|
||||
'id' => $this->resource->id,
|
||||
/** @var string $name Name */
|
||||
'name' => $this->resource->name,
|
||||
/** @var string $project_id ID of the project */
|
||||
'project_id' => $this->resource->project_id,
|
||||
/** @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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,15 @@ 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 $project_id
|
||||
* @property string $organization_id
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property-read Project $project
|
||||
* @property-read Organization $organization
|
||||
*
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use Database\Factories\UserFactory;
|
||||
use Filament\Panel;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -27,6 +28,7 @@ use Laravel\Passport\HasApiTokens;
|
||||
* @property string|null $password
|
||||
* @property string $timezone
|
||||
* @property bool $is_placeholder
|
||||
* @property Weekday $week_start
|
||||
* @property Collection<Organization> $organizations
|
||||
* @property Collection<TimeEntry> $timeEntries
|
||||
*
|
||||
@@ -79,6 +81,16 @@ class User extends Authenticatable
|
||||
'email_verified_at' => 'datetime',
|
||||
'is_admin' => 'boolean',
|
||||
'is_placeholder' => 'boolean',
|
||||
'week_start' => Weekday::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* The model's default values for attributes.
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
protected $attributes = [
|
||||
'week_start' => Weekday::Monday,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Actions\Jetstream\DeleteUser;
|
||||
use App\Actions\Jetstream\InviteOrganizationMember;
|
||||
use App\Actions\Jetstream\RemoveOrganizationMember;
|
||||
use App\Actions\Jetstream\UpdateOrganization;
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Service\TimezoneService;
|
||||
@@ -58,6 +59,10 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'projects:create',
|
||||
'projects:update',
|
||||
'projects:delete',
|
||||
'tasks:view',
|
||||
'tasks:create',
|
||||
'tasks:update',
|
||||
'tasks:delete',
|
||||
'time-entries:view:all',
|
||||
'time-entries:create:all',
|
||||
'time-entries:update:all',
|
||||
@@ -86,6 +91,10 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'projects:create',
|
||||
'projects:update',
|
||||
'projects:delete',
|
||||
'tasks:view',
|
||||
'tasks:create',
|
||||
'tasks:update',
|
||||
'tasks:delete',
|
||||
'time-entries:view:all',
|
||||
'time-entries:create:all',
|
||||
'time-entries:update:all',
|
||||
@@ -105,6 +114,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
Jetstream::role('employee', 'Employee', [
|
||||
'projects:view',
|
||||
'tags:view',
|
||||
'tasks:view',
|
||||
'time-entries:view:own',
|
||||
'time-entries:create:own',
|
||||
'time-entries:update:own',
|
||||
@@ -120,6 +130,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
function (Request $request, array $data) {
|
||||
return array_merge($data, [
|
||||
'timezones' => $this->app->get(TimezoneService::class)->getSelectOptions(),
|
||||
'weekdays' => Weekday::toSelectArray(),
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -4,40 +4,92 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonTimeZone;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DashboardService
|
||||
{
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function lastDays(int $days, CarbonTimeZone $timeZone): array
|
||||
private TimezoneService $timezoneService;
|
||||
|
||||
public function __construct(TimezoneService $timezoneService)
|
||||
{
|
||||
$result = [];
|
||||
$date = Carbon::now($timeZone);
|
||||
$this->timezoneService = $timezoneService;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, string>
|
||||
*/
|
||||
private function lastDays(int $days, CarbonTimeZone $timeZone): Collection
|
||||
{
|
||||
$result = new Collection();
|
||||
$date = Carbon::now($timeZone)->subDays($days);
|
||||
for ($i = 0; $i < $days; $i++) {
|
||||
$result[] = $date->format('Y-m-d');
|
||||
$date = $date->subDay();
|
||||
$date->addDay();
|
||||
$result->push($date->format('Y-m-d'));
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, string>
|
||||
*/
|
||||
private function daysOfThisWeek(CarbonTimeZone $timeZone, Weekday $weekday): Collection
|
||||
{
|
||||
$result = new Collection();
|
||||
$date = Carbon::now($timeZone);
|
||||
$start = $date->startOfWeek($weekday->carbonWeekDay());
|
||||
for ($i = 0; $i < 7; $i++) {
|
||||
$result->push($start->format('Y-m-d'));
|
||||
$start->addDay();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, string> $possibleDates
|
||||
* @param Builder<TimeEntry> $builder
|
||||
* @return Builder<TimeEntry>
|
||||
*/
|
||||
private function constrainDateByPossibleDates(Builder $builder, Collection $possibleDates, CarbonTimeZone $timeZone): Builder
|
||||
{
|
||||
$value1 = Carbon::createFromFormat('Y-m-d', $possibleDates->first(), $timeZone);
|
||||
$value2 = Carbon::createFromFormat('Y-m-d', $possibleDates->last(), $timeZone);
|
||||
if ($value2 === false || $value1 === false) {
|
||||
throw new \RuntimeException('Provided date is not valid');
|
||||
}
|
||||
if ($value1->gt($value2)) {
|
||||
$last = $value1;
|
||||
$first = $value2;
|
||||
} else {
|
||||
$last = $value2;
|
||||
$first = $value1;
|
||||
}
|
||||
|
||||
return $builder->whereBetween('start', [
|
||||
$first->startOfDay()->utc(),
|
||||
$last->endOfDay()->utc(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the daily tracked hours for the user
|
||||
* First value: date
|
||||
* Second value: seconds
|
||||
*
|
||||
* @return array<int, array{0: string, 1: int}>
|
||||
* @return array<int, array{date: string, duration: int}>
|
||||
*/
|
||||
public function getDailyTrackedHours(User $user, int $days): array
|
||||
{
|
||||
$timezone = new CarbonTimeZone($user->timezone);
|
||||
$timezoneShift = $timezone->getOffset(new \DateTime('now', new \DateTimeZone('UTC')));
|
||||
$timezone = $this->timezoneService->getTimezoneFromUser($user);
|
||||
$timezoneShift = $this->timezoneService->getShiftFromUtc($timezone);
|
||||
|
||||
if ($timezoneShift > 0) {
|
||||
$dateWithTimeZone = 'start + INTERVAL \''.$timezoneShift.' second\'';
|
||||
@@ -47,60 +99,67 @@ class DashboardService
|
||||
$dateWithTimeZone = 'start';
|
||||
}
|
||||
|
||||
$resultDb = TimeEntry::query()
|
||||
->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as value'))
|
||||
$possibleDays = $this->lastDays($days, $timezone);
|
||||
|
||||
$query = TimeEntry::query()
|
||||
->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
|
||||
->where('user_id', '=', $user->id)
|
||||
->groupBy(DB::raw('DATE('.$dateWithTimeZone.')'))
|
||||
->orderBy('date')
|
||||
->get()
|
||||
->pluck('value', 'date');
|
||||
->orderBy('date');
|
||||
|
||||
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
|
||||
$resultDb = $query->get()
|
||||
->pluck('aggregate', 'date');
|
||||
|
||||
$result = [];
|
||||
$lastDays = $this->lastDays($days, $timezone);
|
||||
|
||||
foreach ($lastDays as $day) {
|
||||
$result[] = [$day, (int) ($resultDb->get($day) ?? 0)];
|
||||
foreach ($possibleDays as $possibleDay) {
|
||||
$result[] = [
|
||||
'date' => $possibleDay,
|
||||
'duration' => (int) ($resultDb->get($possibleDay) ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistics for the current week starting at Monday / Sunday
|
||||
* Statistics for the current week starting at weekday of users preference
|
||||
*
|
||||
* @return array<int, array{date: string, duration: int}>
|
||||
*/
|
||||
public function getWeeklyHistory(User $user): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'date' => '2024-02-26',
|
||||
'duration' => 3600,
|
||||
],
|
||||
[
|
||||
'date' => '2024-02-27',
|
||||
'duration' => 2000,
|
||||
],
|
||||
[
|
||||
'date' => '2024-02-28',
|
||||
'duration' => 4000,
|
||||
],
|
||||
[
|
||||
'date' => '2024-02-29',
|
||||
'duration' => 3000,
|
||||
],
|
||||
[
|
||||
'date' => '2024-03-01',
|
||||
'duration' => 5000,
|
||||
],
|
||||
[
|
||||
'date' => '2024-03-02',
|
||||
'duration' => 3000,
|
||||
],
|
||||
[
|
||||
'date' => '2024-03-03',
|
||||
'duration' => 2000,
|
||||
],
|
||||
];
|
||||
$timezone = $this->timezoneService->getTimezoneFromUser($user);
|
||||
$timezoneShift = $this->timezoneService->getShiftFromUtc($timezone);
|
||||
if ($timezoneShift > 0) {
|
||||
$dateWithTimeZone = 'start + INTERVAL \''.$timezoneShift.' second\'';
|
||||
} elseif ($timezoneShift < 0) {
|
||||
$dateWithTimeZone = 'start - INTERVAL \''.abs($timezoneShift).' second\'';
|
||||
} else {
|
||||
$dateWithTimeZone = 'start';
|
||||
}
|
||||
$possibleDays = $this->daysOfThisWeek($timezone, $user->week_start);
|
||||
|
||||
$query = TimeEntry::query()
|
||||
->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
|
||||
->where('user_id', '=', $user->id)
|
||||
->groupBy(DB::raw('DATE('.$dateWithTimeZone.')'))
|
||||
->orderBy('date');
|
||||
|
||||
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
|
||||
$resultDb = $query->get()
|
||||
->pluck('aggregate', 'date');
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($possibleDays as $possibleDay) {
|
||||
$result[] = [
|
||||
'date' => $possibleDay,
|
||||
'duration' => (int) ($resultDb->get($possibleDay) ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonTimeZone;
|
||||
use DateTime;
|
||||
use DateTimeZone;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TimezoneService
|
||||
{
|
||||
@@ -19,6 +22,20 @@ class TimezoneService
|
||||
return $tzlist;
|
||||
}
|
||||
|
||||
public function getTimezoneFromUser(User $user): CarbonTimeZone
|
||||
{
|
||||
try {
|
||||
return new CarbonTimeZone($user->timezone);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('User has a invalid timezone', [
|
||||
'user_id' => $user->getKey(),
|
||||
'timezone' => $user->timezone,
|
||||
]);
|
||||
|
||||
return new CarbonTimeZone('UTC');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
@@ -37,4 +54,11 @@ class TimezoneService
|
||||
{
|
||||
return in_array($timezone, $this->getTimezones(), true);
|
||||
}
|
||||
|
||||
public function getShiftFromUtc(CarbonTimeZone $timeZone): int
|
||||
{
|
||||
$timezoneShift = $timeZone->getOffset(new DateTime('now', new DateTimeZone('UTC')));
|
||||
|
||||
return $timezoneShift;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ class OrganizationFactory extends Factory
|
||||
public function withOwner(?User $owner = null): self
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'user_id' => $owner === null ? User::factory() : $owner,
|
||||
'user_id' => $owner === null ? User::factory() : $owner->getKey(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
@@ -27,12 +28,14 @@ class UserFactory extends Factory
|
||||
'email_verified_at' => now(),
|
||||
'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
|
||||
'two_factor_secret' => null,
|
||||
'two_factor_confirmed_at' => null,
|
||||
'two_factor_recovery_codes' => null,
|
||||
'remember_token' => Str::random(10),
|
||||
'profile_photo_path' => null,
|
||||
'current_team_id' => null,
|
||||
'is_placeholder' => false,
|
||||
'timezone' => 'Europe/Vienna',
|
||||
'week_start' => Weekday::Monday,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,15 @@ return new class extends Migration
|
||||
$table->foreignUuid('current_team_id')->nullable();
|
||||
$table->string('profile_photo_path', 2048)->nullable();
|
||||
$table->string('timezone');
|
||||
$table->enum('week_start', [
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
'sunday',
|
||||
]);
|
||||
$table->timestamps();
|
||||
|
||||
$table->uniqueIndex('email')
|
||||
|
||||
+5
-3
@@ -1,7 +1,7 @@
|
||||
services:
|
||||
laravel.test:
|
||||
build:
|
||||
context: ./vendor/laravel/sail/runtimes/8.3
|
||||
context: ./docker/8.3
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
WWWGROUP: '${WWWGROUP}'
|
||||
@@ -13,9 +13,11 @@ services:
|
||||
- "traefik.http.routers.solidtime.entrypoints=web"
|
||||
- "traefik.http.services.solidtime.loadbalancer.server.port=80"
|
||||
- "traefik.http.routers.solidtime.service=solidtime"
|
||||
- "traefik.http.services.solidtime-https.loadbalancer.server.port=80"
|
||||
- "traefik.http.routers.solidtime-https.rule=Host(`${NGINX_HOST_NAME}`)"
|
||||
- "traefik.http.routers.solidtime-https.entrypoints=websecure"
|
||||
- "traefik.http.routers.solidtime-https.tls=true"
|
||||
- "traefik.http.routers.solidtime-https.service=solidtime-https"
|
||||
# vite
|
||||
- "traefik.http.services.solidtime-vite.loadbalancer.server.port=5173"
|
||||
# http
|
||||
@@ -49,7 +51,7 @@ services:
|
||||
POSTGRES_PASSWORD: '${DB_PASSWORD:-secret}'
|
||||
volumes:
|
||||
- 'sail-pgsql:/var/lib/postgresql/data'
|
||||
- './vendor/laravel/sail/database/pgsql/create-testing-database.sql:/docker-entrypoint-initdb.d/10-create-testing-database.sql'
|
||||
- './docker/pgsql/create-testing-database.sql:/docker-entrypoint-initdb.d/10-create-testing-database.sql'
|
||||
networks:
|
||||
- sail
|
||||
healthcheck:
|
||||
@@ -72,7 +74,7 @@ services:
|
||||
POSTGRES_PASSWORD: '${DB_PASSWORD:-secret}'
|
||||
volumes:
|
||||
- 'sail-pgsql-test:/var/lib/postgresql/data'
|
||||
- './vendor/laravel/sail/database/pgsql/create-testing-database.sql:/docker-entrypoint-initdb.d/10-create-testing-database.sql'
|
||||
- './docker/pgsql/create-testing-database.sql:/docker-entrypoint-initdb.d/10-create-testing-database.sql'
|
||||
networks:
|
||||
- sail
|
||||
healthcheck:
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
FROM ubuntu:22.04
|
||||
|
||||
LABEL maintainer="Taylor Otwell"
|
||||
|
||||
ARG WWWGROUP
|
||||
ARG NODE_VERSION=20
|
||||
ARG POSTGRES_VERSION=15
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
||||
ENV DEBIAN_FRONTEND noninteractive
|
||||
ENV TZ=UTC
|
||||
ENV SUPERVISOR_PHP_COMMAND="/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=0.0.0.0 --port=80"
|
||||
ENV SUPERVISOR_PHP_USER="sail"
|
||||
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
RUN apt-get update \
|
||||
&& mkdir -p /etc/apt/keyrings \
|
||||
&& apt-get install -y gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin libpng-dev python2 dnsutils librsvg2-bin fswatch ffmpeg \
|
||||
&& curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x14aa40ec0831756756d7f66c4f4ea0aae5267a6c' | gpg --dearmor | tee /etc/apt/keyrings/ppa_ondrej_php.gpg > /dev/null \
|
||||
&& echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu jammy main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y php8.3-cli php8.3-dev \
|
||||
php8.3-pgsql php8.3-sqlite3 php8.3-gd \
|
||||
php8.3-curl \
|
||||
php8.3-imap php8.3-mysql php8.3-mbstring \
|
||||
php8.3-xml php8.3-zip php8.3-bcmath php8.3-soap \
|
||||
php8.3-intl php8.3-readline \
|
||||
php8.3-ldap \
|
||||
php8.3-msgpack php8.3-igbinary php8.3-redis php8.3-swoole \
|
||||
php8.3-memcached php8.3-pcov php8.3-imagick php8.3-xdebug \
|
||||
&& curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \
|
||||
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
|
||||
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_VERSION.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y nodejs \
|
||||
&& npm install -g npm \
|
||||
&& npm install -g pnpm \
|
||||
&& npm install -g bun \
|
||||
&& curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | tee /etc/apt/keyrings/yarn.gpg >/dev/null \
|
||||
&& echo "deb [signed-by=/etc/apt/keyrings/yarn.gpg] https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list \
|
||||
&& curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg >/dev/null \
|
||||
&& echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt jammy-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y yarn \
|
||||
&& apt-get install -y mysql-client \
|
||||
&& apt-get install -y postgresql-client-$POSTGRES_VERSION \
|
||||
&& apt-get -y autoremove \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.3
|
||||
|
||||
RUN groupadd --force -g $WWWGROUP sail
|
||||
RUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail
|
||||
|
||||
COPY start-container /usr/local/bin/start-container
|
||||
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
COPY php.ini /etc/php/8.3/cli/conf.d/99-sail.ini
|
||||
RUN chmod +x /usr/local/bin/start-container
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["start-container"]
|
||||
@@ -0,0 +1,8 @@
|
||||
[PHP]
|
||||
post_max_size = 100M
|
||||
upload_max_filesize = 100M
|
||||
variables_order = EGPCS
|
||||
pcov.directory = .
|
||||
|
||||
[opcache]
|
||||
opcache.enable_cli=1
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [ "$SUPERVISOR_PHP_USER" != "root" ] && [ "$SUPERVISOR_PHP_USER" != "sail" ]; then
|
||||
echo "You should set SUPERVISOR_PHP_USER to either 'sail' or 'root'."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -z "$WWWUSER" ]; then
|
||||
usermod -u $WWWUSER sail
|
||||
fi
|
||||
|
||||
if [ ! -d /.composer ]; then
|
||||
mkdir /.composer
|
||||
fi
|
||||
|
||||
chmod -R ugo+rw /.composer
|
||||
|
||||
if [ $# -gt 0 ]; then
|
||||
if [ "$SUPERVISOR_PHP_USER" = "root" ]; then
|
||||
exec "$@"
|
||||
else
|
||||
exec gosu $WWWUSER "$@"
|
||||
fi
|
||||
else
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
||||
fi
|
||||
@@ -0,0 +1,14 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=root
|
||||
logfile=/var/log/supervisor/supervisord.log
|
||||
pidfile=/var/run/supervisord.pid
|
||||
|
||||
[program:php]
|
||||
command=%(ENV_SUPERVISOR_PHP_COMMAND)s
|
||||
user=%(ENV_SUPERVISOR_PHP_USER)s
|
||||
environment=LARAVEL_SAIL="1"
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
@@ -0,0 +1,2 @@
|
||||
SELECT 'CREATE DATABASE testing'
|
||||
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'testing')\gexec
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Weekday;
|
||||
|
||||
return [
|
||||
|
||||
'weekday' => [
|
||||
Weekday::Monday->value => 'Monday',
|
||||
Weekday::Tuesday->value => 'Tuesday',
|
||||
Weekday::Wednesday->value => 'Wednesday',
|
||||
Weekday::Thursday->value => 'Thursday',
|
||||
Weekday::Friday->value => 'Friday',
|
||||
Weekday::Saturday->value => 'Saturday',
|
||||
Weekday::Sunday->value => 'Sunday',
|
||||
],
|
||||
|
||||
];
|
||||
@@ -8,3 +8,5 @@ parameters:
|
||||
|
||||
# Level 9 is the highest level
|
||||
level: 7
|
||||
|
||||
checkOctaneCompatibility: true
|
||||
|
||||
@@ -21,6 +21,7 @@ const form = useForm({
|
||||
email: props.user.email,
|
||||
photo: null as File | null,
|
||||
timezone: props.user.timezone,
|
||||
week_start: props.user.week_start,
|
||||
});
|
||||
|
||||
const verificationLinkSent = ref<boolean | null>(null);
|
||||
@@ -211,14 +212,36 @@ const page = usePage<{
|
||||
class="mt-1 block w-full border-input-border bg-input-background text-white focus:border-input-border-active rounded-md shadow-sm">
|
||||
<option value="" disabled>Select a Timezone</option>
|
||||
<option
|
||||
v-for="timezone in $page.props.timezones"
|
||||
:key="timezone"
|
||||
:value="timezone">
|
||||
{{ timezone }}
|
||||
v-for="(timezoneTranslated, timezoneKey) in $page.props
|
||||
.timezones"
|
||||
:key="timezoneKey"
|
||||
:value="timezoneKey">
|
||||
{{ timezoneTranslated }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.timezone" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Week start -->
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="week_start" value="Start of the week" />
|
||||
<select
|
||||
name="week_start"
|
||||
id="week_start"
|
||||
v-model="form.week_start"
|
||||
required
|
||||
class="mt-1 block w-full border-input-border bg-input-background text-white focus:border-input-border-active rounded-md shadow-sm">
|
||||
<option value="" disabled>Select a week day</option>
|
||||
<option
|
||||
v-for="(weekdayTranslated, weekdayKey) in $page.props
|
||||
.weekdays"
|
||||
:key="weekdayKey"
|
||||
:value="weekdayKey">
|
||||
{{ weekdayTranslated }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.week_start" class="mt-2" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
|
||||
@@ -90,6 +90,7 @@ export interface User {
|
||||
two_factor_recovery_codes?: string | null;
|
||||
two_factor_confirmed_at: string | null;
|
||||
timezone: string;
|
||||
week_start: string;
|
||||
// mutators
|
||||
profile_photo_url: string;
|
||||
// relations
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Http\Controllers\Api\V1\MemberController;
|
||||
use App\Http\Controllers\Api\V1\OrganizationController;
|
||||
use App\Http\Controllers\Api\V1\ProjectController;
|
||||
use App\Http\Controllers\Api\V1\TagController;
|
||||
use App\Http\Controllers\Api\V1\TaskController;
|
||||
use App\Http\Controllers\Api\V1\TimeEntryController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
@@ -69,6 +70,14 @@ Route::middleware('auth:api')->prefix('v1')->name('v1.')->group(static function
|
||||
Route::delete('/organizations/{organization}/clients/{client}', [ClientController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// Task routes
|
||||
Route::name('tasks.')->group(static function () {
|
||||
Route::get('/organizations/{organization}/tasks', [TaskController::class, 'index'])->name('index');
|
||||
Route::post('/organizations/{organization}/tasks', [TaskController::class, 'store'])->name('store');
|
||||
Route::put('/organizations/{organization}/tasks/{task}', [TaskController::class, 'update'])->name('update');
|
||||
Route::delete('/organizations/{organization}/tasks/{task}', [TaskController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// Import routes
|
||||
Route::name('import.')->group(static function () {
|
||||
Route::post('/organizations/{organization}/import', [ImportController::class, 'import'])->name('import');
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\User;
|
||||
use App\Service\TimezoneService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -13,6 +14,19 @@ class ProfileInformationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_show_profile_information_succeeds(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = User::factory()->withPersonalOrganization()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
// Act
|
||||
$response = $this->get('/user/profile');
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
}
|
||||
|
||||
public function test_profile_information_can_be_updated(): void
|
||||
{
|
||||
// Arrange
|
||||
@@ -24,10 +38,15 @@ class ProfileInformationTest extends TestCase
|
||||
'name' => 'Test Name',
|
||||
'email' => 'test@example.com',
|
||||
'timezone' => app(TimezoneService::class)->getTimezones()[0],
|
||||
'week_start' => Weekday::Sunday->value,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$this->assertEquals('Test Name', $user->fresh()->name);
|
||||
$this->assertEquals('test@example.com', $user->fresh()->email);
|
||||
$response->assertValid(errorBag: 'updateProfileInformation');
|
||||
$user = $user->fresh();
|
||||
$this->assertEquals('Test Name', $user->name);
|
||||
$this->assertEquals('test@example.com', $user->email);
|
||||
$this->assertEquals(app(TimezoneService::class)->getTimezones()[0], $user->timezone);
|
||||
$this->assertEquals(Weekday::Sunday, $user->week_start);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,6 @@ class RemoveTeamMemberTest extends TestCase
|
||||
|
||||
$response = $this->delete('/teams/'.$user->currentTeam->id.'/members/'.$user->id);
|
||||
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,16 @@ class ApiEndpointTestAbstract extends TestCase
|
||||
* @param array<string> $permissions
|
||||
* @return object{user: User, organization: Organization}
|
||||
*/
|
||||
protected function createUserWithPermission(array $permissions): object
|
||||
protected function createUserWithPermission(array $permissions, bool $isOwner = false): object
|
||||
{
|
||||
Jetstream::role('custom-test', 'Custom Test', $permissions)->description('Role custom for testing');
|
||||
$organization = Organization::factory()->create();
|
||||
Jetstream::role('custom-test', 'Custom Test', $permissions)
|
||||
->description('Role custom for testing');
|
||||
$user = User::factory()->create();
|
||||
if ($isOwner) {
|
||||
$organization = Organization::factory()->withOwner($user)->create();
|
||||
} else {
|
||||
$organization = Organization::factory()->create();
|
||||
}
|
||||
$organization->users()->attach($user, [
|
||||
'role' => 'custom-test',
|
||||
]);
|
||||
|
||||
@@ -23,7 +23,7 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
|
||||
$response = $this->getJson(route('api.v1.clients.index', [$data->organization->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_index_endpoint_returns_list_of_all_clients_of_organization_ordered_by_created_at_desc_per_default(): void
|
||||
@@ -66,7 +66,7 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_store_endpoint_creates_new_client(): void
|
||||
@@ -106,7 +106,7 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_update_endpoint_fails_if_user_is_not_part_of_client_organization(): void
|
||||
@@ -126,7 +126,7 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
$this->assertDatabaseHas(Client::class, [
|
||||
'id' => $client->getKey(),
|
||||
'name' => $client->name,
|
||||
@@ -173,7 +173,7 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
|
||||
$response = $this->deleteJson(route('api.v1.clients.destroy', [$data->organization->getKey(), $client->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_fails_if_user_is_not_part_of_client_organization(): void
|
||||
@@ -190,7 +190,7 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
|
||||
$response = $this->deleteJson(route('api.v1.clients.destroy', [$data->organization->getKey(), $client->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
$this->assertDatabaseHas(Client::class, [
|
||||
'id' => $client->getKey(),
|
||||
'name' => $client->name,
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Tests\Unit\Endpoint\Api\V1;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Service\Import\Importers\ImportException;
|
||||
use App\Service\Import\Importers\ReportDto;
|
||||
use App\Service\Import\ImportService;
|
||||
use Laravel\Passport\Passport;
|
||||
@@ -28,7 +29,35 @@ class ImportEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_import_return_error_message_if_import_fails(): void
|
||||
{
|
||||
$user = $this->createUserWithPermission([
|
||||
'import',
|
||||
]);
|
||||
$this->mock(ImportService::class, function (MockInterface $mock) use (&$user): void {
|
||||
$mock->shouldReceive('import')
|
||||
->withArgs(function (Organization $organization, string $importerType, string $data) use (&$user): bool {
|
||||
return $organization->is($user->organization) && $importerType === 'toggl_time_entries' && $data === 'some data';
|
||||
})
|
||||
->andThrow(new ImportException('This is a test error!'))
|
||||
->once();
|
||||
});
|
||||
Passport::actingAs($user->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.import.import', ['organization' => $user->organization->id]), [
|
||||
'type' => 'toggl_time_entries',
|
||||
'data' => 'some data',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(400);
|
||||
$response->assertExactJson([
|
||||
'message' => 'This is a test error!',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_import_calls_import_service_if_user_has_permission(): void
|
||||
|
||||
+26
-3
@@ -8,7 +8,7 @@ use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Laravel\Passport\Passport;
|
||||
|
||||
class UserEndpointTest extends ApiEndpointTestAbstract
|
||||
class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
{
|
||||
public function test_index_returns_members_of_organization(): void
|
||||
{
|
||||
@@ -25,6 +25,29 @@ class UserEndpointTest extends ApiEndpointTestAbstract
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_invite_placeholder_succeeds_if_data_is_valid(): void
|
||||
{
|
||||
$data = $this->createUserWithPermission([
|
||||
'users:invite-placeholder',
|
||||
], true);
|
||||
$user = User::factory()->create([
|
||||
'is_placeholder' => true,
|
||||
]);
|
||||
$data->organization->users()->attach($user, [
|
||||
'role' => 'placeholder',
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.users.invite-placeholder', [
|
||||
'organization' => $data->organization->id,
|
||||
'user' => $user->id,
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(204);
|
||||
}
|
||||
|
||||
public function test_invite_placeholder_fails_if_user_does_not_have_permission(): void
|
||||
{
|
||||
// Arrange
|
||||
@@ -40,7 +63,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
|
||||
$response = $this->postJson(route('api.v1.users.invite-placeholder', ['organization' => $data->organization->id, 'user' => $user->id]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_invite_placeholder_fails_if_user_is_not_part_of_organization(): void
|
||||
@@ -60,7 +83,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
|
||||
$response = $this->postJson(route('api.v1.users.invite-placeholder', ['organization' => $data->organization->id, 'user' => $user->id]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_invite_placeholder_returns_400_if_user_is_not_placeholder(): void
|
||||
@@ -20,7 +20,7 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract
|
||||
$response = $this->getJson(route('api.v1.organizations.show', [$data->organization->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_show_endpoint_returns_organization(): void
|
||||
@@ -53,7 +53,7 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_update_endpoint_updates_project(): void
|
||||
|
||||
@@ -24,7 +24,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
||||
$response = $this->getJson(route('api.v1.projects.index', [$data->organization->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_index_endpoint_returns_list_of_all_projects_of_organization(): void
|
||||
@@ -58,7 +58,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
||||
$response = $this->getJson(route('api.v1.projects.show', [$data->organization->getKey(), $project->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_show_endpoint_fails_if_user_has_no_permission_to_view_projects(): void
|
||||
@@ -73,7 +73,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
||||
$response = $this->getJson(route('api.v1.projects.show', [$data->organization->getKey(), $project->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_show_endpoint_returns_project(): void
|
||||
@@ -108,7 +108,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_store_endpoint_creates_new_project(): void
|
||||
@@ -180,7 +180,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_update_endpoint_fails_if_user_has_no_permission_to_update_projects(): void
|
||||
@@ -199,7 +199,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_update_endpoint_updates_project(): void
|
||||
@@ -240,7 +240,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
||||
$response = $this->deleteJson(route('api.v1.projects.destroy', [$data->organization->getKey(), $project->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_fails_if_user_has_no_permission_to_delete_projects(): void
|
||||
@@ -255,7 +255,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
||||
$response = $this->deleteJson(route('api.v1.projects.destroy', [$data->organization->getKey(), $project->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_deletes_project(): void
|
||||
|
||||
@@ -23,7 +23,7 @@ class TagEndpointTest extends ApiEndpointTestAbstract
|
||||
$response = $this->getJson(route('api.v1.tags.index', [$data->organization->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_index_endpoint_returns_list_of_all_tags_of_organization_ordered_by_created_at_desc_per_default(): void
|
||||
@@ -66,7 +66,7 @@ class TagEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_store_endpoint_creates_new_tag(): void
|
||||
@@ -106,7 +106,7 @@ class TagEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_update_endpoint_fails_if_user_is_not_part_of_tag_organization(): void
|
||||
@@ -126,7 +126,7 @@ class TagEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
$this->assertDatabaseHas(Tag::class, [
|
||||
'id' => $tag->getKey(),
|
||||
'name' => $tag->name,
|
||||
@@ -173,7 +173,7 @@ class TagEndpointTest extends ApiEndpointTestAbstract
|
||||
$response = $this->deleteJson(route('api.v1.tags.destroy', [$data->organization->getKey(), $tag->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_fails_if_user_is_not_part_of_tag_organization(): void
|
||||
@@ -190,7 +190,7 @@ class TagEndpointTest extends ApiEndpointTestAbstract
|
||||
$response = $this->deleteJson(route('api.v1.tags.destroy', [$data->organization->getKey(), $tag->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
$this->assertDatabaseHas(Tag::class, [
|
||||
'id' => $tag->getKey(),
|
||||
'name' => $tag->name,
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Endpoint\Api\V1;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Task;
|
||||
use Laravel\Passport\Passport;
|
||||
|
||||
class TaskEndpointTest extends ApiEndpointTestAbstract
|
||||
{
|
||||
public function test_index_endpoint_fails_if_user_has_no_permission_to_view_tasks(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
]);
|
||||
Task::factory()->forOrganization($data->organization)->createMany(4);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.tasks.index', [$data->organization->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_index_endpoint_returns_list_of_all_tasks_of_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'tasks:view',
|
||||
]);
|
||||
$tasks = Task::factory()->forOrganization($data->organization)->createMany(4);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.tasks.index', [$data->organization->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(4, 'data');
|
||||
}
|
||||
|
||||
public function test_index_endpoint_returns_list_of_all_tasks_of_organization_filtered_by_project(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'tasks:view',
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||
Task::factory()->forOrganization($data->organization)->createMany(4);
|
||||
Task::factory()->forOrganization($data->organization)->forProject($project)->createMany(2);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.tasks.index', [
|
||||
$data->organization->getKey(),
|
||||
'project_id' => $project->getKey(),
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(2, 'data');
|
||||
}
|
||||
|
||||
public function test_index_endpoint_validation_fails_if_project_id_does_not_belong_to_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'tasks:view',
|
||||
]);
|
||||
$otherData = $this->createUserWithPermission([
|
||||
'tasks:view',
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($otherData->organization)->create();
|
||||
Task::factory()->forOrganization($data->organization)->createMany(4);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.tasks.index', [
|
||||
$data->organization->getKey(),
|
||||
'project_id' => $project->getKey(),
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(422);
|
||||
$response->assertInvalid([
|
||||
'project_id',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_store_endpoint_fails_if_user_has_no_permission_to_create_tags()
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.tasks.store', [$data->organization->getKey()]), [
|
||||
'name' => 'Task 1',
|
||||
'project_id' => $project->getKey(),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
$this->assertDatabaseMissing(Task::class, [
|
||||
'name' => 'Task 1',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_store_endpoint_creates_new_task_if_user_has_permission_to_create_tasks()
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'tasks:create',
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.tasks.store', [$data->organization->getKey()]), [
|
||||
'name' => 'Task 1',
|
||||
'project_id' => $project->getKey(),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(201);
|
||||
$this->assertDatabaseHas(Task::class, [
|
||||
'name' => 'Task 1',
|
||||
'project_id' => $project->getKey(),
|
||||
'organization_id' => $data->organization->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_update_endpoint_fails_if_user_has_no_permission(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
]);
|
||||
$task = Task::factory()->forOrganization($data->organization)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [
|
||||
'name' => 'Updated Task',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
$this->assertDatabaseHas(Task::class, [
|
||||
'id' => $task->getKey(),
|
||||
'name' => $task->name,
|
||||
]);
|
||||
$this->assertDatabaseMissing(Task::class, [
|
||||
'id' => $task->getKey(),
|
||||
'name' => 'Updated Task',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_update_endpoint_updates_task_if_user_has_permission(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'tasks:update',
|
||||
]);
|
||||
$task = Task::factory()->forOrganization($data->organization)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [
|
||||
'name' => 'Updated Task',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200);
|
||||
$this->assertDatabaseHas(Task::class, [
|
||||
'id' => $task->getKey(),
|
||||
'name' => 'Updated Task',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_delete_endpoint_deletes_tasks_if_user_has_permission(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'tasks:delete',
|
||||
]);
|
||||
$task = Task::factory()->forOrganization($data->organization)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.tasks.destroy', [$data->organization->getKey(), $task->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(204);
|
||||
$this->assertDatabaseMissing(Task::class, [
|
||||
'id' => $task->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_delete_endpoint_fails_if_user_has_no_permission_to_delete_tasks(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
]);
|
||||
$task = Task::factory()->forOrganization($data->organization)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.tasks.destroy', [$data->organization->getKey(), $task->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
$this->assertDatabaseHas(Task::class, [
|
||||
'id' => $task->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_delete_endpoint_fails_if_task_does_not_belong_to_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'tasks:delete',
|
||||
]);
|
||||
$otherData = $this->createUserWithPermission([
|
||||
'tasks:delete',
|
||||
]);
|
||||
$task = Task::factory()->forOrganization($otherData->organization)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.tasks.destroy', [$data->organization->getKey(), $task->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
$this->assertDatabaseHas(Task::class, [
|
||||
'id' => $task->getKey(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
$response = $this->getJson(route('api.v1.time-entries.index', [$data->organization->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_index_endpoint_fails_if_user_has_no_permission_to_view_time_entries_for_others_but_wants_all_entries(): void
|
||||
@@ -41,7 +41,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
$response = $this->getJson(route('api.v1.time-entries.index', [$data->organization->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_index_endpoint_returns_time_entries_for_current_user(): void
|
||||
@@ -323,7 +323,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_store_endpoint_fails_if_user_already_has_active_time_entry_and_tries_to_start_new_one(): void
|
||||
@@ -463,7 +463,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_store_endpoint_creates_new_time_entry_for_other_user_in_organization(): void
|
||||
@@ -520,7 +520,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_update_endpoint_fails_if_user_is_not_part_of_time_entry_organization(): void
|
||||
@@ -547,7 +547,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_update_endpoint_fails_if_user_has_no_permission_to_update_time_entries_for_other_users_in_organization(): void
|
||||
@@ -575,7 +575,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_update_endpoint_updates_time_entry_for_current_user(): void
|
||||
@@ -656,7 +656,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
$response = $this->deleteJson(route('api.v1.time-entries.destroy', [$data->organization->getKey(), $timeEntry->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_fails_if_user_tries_to_delete_non_existing_time_entry(): void
|
||||
@@ -686,7 +686,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
$response = $this->deleteJson(route('api.v1.time-entries.destroy', [$data->organization->getKey(), $timeEntry->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_fails_if_user_has_no_permission_to_delete_time_entries_for_other_users_in_organization(): void
|
||||
@@ -706,7 +706,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
$response = $this->deleteJson(route('api.v1.time-entries.destroy', [$data->organization->getKey(), $timeEntry->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_deletes_own_time_entry(): void
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Endpoint\Web;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
class DashboardEndpointTest extends EndpointTestAbstract
|
||||
{
|
||||
public function test_showing_dashboard_succeeds_for_empty_user_with_no_data_entries(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = User::factory()->withPersonalOrganization()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
// Act
|
||||
$response = $this->get('/dashboard');
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Endpoint\Web;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
abstract class EndpointTestAbstract extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Service;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use App\Service\DashboardService;
|
||||
@@ -15,29 +16,115 @@ class DashboardServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected DashboardService $dashboardService;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->dashboardService = app(DashboardService::class);
|
||||
}
|
||||
|
||||
public function test_daily_tracked_hours_returns_correct_values(): void
|
||||
{
|
||||
// Arrange
|
||||
$this->travelTo(Carbon::create(2024, 1, 1, 12, 0, 0, 'UTC'));
|
||||
$this->travelTo(Carbon::create(2024, 1, 1, 12, 0, 0, 'Europe/Vienna'));
|
||||
$user = User::factory()->create([
|
||||
'timezone' => 'Europe/Vienna',
|
||||
]);
|
||||
$timeEntry = TimeEntry::factory()->forUser($user)->create([
|
||||
'start' => Carbon::create(2023, 12, 31, 0, 0, 0, 'UTC'),
|
||||
'end' => Carbon::create(2023, 12, 31, 0, 0, 40, 'UTC'),
|
||||
$timeEntry1 = TimeEntry::factory()->forUser($user)->create([
|
||||
// Note: The start time shifts in timezone Europe/Vienna to the next day
|
||||
'start' => Carbon::create(2023, 12, 30, 23, 0, 0, 'UTC'),
|
||||
'end' => Carbon::create(2023, 12, 30, 23, 0, 40, 'UTC'),
|
||||
]);
|
||||
$timeEntry2 = TimeEntry::factory()->forUser($user)->create([
|
||||
// Note: The start time NOT shifts in timezone Europe/Vienna to the next day
|
||||
'start' => Carbon::create(2023, 12, 30, 22, 59, 59, 'UTC'),
|
||||
'end' => Carbon::create(2023, 12, 30, 23, 0, 39, 'UTC'),
|
||||
]);
|
||||
|
||||
// Act
|
||||
$service = new DashboardService();
|
||||
$result = $service->getDailyTrackedHours($user, 5);
|
||||
$result = $this->dashboardService->getDailyTrackedHours($user, 5);
|
||||
|
||||
// Assert
|
||||
$this->assertSame([
|
||||
['2024-01-01', 0],
|
||||
['2023-12-31', 40],
|
||||
['2023-12-30', 0],
|
||||
['2023-12-29', 0],
|
||||
['2023-12-28', 0],
|
||||
[
|
||||
'date' => '2023-12-28',
|
||||
'duration' => 0,
|
||||
],
|
||||
[
|
||||
'date' => '2023-12-29',
|
||||
'duration' => 0,
|
||||
],
|
||||
[
|
||||
'date' => '2023-12-30',
|
||||
'duration' => 40,
|
||||
],
|
||||
[
|
||||
'date' => '2023-12-31',
|
||||
'duration' => 40,
|
||||
],
|
||||
[
|
||||
'date' => '2024-01-01',
|
||||
'duration' => 0,
|
||||
],
|
||||
], $result);
|
||||
}
|
||||
|
||||
public function test_weekly_history_returns_correct_values(): void
|
||||
{
|
||||
// Arrange
|
||||
// Note: Is a Monday
|
||||
$this->travelTo(Carbon::create(2024, 1, 1, 12, 0, 0, 'Europe/Vienna'));
|
||||
$user = User::factory()->create([
|
||||
'timezone' => 'Europe/Vienna',
|
||||
'week_start' => Weekday::Sunday,
|
||||
]);
|
||||
// Note: This is a Sunday
|
||||
$timeEntry1 = TimeEntry::factory()->forUser($user)->create([
|
||||
// Note: The start time shifts in timezone Europe/Vienna to the next day
|
||||
'start' => Carbon::create(2023, 12, 30, 23, 0, 0, 'UTC'),
|
||||
'end' => Carbon::create(2023, 12, 30, 23, 0, 40, 'UTC'),
|
||||
]);
|
||||
// Note: This is a Saturday
|
||||
$timeEntry2 = TimeEntry::factory()->forUser($user)->create([
|
||||
// Note: The start time NOT shifts in timezone Europe/Vienna to the next day
|
||||
'start' => Carbon::create(2023, 12, 30, 22, 59, 59, 'UTC'),
|
||||
'end' => Carbon::create(2023, 12, 30, 23, 0, 39, 'UTC'),
|
||||
]);
|
||||
|
||||
// Act
|
||||
$result = $this->dashboardService->getWeeklyHistory($user);
|
||||
|
||||
// Assert
|
||||
$this->assertSame([
|
||||
[
|
||||
'date' => '2023-12-31',
|
||||
'duration' => 40,
|
||||
],
|
||||
[
|
||||
'date' => '2024-01-01',
|
||||
'duration' => 0,
|
||||
],
|
||||
[
|
||||
'date' => '2024-01-02',
|
||||
'duration' => 0,
|
||||
],
|
||||
[
|
||||
'date' => '2024-01-03',
|
||||
'duration' => 0,
|
||||
],
|
||||
[
|
||||
'date' => '2024-01-04',
|
||||
'duration' => 0,
|
||||
],
|
||||
[
|
||||
'date' => '2024-01-05',
|
||||
'duration' => 0,
|
||||
],
|
||||
[
|
||||
'date' => '2024-01-06',
|
||||
'duration' => 0,
|
||||
],
|
||||
], $result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Service\Import;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Service\Import\ImportService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ImportServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_import_gets_importer_from_provider_runs_importer_and_returns_report(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$data = file_get_contents(storage_path('tests/toggl_time_entries_import_test_1.csv'));
|
||||
|
||||
// Act
|
||||
$importService = app(ImportService::class);
|
||||
$report = $importService->import($organization, 'toggl_time_entries', $data);
|
||||
|
||||
// Assert
|
||||
$this->assertSame(2, $report->timeEntriesCreated);
|
||||
$this->assertSame(2, $report->tagsCreated);
|
||||
$this->assertSame(1, $report->tasksCreated);
|
||||
$this->assertSame(1, $report->usersCreated);
|
||||
$this->assertSame(2, $report->projectsCreated);
|
||||
$this->assertSame(1, $report->clientsCreated);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ class ClockifyProjectsImporterTest extends ImporterTestAbstract
|
||||
$data = file_get_contents(storage_path('tests/clockify_projects_import_test_1.csv'));
|
||||
|
||||
// Act
|
||||
$importer->importData($data, []);
|
||||
$importer->importData($data);
|
||||
|
||||
// Assert
|
||||
$this->checkTestScenarioProjectsOnlyAfterImport();
|
||||
@@ -31,12 +31,12 @@ class ClockifyProjectsImporterTest extends ImporterTestAbstract
|
||||
$importer = new ClockifyProjectsImporter();
|
||||
$importer->init($organization);
|
||||
$data = file_get_contents(storage_path('tests/clockify_projects_import_test_1.csv'));
|
||||
$importer->importData($data, []);
|
||||
$importer->importData($data);
|
||||
$importer = new ClockifyProjectsImporter();
|
||||
$importer->init($organization);
|
||||
|
||||
// Act
|
||||
$importer->importData($data, []);
|
||||
$importer->importData($data);
|
||||
|
||||
// Assert
|
||||
$this->checkTestScenarioProjectsOnlyAfterImport();
|
||||
|
||||
@@ -19,7 +19,7 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
|
||||
$data = file_get_contents(storage_path('tests/clockify_time_entries_import_test_1.csv'));
|
||||
|
||||
// Act
|
||||
$importer->importData($data, []);
|
||||
$importer->importData($data);
|
||||
|
||||
// Assert
|
||||
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries();
|
||||
@@ -48,12 +48,12 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
|
||||
$importer = new ClockifyTimeEntriesImporter();
|
||||
$importer->init($organization);
|
||||
$data = file_get_contents(storage_path('tests/clockify_time_entries_import_test_1.csv'));
|
||||
$importer->importData($data, []);
|
||||
$importer->importData($data);
|
||||
$importer = new ClockifyTimeEntriesImporter();
|
||||
$importer->init($organization);
|
||||
|
||||
// Act
|
||||
$importer->importData($data, []);
|
||||
$importer->importData($data);
|
||||
|
||||
// Assert
|
||||
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries();
|
||||
|
||||
@@ -38,9 +38,17 @@ class TogglDataImporterTest extends ImporterTestAbstract
|
||||
|
||||
// Act
|
||||
$importer->importData($data);
|
||||
$report = $importer->getReport();
|
||||
|
||||
// Assert
|
||||
$this->checkTestScenarioAfterImportExcludingTimeEntries();
|
||||
$this->assertSame(0, $report->timeEntriesCreated);
|
||||
$this->assertSame(2, $report->tagsCreated);
|
||||
$this->assertSame(1, $report->tasksCreated);
|
||||
$this->assertSame(1, $report->usersCreated);
|
||||
$this->assertSame(2, $report->projectsCreated);
|
||||
$this->assertSame(1, $report->clientsCreated);
|
||||
|
||||
}
|
||||
|
||||
public function test_import_of_test_file_twice_succeeds(): void
|
||||
@@ -57,8 +65,15 @@ class TogglDataImporterTest extends ImporterTestAbstract
|
||||
|
||||
// Act
|
||||
$importer->importData($data);
|
||||
$report = $importer->getReport();
|
||||
|
||||
// Assert
|
||||
$this->checkTestScenarioAfterImportExcludingTimeEntries();
|
||||
$this->assertSame(0, $report->timeEntriesCreated);
|
||||
$this->assertSame(0, $report->tagsCreated);
|
||||
$this->assertSame(0, $report->tasksCreated);
|
||||
$this->assertSame(0, $report->usersCreated);
|
||||
$this->assertSame(0, $report->projectsCreated);
|
||||
$this->assertSame(0, $report->clientsCreated);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ class TogglTimeEntriesImporterTest extends ImporterTestAbstract
|
||||
$data = file_get_contents(storage_path('tests/toggl_time_entries_import_test_1.csv'));
|
||||
|
||||
// Act
|
||||
$importer->importData($data, []);
|
||||
$importer->importData($data);
|
||||
$report = $importer->getReport();
|
||||
|
||||
// Assert
|
||||
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries();
|
||||
@@ -39,6 +40,12 @@ class TogglTimeEntriesImporterTest extends ImporterTestAbstract
|
||||
$this->assertSame('2024-03-04 11:23:01', $timeEntry2->end->toDateTimeString());
|
||||
$this->assertTrue($timeEntry2->billable);
|
||||
$this->assertSame([], $timeEntry2->tags);
|
||||
$this->assertSame(2, $report->timeEntriesCreated);
|
||||
$this->assertSame(2, $report->tagsCreated);
|
||||
$this->assertSame(1, $report->tasksCreated);
|
||||
$this->assertSame(1, $report->usersCreated);
|
||||
$this->assertSame(2, $report->projectsCreated);
|
||||
$this->assertSame(1, $report->clientsCreated);
|
||||
}
|
||||
|
||||
public function test_import_of_test_file_twice_succeeds(): void
|
||||
@@ -53,7 +60,8 @@ class TogglTimeEntriesImporterTest extends ImporterTestAbstract
|
||||
$importer->init($organization);
|
||||
|
||||
// Act
|
||||
$importer->importData($data, []);
|
||||
$importer->importData($data);
|
||||
$report = $importer->getReport();
|
||||
|
||||
// Assert
|
||||
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries();
|
||||
@@ -73,5 +81,11 @@ class TogglTimeEntriesImporterTest extends ImporterTestAbstract
|
||||
$this->assertSame('2024-03-04 11:23:01', $timeEntry2->end->toDateTimeString());
|
||||
$this->assertTrue($timeEntry2->billable);
|
||||
$this->assertSame([], $timeEntry2->tags);
|
||||
$this->assertSame(2, $report->timeEntriesCreated);
|
||||
$this->assertSame(0, $report->tagsCreated);
|
||||
$this->assertSame(0, $report->tasksCreated);
|
||||
$this->assertSame(0, $report->usersCreated);
|
||||
$this->assertSame(0, $report->projectsCreated);
|
||||
$this->assertSame(0, $report->clientsCreated);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,21 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Service;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Service\TimezoneService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Tests\TestCase;
|
||||
use TiMacDonald\Log\LogEntry;
|
||||
|
||||
class TimezoneServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_get_timezones_returns_all_available_timezones(): void
|
||||
{
|
||||
// Arrange
|
||||
$service = new \App\Service\TimezoneService();
|
||||
$service = app(TimezoneService::class);
|
||||
|
||||
// Act
|
||||
$result = $service->getTimezones();
|
||||
@@ -23,4 +30,42 @@ class TimezoneServiceTest extends TestCase
|
||||
$this->assertContains('Europe/Berlin', $result);
|
||||
$this->assertContains('Europe/London', $result);
|
||||
}
|
||||
|
||||
public function test_get_timezone_from_user_returns_timezone_of_user_as_carbon_timezone(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = User::factory()->create([
|
||||
'timezone' => 'Europe/Berlin',
|
||||
]);
|
||||
|
||||
/** @var TimezoneService $service */
|
||||
$service = app(TimezoneService::class);
|
||||
|
||||
// Act
|
||||
$result = $service->getTimezoneFromUser($user);
|
||||
|
||||
// Assert
|
||||
$this->assertEquals('Europe/Berlin', $result->getName());
|
||||
}
|
||||
|
||||
public function test_get_timezone_from_user_falls_back_to_utc_and_logs_this_failure_if_timezone_in_db_is_corrupt(): void
|
||||
{
|
||||
// Arrange
|
||||
$corruptTimezone = 'Invalid/Timezone';
|
||||
$user = User::factory()->create([
|
||||
'timezone' => $corruptTimezone,
|
||||
]);
|
||||
|
||||
/** @var TimezoneService $service */
|
||||
$service = app(TimezoneService::class);
|
||||
|
||||
// Act
|
||||
$result = $service->getTimezoneFromUser($user);
|
||||
|
||||
// Assert
|
||||
$this->assertEquals('UTC', $result->getName());
|
||||
Log::assertLogged(fn (LogEntry $log) => $log->level === 'error'
|
||||
&& $log->message === 'User has a invalid timezone'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user