393 lines
16 KiB
PHP
393 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Unit\app\Plugins\PgmPro\Domain\Resources\Services;
|
|
|
|
use Leantime\Core\Db\Db;
|
|
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
|
use Leantime\Plugins\PgmPro\Domain\Resources\Repositories\ResourceStructureRepository;
|
|
use Leantime\Plugins\PgmPro\Domain\Resources\Services\ResourceStructureService;
|
|
use Leantime\Plugins\PgmPro\Services\Programs;
|
|
use Unit\TestCase;
|
|
|
|
/**
|
|
* Behaviors under test:
|
|
*
|
|
* getForProjects — the ResourcesGateway contract:
|
|
* 1. Empty projectIds → empty ResourceSummary (no walk).
|
|
* 2. Projects with no resource canvas → empty ResourceSummary (honest
|
|
* "resources not authored here" state, not an error).
|
|
* 3. status='stub' PEOPLE are excluded from totals — matches the tab's
|
|
* teamStats split. A seeded stub defaults to capacity:40, and counting
|
|
* it would make the report disagree with the tab the moment anyone
|
|
* uses LM-Inputs seeding.
|
|
* 4. status='stub' BUDGET LINES are excluded from totals AND from the
|
|
* budget[] array — stubs are 0/0 so they don't move totals, but they
|
|
* inflate `count(budget)` and can flip `isEmpty()`.
|
|
*
|
|
* Seeders — the "skip already present" contract:
|
|
* 5. seedPeopleFromChildProjects is idempotent by userId — a second run
|
|
* with the same source data adds zero rows and reports the correct
|
|
* skipped count.
|
|
* 6. seedBudgetFromChildProjects is idempotent by projectId — same.
|
|
*
|
|
* All tests avoid touching the DB via a testable subclass of the SUT that
|
|
* overrides `findResourceCanvasIds()`; the repository and the two service
|
|
* collaborators are stubbed with anonymous classes so the test asserts on
|
|
* the exact call surface.
|
|
*/
|
|
class ResourceStructureServiceTest extends TestCase
|
|
{
|
|
protected function setUp(): void
|
|
{
|
|
// The OSS CI checkout has an empty app/Plugins (private submodule) — the classes
|
|
// under test only exist when the plugins are present. Skip, don't error.
|
|
if (! class_exists(ResourceStructureService::class)) {
|
|
$this->markTestSkipped('PgmPro plugin is not present in this checkout.');
|
|
}
|
|
|
|
parent::setUp();
|
|
}
|
|
|
|
public function test_get_for_projects_returns_empty_summary_when_project_ids_is_empty(): void
|
|
{
|
|
$svc = $this->makeService(canvasIds: [1], items: []);
|
|
$summary = $svc->getForProjects([]);
|
|
|
|
$this->assertSame([], $summary->projectIds);
|
|
$this->assertTrue($summary->isEmpty());
|
|
}
|
|
|
|
public function test_get_for_projects_returns_empty_summary_when_no_resource_canvas(): void
|
|
{
|
|
$svc = $this->makeService(canvasIds: [], items: []);
|
|
$summary = $svc->getForProjects([1, 2]);
|
|
|
|
$this->assertSame([1, 2], $summary->projectIds);
|
|
$this->assertTrue($summary->isEmpty());
|
|
$this->assertSame(0.0, $summary->totalCapacity);
|
|
}
|
|
|
|
public function test_get_for_projects_excludes_stub_people_from_totals(): void
|
|
{
|
|
// Active person: capacity 40, allocated 30. Stub person: default 40.
|
|
// The report and the tab must agree — the tab excludes stubs from
|
|
// teamStats, so the gateway must too. Otherwise a seeded-but-unlinked
|
|
// person shows 40h in the report and 0h in the tab.
|
|
$svc = $this->makeService(
|
|
canvasIds: [100],
|
|
items: [
|
|
100 => [
|
|
'people' => [
|
|
[
|
|
'id' => 1,
|
|
'description' => 'Sarah Chen',
|
|
'status' => 'active',
|
|
'parsedData' => ['userId' => 1, 'capacity' => 40, 'allocations' => ['7' => 30]],
|
|
],
|
|
[
|
|
'id' => 2,
|
|
'description' => 'Unlinked seed',
|
|
'status' => 'stub',
|
|
'parsedData' => ['capacity' => 40, 'allocations' => []],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
);
|
|
|
|
$summary = $svc->getForProjects([7]);
|
|
|
|
$this->assertCount(1, $summary->people, 'Stubs must not appear in the people array');
|
|
$this->assertSame(40.0, $summary->totalCapacity, 'Stub 40h capacity must not be counted');
|
|
$this->assertSame(30.0, $summary->totalAllocated);
|
|
}
|
|
|
|
public function test_get_for_projects_excludes_stub_budget_lines_from_array_and_totals(): void
|
|
{
|
|
$svc = $this->makeService(
|
|
canvasIds: [100],
|
|
items: [
|
|
100 => [
|
|
'budget' => [
|
|
[
|
|
'id' => 10,
|
|
'description' => 'Community Health Fairs',
|
|
'status' => 'active',
|
|
'parsedData' => ['projectId' => 7, 'budgeted' => 10000, 'spent' => 2000],
|
|
],
|
|
[
|
|
'id' => 11,
|
|
'description' => '',
|
|
'status' => 'stub',
|
|
'parsedData' => ['projectId' => 8, 'budgeted' => 0, 'spent' => 0],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
);
|
|
|
|
$summary = $svc->getForProjects([7, 8]);
|
|
|
|
$this->assertCount(1, $summary->budget, 'Stub budget rows must not inflate the array count (would flip isEmpty)');
|
|
$this->assertSame(10000.0, $summary->totalBudgeted);
|
|
$this->assertSame(2000.0, $summary->totalSpent);
|
|
}
|
|
|
|
public function test_get_for_projects_aggregates_across_multiple_canvases(): void
|
|
{
|
|
// A strategy with two programs, each with its own resource canvas.
|
|
// The gateway must sum across both.
|
|
$svc = $this->makeService(
|
|
canvasIds: [100, 200],
|
|
items: [
|
|
100 => [
|
|
'people' => [
|
|
['id' => 1, 'description' => 'A', 'status' => 'active',
|
|
'parsedData' => ['userId' => 1, 'capacity' => 40, 'allocations' => ['7' => 20]]],
|
|
],
|
|
],
|
|
200 => [
|
|
'people' => [
|
|
['id' => 2, 'description' => 'B', 'status' => 'active',
|
|
'parsedData' => ['userId' => 2, 'capacity' => 30, 'allocations' => ['9' => 15]]],
|
|
],
|
|
],
|
|
],
|
|
);
|
|
|
|
$summary = $svc->getForProjects([2, 15, 7, 9]);
|
|
|
|
$this->assertCount(2, $summary->people);
|
|
$this->assertSame(70.0, $summary->totalCapacity);
|
|
$this->assertSame(35.0, $summary->totalAllocated);
|
|
}
|
|
|
|
public function test_seed_people_from_child_projects_is_idempotent_by_user_id(): void
|
|
{
|
|
// Two child projects; user 5 assigned to both, user 7 to one.
|
|
// First run: 2 people added (5 and 7). Second run: 0 added, 2 skipped
|
|
// per encounter — the "skip already present" contract is what makes
|
|
// the seeder safe to re-run on demand from the UI.
|
|
$existingItems = [];
|
|
$addCalls = [];
|
|
$repo = $this->makeRepo(
|
|
canvasId: 500,
|
|
itemsByBoxProvider: function (int $canvasId, string $box) use (&$existingItems) {
|
|
return $existingItems;
|
|
},
|
|
onAddItem: function (int $canvasId, array $values) use (&$existingItems, &$addCalls): int {
|
|
$addCalls[] = $values;
|
|
$existingItems[] = [
|
|
'id' => count($existingItems) + 1,
|
|
'description' => $values['description'],
|
|
'status' => $values['status'],
|
|
'parsedData' => $values['data'],
|
|
];
|
|
|
|
return count($existingItems);
|
|
},
|
|
);
|
|
|
|
$programs = $this->makePrograms(childProjects: [
|
|
['id' => 7, 'name' => 'Project A'],
|
|
['id' => 8, 'name' => 'Project B'],
|
|
]);
|
|
|
|
$projects = $this->makeProjects(usersByProject: [
|
|
7 => [['id' => 5, 'firstname' => 'Sarah', 'lastname' => 'Chen', 'jobTitle' => 'PM']],
|
|
8 => [
|
|
['id' => 5, 'firstname' => 'Sarah', 'lastname' => 'Chen', 'jobTitle' => 'PM'],
|
|
['id' => 7, 'firstname' => 'Aisha', 'lastname' => 'Patel', 'jobTitle' => 'Dev'],
|
|
],
|
|
]);
|
|
|
|
$svc = new ResourceStructureService($repo, $this->makeDb(), $projects, $programs);
|
|
|
|
$first = $svc->seedPeopleFromChildProjects(500);
|
|
$this->assertSame(2, $first['added'], 'First run adds the two distinct users');
|
|
$this->assertSame(1, $first['skipped'], 'Sarah appearing on the second project is skipped');
|
|
$this->assertSame(500, $first['canvasId']);
|
|
|
|
$second = $svc->seedPeopleFromChildProjects(500);
|
|
$this->assertSame(0, $second['added'], 'Second run must add nothing (idempotent by userId)');
|
|
$this->assertSame(3, $second['skipped'], 'Every source-user encounter is a skip on re-run');
|
|
|
|
// Also validates the add-payload shape: userId is captured, status is 'active' (not 'stub').
|
|
$this->assertCount(2, $addCalls);
|
|
$this->assertSame('active', $addCalls[0]['status']);
|
|
$this->assertSame(5, $addCalls[0]['data']['userId']);
|
|
}
|
|
|
|
public function test_get_for_projects_hydrates_a_dependency_with_none_of_the_optional_fields(): void
|
|
{
|
|
// Back-compat guarantee for pre-existing dependency canvas items —
|
|
// owner/dueDate/notes/lastModified were added later. An item authored
|
|
// before those existed must hydrate cleanly (no undefined-index
|
|
// warning, all four optional slots null) so Page 3 renders the empty
|
|
// state instead of crashing or flashing blank cells.
|
|
$svc = $this->makeService(
|
|
canvasIds: [100],
|
|
items: [
|
|
100 => [
|
|
'dependency' => [
|
|
[
|
|
'id' => 42,
|
|
'description' => 'Community Health Fair Partner',
|
|
'status' => 'active',
|
|
'parsedData' => [
|
|
'partnerName' => 'City Health Dept',
|
|
'type' => 'partnership',
|
|
'confirmed' => true,
|
|
// owner, dueDate, notes intentionally absent.
|
|
],
|
|
// 'modified' intentionally absent too — pre-hydrated rows
|
|
// that predate lastModified capture.
|
|
],
|
|
],
|
|
],
|
|
],
|
|
);
|
|
|
|
$summary = $svc->getForProjects([7]);
|
|
|
|
$this->assertCount(1, $summary->dependencies);
|
|
$dep = $summary->dependencies[0];
|
|
$this->assertSame(42, $dep->itemId);
|
|
$this->assertSame('City Health Dept', $dep->partnerName);
|
|
$this->assertSame('partnership', $dep->type);
|
|
$this->assertTrue($dep->confirmed);
|
|
$this->assertNull($dep->owner);
|
|
$this->assertNull($dep->dueDate);
|
|
$this->assertNull($dep->notes);
|
|
$this->assertNull($dep->lastModified);
|
|
}
|
|
|
|
public function test_seed_budget_from_child_projects_is_idempotent_by_project_id(): void
|
|
{
|
|
// Same skip-when-present contract, keyed on projectId. A child with
|
|
// dollarBudget=0 is skipped entirely (no line to seed).
|
|
$existingItems = [];
|
|
$addCalls = [];
|
|
$repo = $this->makeRepo(
|
|
canvasId: 500,
|
|
itemsByBoxProvider: function (int $canvasId, string $box) use (&$existingItems) {
|
|
return $existingItems;
|
|
},
|
|
onAddItem: function (int $canvasId, array $values) use (&$existingItems, &$addCalls): int {
|
|
$addCalls[] = $values;
|
|
$existingItems[] = [
|
|
'id' => count($existingItems) + 1,
|
|
'description' => $values['description'],
|
|
'status' => $values['status'],
|
|
'parsedData' => $values['data'],
|
|
];
|
|
|
|
return count($existingItems);
|
|
},
|
|
);
|
|
|
|
$programs = $this->makePrograms(childProjects: [
|
|
['id' => 7, 'name' => 'Health Fairs', 'dollarBudget' => 45000, 'color' => '#3E937A'],
|
|
['id' => 8, 'name' => 'Zero-budget project', 'dollarBudget' => 0, 'color' => '#000'],
|
|
['id' => 9, 'name' => 'Walk-in Days', 'dollarBudget' => 12000, 'color' => '#C09035'],
|
|
]);
|
|
|
|
$svc = new ResourceStructureService($repo, $this->makeDb(), $this->makeProjects(), $programs);
|
|
|
|
$first = $svc->seedBudgetFromChildProjects(500);
|
|
$this->assertSame(2, $first['added'], 'The zero-budget project is skipped (no line to seed)');
|
|
$this->assertSame(0, $first['skipped']);
|
|
|
|
$second = $svc->seedBudgetFromChildProjects(500);
|
|
$this->assertSame(0, $second['added'], 'Re-run must add nothing');
|
|
$this->assertSame(2, $second['skipped'], 'Both real-budget projects skip on re-run');
|
|
}
|
|
|
|
// ─── Test doubles ────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Testable subclass of the SUT that overrides `findResourceCanvasIds()`
|
|
* with a canned list, so the test doesn't need to mock a Db query chain.
|
|
* `getItemsByBox()` returns items keyed by `[canvasId][box]`.
|
|
*
|
|
* @param int[] $canvasIds
|
|
* @param array<int, array<string, array<int, array<string, mixed>>>> $items
|
|
*/
|
|
private function makeService(array $canvasIds, array $items): ResourceStructureService
|
|
{
|
|
$repo = $this->makeRepo(
|
|
canvasId: 0,
|
|
itemsByBoxProvider: fn (int $canvasId, string $box) => $items[$canvasId][$box] ?? [],
|
|
);
|
|
|
|
return new class($repo, $this->makeDb(), $this->makeProjects(), $this->makePrograms(), $canvasIds) extends ResourceStructureService
|
|
{
|
|
/** @param int[] $canvasIds */
|
|
public function __construct(
|
|
ResourceStructureRepository $repo,
|
|
Db $dbCore,
|
|
ProjectService $projectService,
|
|
Programs $programService,
|
|
private array $canvasIds,
|
|
) {
|
|
parent::__construct($repo, $dbCore, $projectService, $programService);
|
|
}
|
|
|
|
protected function findResourceCanvasIds(array $projectIds): array
|
|
{
|
|
return $this->canvasIds;
|
|
}
|
|
};
|
|
}
|
|
|
|
private function makeRepo(
|
|
int $canvasId = 0,
|
|
?\Closure $itemsByBoxProvider = null,
|
|
?\Closure $onAddItem = null,
|
|
): ResourceStructureRepository {
|
|
$repo = $this->createMock(ResourceStructureRepository::class);
|
|
$repo->method('getOrCreateResourceCanvas')->willReturn($canvasId);
|
|
$repo->method('getItemsByBox')->willReturnCallback(
|
|
$itemsByBoxProvider ?? fn (int $canvasId, string $box) => []
|
|
);
|
|
if ($onAddItem !== null) {
|
|
$repo->method('addItem')->willReturnCallback($onAddItem);
|
|
}
|
|
|
|
return $repo;
|
|
}
|
|
|
|
private function makeDb(): Db
|
|
{
|
|
// Not used by the SUT paths under test — `findResourceCanvasIds` is
|
|
// overridden in makeService(), and seeders don't touch dbCore.
|
|
return $this->createMock(Db::class);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<int, array<string, mixed>>> $usersByProject projectId => user rows
|
|
*/
|
|
private function makeProjects(array $usersByProject = []): ProjectService
|
|
{
|
|
$projects = $this->createMock(ProjectService::class);
|
|
$projects->method('getUsersAssignedToProject')->willReturnCallback(
|
|
fn (int $projectId) => $usersByProject[$projectId] ?? []
|
|
);
|
|
|
|
return $projects;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $childProjects
|
|
*/
|
|
private function makePrograms(array $childProjects = []): Programs
|
|
{
|
|
$programs = $this->createMock(Programs::class);
|
|
$programs->method('getColoredProgramProjects')->willReturn($childProjects);
|
|
|
|
return $programs;
|
|
}
|
|
}
|