Add goals feature for personal goals with filters

This commit is contained in:
Gregor Vostrak
2026-10-02 12:59:44 +02:00
parent 5010a5be3a
commit 38e33b318e
83 changed files with 7702 additions and 105 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Tests\Unit\Endpoint\Web;
use App\Enums\Weekday;
use App\Http\Controllers\Web\UserProfileController;
use App\Models\User;
use Illuminate\Support\Carbon;
@@ -29,8 +28,6 @@ class UserProfileEndpointTest extends EndpointTestAbstract
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('Profile/Show')
->has('timezones')
->where('weekdays', Weekday::toSelectArray())
->where('confirmsTwoFactorAuthentication', true)
->where('sessions', [])
);

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Filament\Resources;
use App\Filament\Resources\GoalResource;
use App\Models\Goal;
use App\Models\User;
use Illuminate\Support\Facades\Config;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\UsesClass;
use Tests\Unit\Filament\FilamentTestCase;
#[UsesClass(GoalResource::class)]
class GoalResourceTest extends FilamentTestCase
{
protected function setUp(): void
{
parent::setUp();
Config::set('auth.super_admins', ['admin@example.com']);
$user = User::factory()->withPersonalOrganization()->create([
'email' => 'admin@example.com',
]);
$this->actingAs($user);
}
public function test_can_list_goals(): void
{
// Arrange
$goals = Goal::factory()->createMany(5);
// Act
$response = Livewire::test(GoalResource\Pages\ListGoals::class);
// Assert
$response->assertSuccessful();
$response->assertCanSeeTableRecords($goals);
}
public function test_can_see_view_page_of_goal(): void
{
// Arrange
$goal = Goal::factory()->create();
// Act
$response = Livewire::test(GoalResource\Pages\ViewGoal::class, ['record' => $goal->getKey()]);
// Assert
$response->assertSuccessful();
}
public function test_can_see_edit_page_of_goal(): void
{
// Arrange
$goal = Goal::factory()->create();
// Act
$response = Livewire::test(GoalResource\Pages\EditGoal::class, ['record' => $goal->getKey()]);
// Assert
$response->assertSuccessful();
}
}

View File

@@ -35,6 +35,7 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
$mock->shouldReceive('hasTrial')->andReturn(false);
$mock->shouldReceive('getTrialUntil')->andReturn(null);
$mock->shouldReceive('isBlocked')->andReturn(false);
$mock->shouldReceive('getGoalLimit')->andReturn(1);
});
Passport::actingAs($user->user);
@@ -47,6 +48,7 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
->where('billing.has_trial', false)
->where('billing.trial_until', null)
->where('billing.is_blocked', false)
->where('billing.goal_limit', 1)
);
}
@@ -61,6 +63,7 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
$mock->shouldReceive('hasTrial')->andReturn(true);
$mock->shouldReceive('getTrialUntil')->andReturn($trialUntil);
$mock->shouldReceive('isBlocked')->andReturn(false);
$mock->shouldReceive('getGoalLimit')->andReturn(null);
});
Passport::actingAs($user->user);
@@ -73,6 +76,7 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
->where('billing.has_trial', true)
->where('billing.trial_until', $trialUntil->toIso8601ZuluString())
->where('billing.is_blocked', false)
->where('billing.goal_limit', null)
);
}
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Model;
use App\Enums\GoalType;
use App\Models\Goal;
use App\Models\Member;
use App\Models\Organization;
use Illuminate\Database\QueryException;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(Goal::class)]
class GoalModelTest extends ModelTestAbstract
{
public function test_it_belongs_to_an_organization_and_a_member(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->create();
$goal = Goal::factory()->forMember($member)->create();
// Act
$goal->refresh();
// Assert
$this->assertTrue($goal->organization->is($organization));
$this->assertTrue($goal->member->is($member));
$this->assertTrue($goal->isPersonalGoalOf($member));
$this->assertTrue($member->goals->contains($goal));
}
public function test_factory_default_creates_the_member_in_the_organization_of_the_goal(): void
{
// Act
$goal = Goal::factory()->create();
// Assert
$this->assertNotNull($goal->member);
$this->assertSame($goal->organization_id, $goal->member->organization_id);
}
public function test_goal_for_every_member_has_no_member(): void
{
// Arrange
$organization = Organization::factory()->create();
$goal = Goal::factory()->forOrganization($organization)->forEveryMember()->create();
// Act
$goal->refresh();
// Assert
$this->assertNull($goal->member);
$this->assertNull($goal->member_id);
$this->assertSame(GoalType::Organization, $goal->type);
}
public function test_an_organization_goal_for_a_member_is_not_a_personal_goal_of_that_member(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->create();
$goal = Goal::factory()->organizationGoalForMember($member)->create();
// Act
$goal->refresh();
// Assert
$this->assertSame($member->getKey(), $goal->member_id);
$this->assertFalse($goal->isPersonalGoalOf($member));
}
public function test_archived_at_decides_the_is_archived_attribute_and_the_scopes(): void
{
// Arrange
$organization = Organization::factory()->create();
$goal = Goal::factory()->forOrganization($organization)->create();
$archivedGoal = Goal::factory()->forOrganization($organization)->archived()->create();
// Act
$notArchivedIds = Goal::query()->notArchived()->pluck('id')->all();
$archivedIds = Goal::query()->archived()->pluck('id')->all();
// Assert
$this->assertFalse($goal->refresh()->is_archived);
$this->assertTrue($archivedGoal->refresh()->is_archived);
$this->assertNotNull($archivedGoal->archived_at);
$this->assertSame([$goal->getKey()], $notArchivedIds);
$this->assertSame([$archivedGoal->getKey()], $archivedIds);
}
public function test_a_member_that_still_has_a_goal_can_not_be_deleted(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->create();
Goal::factory()->forMember($member)->create();
// Assert
$this->expectException(QueryException::class);
// Act
$member->delete();
}
}

View File

@@ -9,6 +9,7 @@ use App\Events\BeforeOrganizationDeletion;
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Models\Audit;
use App\Models\Client;
use App\Models\Goal;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Passport\Client as PassportClient;
@@ -60,7 +61,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
@@ -105,6 +107,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);
@@ -121,6 +127,7 @@ class DeletionServiceTest extends TestCaseWithDatabase
'timeEntries' => $timeEntries,
'owner' => $userOwner,
'reports' => $reports,
'goals' => $goals,
];
}
@@ -137,6 +144,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());
}
@@ -150,6 +158,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