mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-14 16:53:17 +01:00
Add goals feature for personal goals with filters
This commit is contained in:
1335
tests/Unit/Endpoint/Api/V1/GoalEndpointTest.php
Normal file
1335
tests/Unit/Endpoint/Api/V1/GoalEndpointTest.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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', [])
|
||||
);
|
||||
|
||||
65
tests/Unit/Filament/Resources/GoalResourceTest.php
Normal file
65
tests/Unit/Filament/Resources/GoalResourceTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
106
tests/Unit/Model/GoalModelTest.php
Normal file
106
tests/Unit/Model/GoalModelTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
100
tests/Unit/Service/Dto/GoalFiltersDtoTest.php
Normal file
100
tests/Unit/Service/Dto/GoalFiltersDtoTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
472
tests/Unit/Service/GoalProgressServiceTest.php
Normal file
472
tests/Unit/Service/GoalProgressServiceTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user