Add goals feature for personal goals with filters

This commit is contained in:
Gregor Vostrak
2026-10-02 12:59:44 +02:00
parent 349623d537
commit f9519d0d5a
83 changed files with 7702 additions and 105 deletions

View File

@@ -8,6 +8,7 @@ use App\Enums\Role;
use App\Events\BeforeOrganizationDeletion;
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Models\Client;
use App\Models\Goal;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Passport\Client as PassportClient;
@@ -59,7 +60,8 @@ class DeletionServiceTest extends TestCaseWithDatabase
* tasks: Collection<Task>,
* timeEntries: Collection<TimeEntry>,
* owner: User,
* reports: Collection<Report>
* reports: Collection<Report>,
* goals: Collection<Goal>
* }
*/
private function createOrganizationWithAllRelations(): object
@@ -104,6 +106,10 @@ class DeletionServiceTest extends TestCaseWithDatabase
$report2 = Report::factory()->forOrganization($organization)->create();
$reports = collect([$report1, $report2]);
$goal1 = Goal::factory()->forMember($memberOwner)->create();
$goal2 = Goal::factory()->forMember($memberEmployee)->create();
$goals = collect([$goal1, $goal2]);
$timeEntries = TimeEntry::factory()->forOrganization($organization)->forMember($memberOwner)->createMany(2);
$timeEntriesWithTask = TimeEntry::factory()->forTask($task1)->forOrganization($organization)->forMember($memberEmployee)->createMany(2);
$timeEntriesWithProject = TimeEntry::factory()->forProject($projectWithClient)->forOrganization($organization)->forMember($memberPlaceholder)->createMany(2);
@@ -120,6 +126,7 @@ class DeletionServiceTest extends TestCaseWithDatabase
'timeEntries' => $timeEntries,
'owner' => $userOwner,
'reports' => $reports,
'goals' => $goals,
];
}
@@ -136,6 +143,7 @@ class DeletionServiceTest extends TestCaseWithDatabase
$this->assertSame(0, Member::query()->whereBelongsTo($organization, 'organization')->count());
$this->assertSame(0, Task::query()->whereBelongsTo($organization, 'organization')->count());
$this->assertSame(0, Report::query()->whereBelongsTo($organization, 'organization')->count());
$this->assertSame(0, Goal::query()->whereBelongsTo($organization, 'organization')->count());
$this->assertSame(0, TimeEntry::query()->whereBelongsTo($organization, 'organization')->count());
}
@@ -149,6 +157,7 @@ class DeletionServiceTest extends TestCaseWithDatabase
$this->assertSame(3, Member::query()->whereBelongsTo($organization, 'organization')->count());
$this->assertSame(2, Task::query()->whereBelongsTo($organization, 'organization')->count());
$this->assertSame(2, Report::query()->whereBelongsTo($organization, 'organization')->count());
$this->assertSame(2, Goal::query()->whereBelongsTo($organization, 'organization')->count());
$this->assertSame($specialCase ? 7 : 6, TimeEntry::query()->whereBelongsTo($organization, 'organization')->count());
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Service\Dto;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryType;
use App\Models\Goal;
use App\Service\Dto\GoalFiltersDto;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCase;
#[CoversClass(GoalFiltersDto::class)]
class GoalFiltersDtoTest extends TestCase
{
public function test_get_throws_when_json_is_missing_a_required_property(): void
{
// Arrange
$caster = GoalFiltersDto::castUsing([]);
// Assert
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('The given JSON string does not contain the required property "memberIds"');
// Act
$caster->get(new Goal, 'filters', '{}', []);
}
public function test_get_returns_null_filters_when_all_properties_are_null(): void
{
// Arrange
$caster = GoalFiltersDto::castUsing([]);
$json = '{"memberIds":null,"projectIds":null,"taskIds":null,"tagIds":null,"tagMatchType":null,"clientIds":null,"billable":null,"timeEntryType":null}';
// Act
$dto = $caster->get(new Goal, 'filters', $json, []);
// Assert
$this->assertNull($dto->memberIds);
$this->assertNull($dto->projectIds);
$this->assertNull($dto->taskIds);
$this->assertNull($dto->tagIds);
$this->assertNull($dto->tagMatchType);
$this->assertNull($dto->clientIds);
$this->assertNull($dto->billable);
$this->assertNull($dto->timeEntryType);
}
public function test_set_and_get_round_trip_keeps_all_values(): void
{
// Arrange
$caster = GoalFiltersDto::castUsing([]);
$dto = new GoalFiltersDto;
$dto->setProjectIds(['none']);
$dto->setTaskIds(['9e2a7a1e-5a5e-4a0c-9d6b-3d5b2a1c0f11']);
$dto->setTagIds([]);
$dto->tagMatchType = TagMatchType::Contains;
$dto->setClientIds(['9e2a7a1e-5a5e-4a0c-9d6b-3d5b2a1c0f13']);
$dto->billable = false;
$dto->timeEntryType = TimeEntryType::Work;
// Act
$json = $caster->set(new Goal, 'filters', $dto, []);
$result = $caster->get(new Goal, 'filters', $json, []);
// Assert
$this->assertSame(['none'], $result->projectIds?->toArray());
$this->assertSame(['9e2a7a1e-5a5e-4a0c-9d6b-3d5b2a1c0f11'], $result->taskIds?->toArray());
$this->assertNull($result->tagIds);
$this->assertSame(TagMatchType::Contains, $result->tagMatchType);
$this->assertSame(['9e2a7a1e-5a5e-4a0c-9d6b-3d5b2a1c0f13'], $result->clientIds?->toArray());
$this->assertFalse($result->billable);
$this->assertSame(TimeEntryType::Work, $result->timeEntryType);
}
public function test_get_throws_for_invalid_json(): void
{
// Arrange
$caster = GoalFiltersDto::castUsing([]);
// Assert
$this->expectException(\InvalidArgumentException::class);
// Act
$caster->get(new Goal, 'filters', 'not json', []);
}
public function test_id_setters_reject_invalid_ids(): void
{
// Arrange
$dto = new GoalFiltersDto;
// Assert
$this->expectException(\InvalidArgumentException::class);
// Act
$dto->setProjectIds(['not-a-uuid']);
}
}

View File

@@ -0,0 +1,472 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Service;
use App\Enums\GoalComparison;
use App\Enums\GoalPeriod;
use App\Enums\GoalStatus;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryType;
use App\Enums\Weekday;
use App\Models\Client;
use App\Models\Goal;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Models\User;
use App\Service\Dto\GoalFiltersDto;
use App\Service\GoalProgressService;
use App\Service\TimeEntryFilter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCase;
#[CoversClass(GoalProgressService::class)]
class GoalProgressServiceTest extends TestCase
{
use RefreshDatabase;
private GoalProgressService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = app(GoalProgressService::class);
}
private function progressAt(Goal $goal, Carbon $now): int
{
Carbon::setTestNow($now);
[$periodStart, $periodEnd] = $this->service->getPeriodBounds($goal, $now);
return $this->service->getProgress($goal, $periodStart, $periodEnd);
}
public function test_period_bounds_of_day_goal_are_calculated_in_the_timezone_of_the_goal(): void
{
// Arrange
$member = Member::factory()->create();
$goal = Goal::factory()->forMember($member)->timezone('Europe/Vienna')->period(GoalPeriod::Day)->create();
$date = Carbon::create(2024, 1, 1, 0, 30, 0, 'Europe/Vienna');
// Act
[$start, $end] = $this->service->getPeriodBounds($goal, $date);
// Assert
$this->assertSame('2023-12-31T23:00:00Z', $start->toIso8601ZuluString());
$this->assertSame('2024-01-01T23:00:00Z', $end->toIso8601ZuluString());
$this->assertSame('UTC', $start->getTimezone()->getName());
}
public function test_period_bounds_of_week_goal_respect_the_week_start_of_the_goal(): void
{
// Arrange
$member = Member::factory()->create();
$goal = Goal::factory()->forMember($member)->weekStart(Weekday::Sunday)->period(GoalPeriod::Week)->create();
// Wednesday
$date = Carbon::create(2024, 1, 3, 12, 0, 0, 'UTC');
// Act
[$start, $end] = $this->service->getPeriodBounds($goal, $date);
// Assert
// Sunday before
$this->assertSame('2023-12-31T00:00:00Z', $start->toIso8601ZuluString());
$this->assertSame('2024-01-07T00:00:00Z', $end->toIso8601ZuluString());
}
public function test_period_bounds_of_month_goal_cover_the_calendar_month(): void
{
// Arrange
$member = Member::factory()->create();
$goal = Goal::factory()->forMember($member)->timezone('America/New_York')->period(GoalPeriod::Month)->create();
$date = Carbon::create(2024, 2, 15, 12, 0, 0, 'America/New_York');
// Act
[$start, $end] = $this->service->getPeriodBounds($goal, $date);
// Assert
$this->assertSame('2024-02-01T05:00:00Z', $start->toIso8601ZuluString());
$this->assertSame('2024-03-01T05:00:00Z', $end->toIso8601ZuluString());
}
public function test_period_bounds_use_the_settings_pinned_on_the_goal_not_the_settings_of_the_member(): void
{
// Arrange
$organization = Organization::factory()->create();
$memberUser = User::factory()->create(['timezone' => 'America/New_York', 'week_start' => Weekday::Monday]);
$member = Member::factory()->forUser($memberUser)->forOrganization($organization)->create();
$goal = Goal::factory()->organizationGoalForMember($member)
->timezone('Europe/Vienna')
->weekStart(Weekday::Sunday)
->period(GoalPeriod::Week)
->create();
// Wednesday
$date = Carbon::create(2024, 1, 3, 12, 0, 0, 'UTC');
// Act
[$start, $end] = $this->service->getPeriodBounds($goal, $date);
// Assert
// Sunday 00:00 Vienna time
$this->assertSame('2023-12-30T23:00:00Z', $start->toIso8601ZuluString());
$this->assertSame('2024-01-06T23:00:00Z', $end->toIso8601ZuluString());
}
public function test_period_bounds_of_week_goal_spanning_the_end_of_daylight_saving_time_start_and_end_at_local_midnight(): void
{
// Arrange
$member = Member::factory()->create();
$goal = Goal::factory()->forMember($member)
->timezone('Europe/Vienna')
->weekStart(Weekday::Monday)
->period(GoalPeriod::Week)
->create();
// Friday before the switch on Sunday 2024-10-27
$date = Carbon::create(2024, 10, 25, 12, 0, 0, 'Europe/Vienna');
// Act
[$start, $end] = $this->service->getPeriodBounds($goal, $date);
// Assert
// Monday 00:00 CEST (UTC+2) to Monday 00:00 CET (UTC+1)
$this->assertSame('2024-10-20T22:00:00Z', $start->toIso8601ZuluString());
$this->assertSame('2024-10-27T23:00:00Z', $end->toIso8601ZuluString());
}
public function test_period_bounds_of_week_goal_use_the_local_weekday_when_it_differs_from_the_utc_weekday(): void
{
// Arrange
$member = Member::factory()->create();
$goal = Goal::factory()->forMember($member)
->timezone('Europe/Vienna')
->weekStart(Weekday::Monday)
->period(GoalPeriod::Week)
->create();
// Sunday 23:30 in UTC, but already Monday 00:30 in Vienna
$date = Carbon::create(2024, 1, 7, 23, 30, 0, 'UTC');
// Act
[$start, $end] = $this->service->getPeriodBounds($goal, $date);
// Assert
// The new week that started on Monday 00:00 in Vienna, not the week of the UTC Sunday
$this->assertSame('2024-01-07T23:00:00Z', $start->toIso8601ZuluString());
$this->assertSame('2024-01-14T23:00:00Z', $end->toIso8601ZuluString());
}
public function test_progress_assigns_entries_around_local_midnight_to_the_week_their_start_is_in(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->create();
$goal = Goal::factory()->forMember($member)
->timezone('Europe/Vienna')
->weekStart(Weekday::Monday)
->period(GoalPeriod::Week)
->create();
// Sunday 23:59 in Vienna, previous week
TimeEntry::factory()->forMember($member)->startWithDuration(Carbon::create(2024, 1, 7, 22, 59, 0, 'UTC'), 600)->create();
// Monday 00:00 in Vienna, first second of the week
TimeEntry::factory()->forMember($member)->startWithDuration(Carbon::create(2024, 1, 7, 23, 0, 0, 'UTC'), 300)->create();
// Act
$progress = $this->progressAt($goal, Carbon::create(2024, 1, 8, 12, 0, 0, 'UTC'));
// Assert
$this->assertSame(300, $progress);
}
public function test_progress_does_not_count_entries_that_start_at_the_end_of_the_period(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->create();
$goal = Goal::factory()->forMember($member)->timezone('Europe/Vienna')->period(GoalPeriod::Day)->create();
// 22:59:59 UTC is the last second of the day in Vienna
TimeEntry::factory()->forMember($member)->startWithDuration(Carbon::create(2024, 1, 1, 22, 59, 59, 'UTC'), 600)->create();
// 23:00 UTC is the first second of the next day in Vienna
TimeEntry::factory()->forMember($member)->startWithDuration(Carbon::create(2024, 1, 1, 23, 0, 0, 'UTC'), 300)->create();
// Act
$progress = $this->progressAt($goal, Carbon::create(2024, 1, 1, 12, 0, 0, 'Europe/Vienna'));
// Assert
$this->assertSame(600, $progress);
}
public function test_progress_of_goal_for_a_member_only_counts_that_member(): void
{
// Arrange
$organization = Organization::factory()->create();
$creator = Member::factory()->forOrganization($organization)->create();
$memberA = Member::factory()->forOrganization($organization)->create();
$memberB = Member::factory()->forOrganization($organization)->create();
$goal = Goal::factory()->organizationGoalForMember($memberA)->period(GoalPeriod::Day)->atLeast(3600)->create();
$now = Carbon::create(2024, 1, 3, 12, 0, 0, 'UTC');
TimeEntry::factory()->forMember($memberA)->startWithDuration($now->copy()->subHours(2), 1000)->create();
TimeEntry::factory()->forMember($memberB)->startWithDuration($now->copy()->subHours(2), 2000)->create();
TimeEntry::factory()->forMember($creator)->startWithDuration($now->copy()->subHours(2), 4000)->create();
// Act
$trackedSeconds = $this->progressAt($goal, $now);
// Assert
$this->assertSame(1000, $trackedSeconds);
}
public function test_progress_of_goal_for_every_member_counts_all_members_of_the_organization(): void
{
// Arrange
$organization = Organization::factory()->create();
$creator = Member::factory()->forOrganization($organization)->create();
$memberOfOtherOrganization = Member::factory()->create();
$memberA = Member::factory()->forOrganization($organization)->create();
$memberB = Member::factory()->forOrganization($organization)->create();
$goal = Goal::factory()->forOrganization($organization)->forEveryMember()->period(GoalPeriod::Day)->atLeast(3600)->create();
$now = Carbon::create(2024, 1, 3, 12, 0, 0, 'UTC');
TimeEntry::factory()->forMember($memberA)->startWithDuration($now->copy()->subHours(2), 1000)->create();
TimeEntry::factory()->forMember($memberB)->startWithDuration($now->copy()->subHours(2), 2000)->create();
TimeEntry::factory()->forMember($creator)->startWithDuration($now->copy()->subHours(2), 4000)->create();
TimeEntry::factory()->forMember($memberOfOtherOrganization)->startWithDuration($now->copy()->subHours(2), 8000)->create();
// Act
$trackedSeconds = $this->progressAt($goal, $now);
// Assert
$this->assertSame(7000, $trackedSeconds);
}
public function test_progress_of_goal_for_every_member_can_be_narrowed_down_with_the_member_ids_filter(): void
{
// Arrange
$organization = Organization::factory()->create();
$memberA = Member::factory()->forOrganization($organization)->create();
$memberB = Member::factory()->forOrganization($organization)->create();
$memberC = Member::factory()->forOrganization($organization)->create();
$filters = new GoalFiltersDto;
$filters->setMemberIds([$memberA->getKey(), $memberB->getKey()]);
$goal = Goal::factory()->forOrganization($organization)->forEveryMember()
->period(GoalPeriod::Day)->atLeast(3600)->filters($filters)->create();
$now = Carbon::create(2024, 1, 3, 12, 0, 0, 'UTC');
TimeEntry::factory()->forMember($memberA)->startWithDuration($now->copy()->subHours(2), 1000)->create();
TimeEntry::factory()->forMember($memberB)->startWithDuration($now->copy()->subHours(2), 2000)->create();
TimeEntry::factory()->forMember($memberC)->startWithDuration($now->copy()->subHours(2), 4000)->create();
// Act
$trackedSeconds = $this->progressAt($goal, $now);
// Assert
$this->assertSame(3000, $trackedSeconds);
}
public function test_progress_of_goal_for_one_member_ignores_the_member_ids_filter(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->create();
$otherMember = Member::factory()->forOrganization($organization)->create();
$filters = new GoalFiltersDto;
$filters->setMemberIds([$otherMember->getKey()]);
$goal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->atLeast(3600)->filters($filters)->create();
$now = Carbon::create(2024, 1, 3, 12, 0, 0, 'UTC');
TimeEntry::factory()->forMember($member)->startWithDuration($now->copy()->subHours(2), 1000)->create();
TimeEntry::factory()->forMember($otherMember)->startWithDuration($now->copy()->subHours(2), 2000)->create();
// Act
$trackedSeconds = $this->progressAt($goal, $now);
// Assert
$this->assertSame(1000, $trackedSeconds);
}
public function test_progress_counts_running_time_entry_up_to_now(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->create();
$goal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->atLeast(3600)->create();
$now = Carbon::create(2024, 1, 3, 12, 0, 0, 'UTC');
TimeEntry::factory()->forMember($member)->forOrganization($organization)
->startWithDuration($now->copy()->subHours(3), 600)
->create();
TimeEntry::factory()->forMember($member)->forOrganization($organization)
->start($now->copy()->subMinutes(30))
->active()
->create();
// Act
$trackedSeconds = $this->progressAt($goal, $now);
// Assert
$this->assertSame(600 + 1800, $trackedSeconds);
}
public function test_progress_does_not_count_running_time_entry_that_started_before_the_period(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->create();
$goal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->atLeast(3600)->create();
$now = Carbon::create(2024, 1, 3, 1, 0, 0, 'UTC');
TimeEntry::factory()->forMember($member)->forOrganization($organization)
->start($now->copy()->subHours(3))
->active()
->create();
// Act
$trackedSeconds = $this->progressAt($goal, $now);
// Assert
$this->assertSame(0, $trackedSeconds);
}
public function test_progress_applies_project_task_client_and_none_filters(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->create();
$client = Client::factory()->forOrganization($organization)->create();
$project = Project::factory()->forOrganization($organization)->forClient($client)->create();
$otherProject = Project::factory()->forOrganization($organization)->create();
$task = Task::factory()->forOrganization($organization)->forProject($project)->create();
$now = Carbon::create(2024, 1, 3, 12, 0, 0, 'UTC');
$start = $now->copy()->subHours(2);
TimeEntry::factory()->forMember($member)->forOrganization($organization)->forTask($task)
->startWithDuration($start, 100)->create();
TimeEntry::factory()->forMember($member)->forOrganization($organization)->forProject($project)
->startWithDuration($start, 200)->create();
TimeEntry::factory()->forMember($member)->forOrganization($organization)->forProject($otherProject)
->startWithDuration($start, 400)->create();
TimeEntry::factory()->forMember($member)->forOrganization($organization)
->startWithDuration($start, 800)->create();
$projectFilters = new GoalFiltersDto;
$projectFilters->setProjectIds([$project->getKey()]);
$taskFilters = new GoalFiltersDto;
$taskFilters->setTaskIds([$task->getKey()]);
$clientFilters = new GoalFiltersDto;
$clientFilters->setClientIds([$client->getKey()]);
$noneProjectFilters = new GoalFiltersDto;
$noneProjectFilters->setProjectIds([TimeEntryFilter::NONE_VALUE]);
$noFilters = new GoalFiltersDto;
$projectGoal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->filters($projectFilters)->create();
$taskGoal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->filters($taskFilters)->create();
$clientGoal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->filters($clientFilters)->create();
$noneProjectGoal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->filters($noneProjectFilters)->create();
$allGoal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->filters($noFilters)->create();
// Act & Assert
$this->assertSame(300, $this->progressAt($projectGoal, $now));
$this->assertSame(100, $this->progressAt($taskGoal, $now));
$this->assertSame(300, $this->progressAt($clientGoal, $now));
$this->assertSame(800, $this->progressAt($noneProjectGoal, $now));
$this->assertSame(1500, $this->progressAt($allGoal, $now));
}
public function test_progress_applies_tag_filters_with_match_type(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->create();
$tag = Tag::factory()->forOrganization($organization)->create();
$otherTag = Tag::factory()->forOrganization($organization)->create();
$now = Carbon::create(2024, 1, 3, 12, 0, 0, 'UTC');
$start = $now->copy()->subHours(2);
TimeEntry::factory()->forMember($member)->forOrganization($organization)
->startWithDuration($start, 100)->create(['tags' => [$tag->getKey()]]);
TimeEntry::factory()->forMember($member)->forOrganization($organization)
->startWithDuration($start, 200)->create(['tags' => [$otherTag->getKey()]]);
TimeEntry::factory()->forMember($member)->forOrganization($organization)
->startWithDuration($start, 400)->create(['tags' => []]);
$containsFilters = new GoalFiltersDto;
$containsFilters->setTagIds([$tag->getKey()]);
$containsFilters->tagMatchType = TagMatchType::Contains;
$notContainsFilters = new GoalFiltersDto;
$notContainsFilters->setTagIds([$tag->getKey()]);
$notContainsFilters->tagMatchType = TagMatchType::NotContains;
$noneFilters = new GoalFiltersDto;
$noneFilters->setTagIds([TimeEntryFilter::NONE_VALUE]);
$containsGoal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->filters($containsFilters)->create();
$notContainsGoal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->filters($notContainsFilters)->create();
$noneGoal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->filters($noneFilters)->create();
// Act & Assert
$this->assertSame(100, $this->progressAt($containsGoal, $now));
$this->assertSame(600, $this->progressAt($notContainsGoal, $now));
$this->assertSame(400, $this->progressAt($noneGoal, $now));
}
public function test_progress_applies_billable_and_time_entry_type_filters(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->create();
$now = Carbon::create(2024, 1, 3, 12, 0, 0, 'UTC');
$start = $now->copy()->subHours(2);
TimeEntry::factory()->forMember($member)->forOrganization($organization)->billable()
->startWithDuration($start, 100)->create();
TimeEntry::factory()->forMember($member)->forOrganization($organization)->notBillable()
->startWithDuration($start, 200)->create();
TimeEntry::factory()->forMember($member)->forOrganization($organization)->isBreak()
->startWithDuration($start, 400)->create();
$billableFilters = new GoalFiltersDto;
$billableFilters->billable = true;
$nonBillableFilters = new GoalFiltersDto;
$nonBillableFilters->billable = false;
$workFilters = new GoalFiltersDto;
$workFilters->timeEntryType = TimeEntryType::Work;
$breakFilters = new GoalFiltersDto;
$breakFilters->timeEntryType = TimeEntryType::Break;
$allFilters = new GoalFiltersDto;
$billableGoal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->filters($billableFilters)->create();
$nonBillableGoal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->filters($nonBillableFilters)->create();
$workGoal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->filters($workFilters)->create();
$breakGoal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->filters($breakFilters)->create();
$allGoal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->filters($allFilters)->create();
// Act & Assert
$this->assertSame(100, $this->progressAt($billableGoal, $now));
$this->assertSame(600, $this->progressAt($nonBillableGoal, $now));
$this->assertSame(300, $this->progressAt($workGoal, $now));
$this->assertSame(400, $this->progressAt($breakGoal, $now));
$this->assertSame(700, $this->progressAt($allGoal, $now));
}
public function test_status_of_at_least_goal_is_achieved_once_target_is_reached(): void
{
// Arrange
$goal = new Goal;
$goal->comparison = GoalComparison::AtLeast;
$goal->target_seconds = 3600;
// Act & Assert
$this->assertSame(GoalStatus::InProgress, $this->service->getStatus($goal, 0));
$this->assertSame(GoalStatus::InProgress, $this->service->getStatus($goal, 3599));
$this->assertSame(GoalStatus::Achieved, $this->service->getStatus($goal, 3600));
}
public function test_status_of_less_than_goal_is_exceeded_once_target_is_reached(): void
{
// Arrange
$goal = new Goal;
$goal->comparison = GoalComparison::LessThan;
$goal->target_seconds = 3600;
// Act & Assert
$this->assertSame(GoalStatus::OnTrack, $this->service->getStatus($goal, 0));
$this->assertSame(GoalStatus::OnTrack, $this->service->getStatus($goal, 3599));
$this->assertSame(GoalStatus::Exceeded, $this->service->getStatus($goal, 3600));
}
}

View File

@@ -5,12 +5,15 @@ declare(strict_types=1);
namespace Tests\Unit\Service;
use App\Enums\Role;
use App\Enums\TimeEntryType;
use App\Models\Goal;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\TimeEntry;
use App\Models\User;
use App\Service\Dto\GoalFiltersDto;
use App\Service\MemberService;
use App\Service\UserService;
use Illuminate\Support\Facades\Hash;
@@ -225,6 +228,141 @@ class MemberServiceTest extends TestCaseWithDatabase
$this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUserMember, 'member')->count());
}
public function test_assign_organization_entities_to_different_member_moves_all_goals_of_the_member(): void
{
// Arrange
$organization = Organization::factory()->create();
$otherOrganization = Organization::factory()->create();
$fromMember = Member::factory()->forOrganization($organization)->create();
$toMember = Member::factory()->forOrganization($organization)->create();
$otherMember = Member::factory()->forOrganization($organization)->create();
$personalGoal = Goal::factory()->forMember($fromMember)->create();
$organizationGoalForFromMember = Goal::factory()->organizationGoalForMember($fromMember)->create();
$organizationGoalForOtherMember = Goal::factory()->organizationGoalForMember($otherMember)->create();
$goalForEveryMember = Goal::factory()->forOrganization($organization)->forEveryMember()->create();
$personalGoalOfOtherMember = Goal::factory()->forMember($otherMember)->create();
$goalOfOtherOrganization = Goal::factory()->forOrganization($otherOrganization)->create();
// Act
$this->memberService->assignOrganizationEntitiesToDifferentMember($organization, $fromMember, $toMember);
// Assert
$this->assertSame($toMember->getKey(), $personalGoal->refresh()->member_id);
$this->assertSame($toMember->getKey(), $organizationGoalForFromMember->refresh()->member_id);
$this->assertSame($otherMember->getKey(), $organizationGoalForOtherMember->refresh()->member_id);
$this->assertNull($goalForEveryMember->refresh()->member_id);
$this->assertSame($otherMember->getKey(), $personalGoalOfOtherMember->refresh()->member_id);
$this->assertDatabaseHas(Goal::class, ['id' => $goalOfOtherOrganization->getKey()]);
$this->assertSame(0, $fromMember->goals()->count());
}
public function test_assign_organization_entities_to_different_member_replaces_the_member_in_goal_member_filters(): void
{
// Arrange
$organization = Organization::factory()->create();
$otherOrganization = Organization::factory()->create();
$fromMember = Member::factory()->forOrganization($organization)->create();
$toMember = Member::factory()->forOrganization($organization)->create();
$otherMember = Member::factory()->forOrganization($organization)->create();
$goalWithFromMember = Goal::factory()->forOrganization($organization)->forEveryMember()
->filters($this->memberFilter([$fromMember->getKey(), $otherMember->getKey()]))->create();
$goalWithBothMembers = Goal::factory()->forOrganization($organization)->forEveryMember()
->filters($this->memberFilter([$toMember->getKey(), $fromMember->getKey()]))->create();
$goalWithoutFromMember = Goal::factory()->forOrganization($organization)->forEveryMember()
->filters($this->memberFilter([$otherMember->getKey()]))->create();
$goalWithoutMemberFilter = Goal::factory()->forOrganization($organization)->forEveryMember()->create();
// Can not happen through the API, but a filter of another organization must stay untouched
$goalOfOtherOrganization = Goal::factory()->forOrganization($otherOrganization)->forEveryMember()
->filters($this->memberFilter([$fromMember->getKey()]))->create();
// Act
$this->memberService->assignOrganizationEntitiesToDifferentMember($organization, $fromMember, $toMember);
// Assert
$this->assertSame([$toMember->getKey(), $otherMember->getKey()], $goalWithFromMember->refresh()->filters->memberIds?->all());
// No duplicate if $toMember was already in the filter
$this->assertSame([$toMember->getKey()], $goalWithBothMembers->refresh()->filters->memberIds?->all());
$this->assertSame([$otherMember->getKey()], $goalWithoutFromMember->refresh()->filters->memberIds?->all());
$this->assertNull($goalWithoutMemberFilter->refresh()->filters->memberIds);
$this->assertSame([$fromMember->getKey()], $goalOfOtherOrganization->refresh()->filters->memberIds?->all());
}
/**
* @param array<string> $memberIds
*/
private function memberFilter(array $memberIds): GoalFiltersDto
{
$filters = new GoalFiltersDto;
$filters->timeEntryType = TimeEntryType::Work;
$filters->setMemberIds($memberIds);
return $filters;
}
public function test_make_member_to_placeholder_keeps_all_goals_of_the_member(): void
{
// Arrange
$organization = Organization::factory()->create();
$user = User::factory()->create();
$member = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create();
$otherMember = Member::factory()->forOrganization($organization)->create();
$personalGoal = Goal::factory()->forMember($member)->create();
$organizationGoal = Goal::factory()->organizationGoalForMember($member)->create();
$goalOfOtherMember = Goal::factory()->forMember($otherMember)->create();
// Act
$this->memberService->makeMemberToPlaceholder($member);
// Assert
$member->refresh();
$this->assertTrue($member->user->is_placeholder);
$this->assertDatabaseHas(Goal::class, ['id' => $personalGoal->getKey(), 'member_id' => $member->getKey()]);
$this->assertDatabaseHas(Goal::class, ['id' => $organizationGoal->getKey(), 'member_id' => $member->getKey()]);
$this->assertDatabaseHas(Goal::class, ['id' => $goalOfOtherMember->getKey()]);
}
public function test_remove_member_deletes_the_goals_of_the_member(): void
{
// Arrange
$organization = Organization::factory()->create();
$user = User::factory()->create();
$member = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create();
$otherMember = Member::factory()->forOrganization($organization)->create();
$personalGoal = Goal::factory()->forMember($member)->create();
$organizationGoal = Goal::factory()->organizationGoalForMember($member)->create();
$goalForEveryMember = Goal::factory()->forOrganization($organization)->forEveryMember()->create();
$goalOfOtherMember = Goal::factory()->forMember($otherMember)->create();
// Act
$this->memberService->removeMember($member, $organization);
// Assert
$this->assertDatabaseMissing(Member::class, ['id' => $member->getKey()]);
$this->assertDatabaseMissing(Goal::class, ['id' => $personalGoal->getKey()]);
$this->assertDatabaseMissing(Goal::class, ['id' => $organizationGoal->getKey()]);
$this->assertDatabaseHas(Goal::class, ['id' => $goalForEveryMember->getKey()]);
$this->assertDatabaseHas(Goal::class, ['id' => $goalOfOtherMember->getKey()]);
}
public function test_add_member_moves_all_goals_of_placeholder_with_same_email_to_new_member(): void
{
// Arrange
$organization = Organization::factory()->create();
$user = User::factory()->create();
$member = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create();
$personalGoal = Goal::factory()->forMember($member)->create();
$organizationGoal = Goal::factory()->organizationGoalForMember($member)->create();
$this->memberService->makeMemberToPlaceholder($member);
// Act
$newMember = $this->memberService->addMember($user, $organization, Role::Employee);
// Assert
$this->assertDatabaseMissing(Member::class, ['id' => $member->getKey()]);
$this->assertDatabaseHas(Goal::class, ['id' => $personalGoal->getKey(), 'member_id' => $newMember->getKey()]);
$this->assertDatabaseHas(Goal::class, ['id' => $organizationGoal->getKey(), 'member_id' => $newMember->getKey()]);
}
public function test_assign_organization_entities_to_different_member_with_entries(): void
{
// Arrange