Merge branch 'main' of github.com:Timetracking-Project/timetracking into feature/add_time_overview

# Conflicts:
#	docker-compose.yml
This commit is contained in:
Gregor Vostrak
2024-03-26 18:22:47 +01:00
56 changed files with 1316 additions and 128 deletions
+1
View File
@@ -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}"
+12
View File
@@ -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"
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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);
+47
View File
@@ -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),
];
}
}
+3
View File
@@ -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
*
+12
View File
@@ -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(),
]);
}
);
+109 -50
View File
@@ -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;
}
}
+24
View File
@@ -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;
}
}
+1 -1
View File
@@ -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(),
]);
}
}
+3
View File
@@ -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
View File
@@ -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:
+65
View File
@@ -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"]
+8
View File
@@ -0,0 +1,8 @@
[PHP]
post_max_size = 100M
upload_max_filesize = 100M
variables_order = EGPCS
pcov.directory = .
[opcache]
opcache.enable_cli=1
+26
View File
@@ -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
+14
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
SELECT 'CREATE DATABASE testing'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'testing')\gexec
+19
View File
@@ -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',
],
];
+2
View File
@@ -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>
+1
View File
@@ -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
+9
View File
@@ -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');
+21 -2
View File
@@ -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);
}
}
+1 -1
View File
@@ -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
@@ -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;
}
+98 -11
View File
@@ -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);
}
}
+46 -1
View File
@@ -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'
);
}
}