339 lines
18 KiB
PHP
339 lines
18 KiB
PHP
<?php
|
||
|
||
namespace Tests\Unit\App\Domain\Reports\Services;
|
||
|
||
use Carbon\CarbonImmutable;
|
||
use Leantime\Core\Auth\Permissions\PermissionService;
|
||
use Leantime\Core\Configuration\Environment;
|
||
use Leantime\Core\Language;
|
||
use Leantime\Core\Support\CarbonMacros;
|
||
use Leantime\Core\Support\DateTimeHelper;
|
||
use Leantime\Domain\Goalcanvas\Services\Goalcanvas as GoalcanvasService;
|
||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||
use Leantime\Domain\Reports\Models\ReportPeriod;
|
||
use Leantime\Domain\Reports\Repositories\ReportEngine as ReportEngineRepository;
|
||
use Leantime\Domain\Reports\Services\ReportEngine;
|
||
use Leantime\Domain\Tickets\Repositories\Tickets as TicketRepository;
|
||
use PHPUnit\Framework\MockObject\MockObject;
|
||
use Unit\TestCase;
|
||
|
||
class ReportEngineTest extends TestCase
|
||
{
|
||
use \Codeception\Test\Feature\Stub;
|
||
|
||
private ReportEngine $service;
|
||
|
||
private ReportEngineRepository&MockObject $repository;
|
||
|
||
private TicketRepository&MockObject $ticketRepository;
|
||
|
||
private ProjectService&MockObject $projectService;
|
||
|
||
private GoalcanvasService&MockObject $goalService;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
|
||
$environmentMock = $this->make(Environment::class, [
|
||
'defaultTimezone' => 'UTC',
|
||
'language' => 'en-US',
|
||
]);
|
||
app()->instance(Environment::class, $environmentMock);
|
||
|
||
$languageMock = $this->createMock(Language::class);
|
||
$languageMock->method('__')->willReturnCallback(function ($index) {
|
||
$map = [
|
||
'language.dateformat' => 'm/d/Y',
|
||
'language.timeformat' => 'h:i A',
|
||
];
|
||
|
||
return $map[$index] ?? null;
|
||
});
|
||
app()->instance(Language::class, $languageMock);
|
||
|
||
CarbonImmutable::mixin(new CarbonMacros('UTC', 'en_US', 'm/d/Y', 'h:i A'));
|
||
app()->instance(DateTimeHelper::class, new DateTimeHelper);
|
||
|
||
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-07-08 12:00:00', 'UTC'));
|
||
|
||
$this->repository = $this->createMock(ReportEngineRepository::class);
|
||
$this->ticketRepository = $this->createMock(TicketRepository::class);
|
||
$this->projectService = $this->createMock(ProjectService::class);
|
||
$this->goalService = $this->createMock(GoalcanvasService::class);
|
||
|
||
$this->service = new ReportEngine(
|
||
$this->repository,
|
||
$this->ticketRepository,
|
||
$this->projectService,
|
||
$this->goalService,
|
||
);
|
||
|
||
$permissionService = $this->createMock(PermissionService::class);
|
||
$permissionService->method('currentUserCan')->willReturn(true);
|
||
$this->service->setPermissionService($permissionService);
|
||
|
||
// Status vocabulary for project 10: 1 = NEW, 2 = INPROGRESS, 3 = DONE.
|
||
$this->ticketRepository->method('getStateLabels')->willReturn([
|
||
1 => ['statusType' => 'NEW', 'name' => 'New'],
|
||
2 => ['statusType' => 'INPROGRESS', 'name' => 'In Progress'],
|
||
3 => ['statusType' => 'DONE', 'name' => 'Done'],
|
||
]);
|
||
}
|
||
|
||
protected function tearDown(): void
|
||
{
|
||
CarbonImmutable::setTestNow();
|
||
app()->forgetInstance(DateTimeHelper::class);
|
||
|
||
parent::tearDown();
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $overrides
|
||
*/
|
||
private function milestone(int $id, array $overrides = []): object
|
||
{
|
||
return (object) array_merge([
|
||
'id' => $id,
|
||
'headline' => 'Milestone '.$id,
|
||
'description' => '',
|
||
'outcomeImpact' => null,
|
||
'date' => '2026-01-01 00:00:00',
|
||
'projectId' => 10,
|
||
'status' => 1,
|
||
'editFrom' => null,
|
||
'editTo' => null,
|
||
'modified' => null,
|
||
'projectName' => 'Project 10',
|
||
'type' => 'milestone',
|
||
'tags' => 'var(--grey)',
|
||
], $overrides);
|
||
}
|
||
|
||
private function lastQuarter(): ReportPeriod
|
||
{
|
||
// With test-now 2026-07-08 UTC this is Apr 1 – Jun 30 2026 (UTC).
|
||
return ReportPeriod::lastQuarter();
|
||
}
|
||
|
||
public function test_milestones_bucket_into_completed_overdue_in_progress_and_upcoming(): void
|
||
{
|
||
$this->repository->method('getMilestonesForProjects')->willReturn([
|
||
// Done, history transition inside the period -> completed.
|
||
$this->milestone(1, ['status' => 3]),
|
||
// Done, history transition long before the period -> allDone only.
|
||
$this->milestone(2, ['status' => 3]),
|
||
// Open with a past due date -> overdue.
|
||
$this->milestone(3, ['editFrom' => '2026-05-01 00:00:00', 'editTo' => '2026-06-01 00:00:00']),
|
||
// Open, starting two months after the period -> upcoming (Q3 2026).
|
||
$this->milestone(4, ['editFrom' => '2026-08-15 00:00:00', 'editTo' => '2026-09-15 00:00:00']),
|
||
// Open and completely unscheduled -> in progress.
|
||
$this->milestone(5),
|
||
// Open, starting after the two-quarter horizon -> dropped from upcoming.
|
||
$this->milestone(6, ['editFrom' => '2027-06-01 00:00:00', 'editTo' => '2027-07-01 00:00:00']),
|
||
]);
|
||
$this->repository->method('getStatusHistoryForTickets')->willReturn([
|
||
(object) ['ticketId' => 1, 'changeValue' => '2', 'dateModified' => '2026-05-01 09:00:00'],
|
||
(object) ['ticketId' => 1, 'changeValue' => '3', 'dateModified' => '2026-05-10 09:00:00'],
|
||
(object) ['ticketId' => 2, 'changeValue' => '3', 'dateModified' => '2026-01-15 09:00:00'],
|
||
]);
|
||
$this->repository->method('getTasksForMilestones')->willReturn([]);
|
||
$this->repository->method('getDueDateChangesForTickets')->willReturn([]);
|
||
|
||
$report = $this->service->getMilestoneReportForProjects([10], $this->lastQuarter());
|
||
|
||
$this->assertSame([1], array_map(fn ($m) => $m->id, $report['completed']));
|
||
$this->assertSame('2026-05-10 09:00:00', $report['completed'][0]->completedOn->format('Y-m-d H:i:s'));
|
||
$this->assertSame([3], array_map(fn ($m) => $m->id, $report['overdue']));
|
||
$this->assertSame([5], array_map(fn ($m) => $m->id, $report['inProgress']));
|
||
$this->assertSame([4], array_map(fn ($m) => $m->id, $report['upcoming']));
|
||
$this->assertArrayHasKey('Q3 2026', $report['upcomingByQuarter']);
|
||
$this->assertCount(2, $report['allDone']);
|
||
}
|
||
|
||
public function test_completion_date_falls_back_to_due_date_then_modified_without_history(): void
|
||
{
|
||
$this->repository->method('getMilestonesForProjects')->willReturn([
|
||
// No history, has a due date inside the period.
|
||
$this->milestone(1, ['status' => 3, 'editTo' => '2026-05-20 00:00:00']),
|
||
// No history, no due date, modified inside the period.
|
||
$this->milestone(2, ['status' => 3, 'modified' => '2026-06-15 08:00:00']),
|
||
// History exists but never transitions into DONE -> falls back to modified.
|
||
$this->milestone(3, ['status' => 3, 'modified' => '2026-06-20 08:00:00']),
|
||
]);
|
||
$this->repository->method('getStatusHistoryForTickets')->willReturn([
|
||
(object) ['ticketId' => 3, 'changeValue' => '2', 'dateModified' => '2026-06-01 09:00:00'],
|
||
]);
|
||
$this->repository->method('getTasksForMilestones')->willReturn([]);
|
||
$this->repository->method('getDueDateChangesForTickets')->willReturn([]);
|
||
|
||
$report = $this->service->getMilestoneReportForProjects([10], $this->lastQuarter());
|
||
|
||
$completedOn = [];
|
||
foreach ($report['completed'] as $milestone) {
|
||
$completedOn[$milestone->id] = $milestone->completedOn->format('Y-m-d H:i:s');
|
||
}
|
||
|
||
$this->assertSame('2026-05-20 00:00:00', $completedOn[1]);
|
||
$this->assertSame('2026-06-15 08:00:00', $completedOn[2]);
|
||
$this->assertSame('2026-06-20 08:00:00', $completedOn[3]);
|
||
}
|
||
|
||
public function test_milestone_progress_uses_weighted_task_scores(): void
|
||
{
|
||
$this->repository->method('getMilestonesForProjects')->willReturn([
|
||
$this->milestone(1, ['editFrom' => '2026-06-01 00:00:00', 'editTo' => '2026-09-01 00:00:00']),
|
||
]);
|
||
$this->repository->method('getStatusHistoryForTickets')->willReturn([]);
|
||
$this->repository->method('getDueDateChangesForTickets')->willReturn([]);
|
||
$this->repository->method('getTasksForMilestones')->willReturn([
|
||
// Done: 5 points × priority-1 factor 2 = 10. Open: 5 × factor 1 (priority 5) = 5.
|
||
(object) ['id' => 100, 'headline' => 'Done task', 'status' => 3, 'projectId' => 10, 'milestoneid' => 1, 'storypoints' => 5, 'priority' => 1, 'editTo' => null, 'dateToFinish' => null],
|
||
(object) ['id' => 101, 'headline' => 'Open task', 'status' => 1, 'projectId' => 10, 'milestoneid' => 1, 'storypoints' => 5, 'priority' => 5, 'editTo' => null, 'dateToFinish' => null],
|
||
]);
|
||
|
||
$report = $this->service->getMilestoneReportForProjects([10], $this->lastQuarter());
|
||
|
||
$milestone = $report['inProgress'][0];
|
||
$this->assertEqualsWithDelta(66.67, $milestone->percentDone, 0.01);
|
||
$this->assertSame(['done' => 1, 'total' => 2], $milestone->taskStats);
|
||
// Key tasks list leads with completed work.
|
||
$this->assertSame(100, $milestone->keyTasks[0]->id);
|
||
$this->assertTrue($milestone->keyTasks[0]->isDone);
|
||
}
|
||
|
||
public function test_slippage_reports_milestones_pushed_out_and_added_mid_period(): void
|
||
{
|
||
$this->repository->method('getMilestonesForProjects')->willReturn([
|
||
// Open, now due after the period, with an in-period due-date change -> pushed out.
|
||
$this->milestone(1, ['editFrom' => '2026-04-10 00:00:00', 'editTo' => '2026-09-15 00:00:00']),
|
||
// Created mid-period -> added.
|
||
$this->milestone(2, ['date' => '2026-05-05 00:00:00', 'editFrom' => '2026-05-05 00:00:00', 'editTo' => '2026-12-01 00:00:00']),
|
||
]);
|
||
$this->repository->method('getStatusHistoryForTickets')->willReturn([]);
|
||
$this->repository->method('getTasksForMilestones')->willReturn([]);
|
||
$this->repository->method('getDueDateChangesForTickets')->willReturn([
|
||
(object) ['ticketId' => 1, 'changeValue' => '2026-09-15 00:00:00', 'dateModified' => '2026-06-10 09:00:00'],
|
||
(object) ['ticketId' => 1, 'changeValue' => '2026-09-15 00:00:00', 'dateModified' => '2026-06-20 09:00:00'],
|
||
]);
|
||
|
||
$report = $this->service->getMilestoneReportForProjects([10], $this->lastQuarter());
|
||
|
||
$this->assertCount(1, $report['slippage']['pushedOut']);
|
||
$this->assertSame(1, $report['slippage']['pushedOut'][0]->id);
|
||
$this->assertSame(2, $report['slippage']['pushedOut'][0]->dueDateMoves);
|
||
$this->assertSame([2], array_map(fn ($m) => $m->id, $report['slippage']['addedMidPeriod']));
|
||
}
|
||
|
||
public function test_goal_report_resolves_rollups_and_progress(): void
|
||
{
|
||
$this->repository->method('getGoalsForProjects')->willReturn([
|
||
(object) ['id' => 1, 'title' => 'Graduates', 'description' => '', 'status' => 'status_ontrack', 'metricType' => 'count', 'startValue' => 0.0, 'currentValue' => 42.0, 'endValue' => 60.0, 'setting' => '', 'milestoneId' => '', 'kpi' => '', 'startDate' => null, 'endDate' => null, 'canvasId' => 5, 'projectId' => 10, 'boardTitle' => 'Goals', 'milestoneHeadline' => null],
|
||
(object) ['id' => 2, 'title' => 'Rollup KPI', 'description' => '', 'status' => 'status_atrisk', 'metricType' => 'count', 'startValue' => 0.0, 'currentValue' => 0.0, 'endValue' => 100.0, 'setting' => 'linkAndReport', 'milestoneId' => '', 'kpi' => '', 'startDate' => null, 'endDate' => null, 'canvasId' => 5, 'projectId' => 10, 'boardTitle' => 'Goals', 'milestoneHeadline' => null],
|
||
]);
|
||
$this->goalService->method('getChildGoalsForReporting')->with(2)->willReturn(25.0);
|
||
// The engine batches milestone-chip hydration up front; stub it so the
|
||
// test exercises the rollup/progress path without relying on a mock's
|
||
// default null return.
|
||
$this->goalService->method('getMilestonesForGoals')->willReturn([1 => [], 2 => []]);
|
||
|
||
$report = $this->service->getGoalReportForProjects([10]);
|
||
|
||
$this->assertEqualsWithDelta(70.0, $report['goals'][0]->goalProgress, 0.01);
|
||
$this->assertEqualsWithDelta(25.0, $report['goals'][1]->currentValue, 0.01);
|
||
$this->assertEqualsWithDelta(25.0, $report['goals'][1]->goalProgress, 0.01);
|
||
$this->assertSame(['ontrack' => 1, 'atrisk' => 1, 'miss' => 0], $report['counts']);
|
||
}
|
||
|
||
public function test_status_updates_group_by_project_and_respect_limit(): void
|
||
{
|
||
$this->repository->method('getStatusUpdatesForProjects')->willReturn([
|
||
(object) ['id' => 1, 'projectId' => 10, 'text' => 'newest', 'date' => '2026-06-20 10:00:00', 'status' => 'green', 'authorFirstname' => 'A', 'authorLastname' => 'B', 'authorProfileId' => null],
|
||
(object) ['id' => 2, 'projectId' => 10, 'text' => 'older', 'date' => '2026-05-01 10:00:00', 'status' => 'yellow', 'authorFirstname' => 'A', 'authorLastname' => 'B', 'authorProfileId' => null],
|
||
(object) ['id' => 3, 'projectId' => 11, 'text' => 'other project', 'date' => '2026-05-02 10:00:00', 'status' => 'green', 'authorFirstname' => 'C', 'authorLastname' => 'D', 'authorProfileId' => null],
|
||
]);
|
||
|
||
$updates = $this->service->getStatusUpdatesForProjects([10, 11], $this->lastQuarter(), 1);
|
||
|
||
$this->assertCount(1, $updates[10]);
|
||
$this->assertSame('newest', $updates[10][0]->text);
|
||
$this->assertCount(1, $updates[11]);
|
||
}
|
||
|
||
public function test_project_summaries_flag_stale_and_alerting_projects(): void
|
||
{
|
||
$this->repository->method('getProjectsMeta')->willReturn([
|
||
10 => (object) ['id' => 10, 'name' => 'Fresh red project', 'details' => '<p>Some <b>html</b> description</p>', 'clientId' => 1, 'state' => 0, 'start' => null, 'end' => null, 'type' => 'project', 'parent' => null, 'clientName' => 'Client'],
|
||
11 => (object) ['id' => 11, 'name' => 'Silent project', 'details' => '', 'clientId' => 1, 'state' => 0, 'start' => null, 'end' => null, 'type' => 'project', 'parent' => null, 'clientName' => 'Client'],
|
||
]);
|
||
$this->repository->method('getLatestStatusUpdateForProjects')->willReturn([
|
||
10 => (object) ['projectId' => 10, 'text' => 'Behind on hiring', 'date' => '2026-07-01 10:00:00', 'status' => 'red', 'authorFirstname' => 'A', 'authorLastname' => 'B'],
|
||
// Project 11 has no status update at all.
|
||
]);
|
||
$this->projectService->method('getProjectProgress')->willReturn(['percent' => 40.0, 'estimatedCompletionDate' => false, 'plannedCompletionDate' => '']);
|
||
|
||
$summaries = $this->service->getProjectSummaries([10, 11]);
|
||
|
||
$this->assertSame('red', $summaries[10]->latestStatus);
|
||
$this->assertFalse($summaries[10]->isStale);
|
||
$this->assertSame('Some html description', $summaries[10]->descriptionExcerpt);
|
||
$this->assertNull($summaries[11]->latestStatus);
|
||
$this->assertTrue($summaries[11]->isStale);
|
||
}
|
||
|
||
public function test_effort_totals_by_project_and_milestone(): void
|
||
{
|
||
$this->repository->method('getHoursLoggedForProjects')->willReturn([
|
||
(object) ['projectId' => 10, 'milestoneId' => 1, 'loggedHours' => 12.5],
|
||
(object) ['projectId' => 10, 'milestoneId' => 0, 'loggedHours' => 3.0],
|
||
(object) ['projectId' => 11, 'milestoneId' => 2, 'loggedHours' => 4.25],
|
||
]);
|
||
|
||
$effort = $this->service->getEffortForProjects([10, 11], $this->lastQuarter());
|
||
|
||
$this->assertEqualsWithDelta(19.75, $effort['total'], 0.001);
|
||
$this->assertEqualsWithDelta(15.5, $effort['byProject'][10], 0.001);
|
||
$this->assertEqualsWithDelta(12.5, $effort['byMilestone'][1], 0.001);
|
||
$this->assertArrayNotHasKey(0, $effort['byMilestone']);
|
||
}
|
||
|
||
public function test_build_report_composes_needs_attention_and_deltas(): void
|
||
{
|
||
$this->repository->method('getMilestonesForProjects')->willReturn([
|
||
// Completed this period.
|
||
$this->milestone(1, ['status' => 3]),
|
||
// Completed in the prior period (feeds the delta).
|
||
$this->milestone(2, ['status' => 3]),
|
||
// Overdue -> needs attention.
|
||
$this->milestone(3, ['editTo' => '2026-06-01 00:00:00']),
|
||
]);
|
||
$this->repository->method('getStatusHistoryForTickets')->willReturn([
|
||
(object) ['ticketId' => 1, 'changeValue' => '3', 'dateModified' => '2026-05-10 09:00:00'],
|
||
(object) ['ticketId' => 2, 'changeValue' => '3', 'dateModified' => '2026-02-10 09:00:00'],
|
||
]);
|
||
$this->repository->method('getTasksForMilestones')->willReturn([]);
|
||
$this->repository->method('getDueDateChangesForTickets')->willReturn([]);
|
||
$this->repository->method('getGoalsForProjects')->willReturn([
|
||
(object) ['id' => 1, 'title' => 'At-risk goal', 'description' => '', 'status' => 'status_atrisk', 'metricType' => '', 'startValue' => 0.0, 'currentValue' => 1.0, 'endValue' => 10.0, 'setting' => '', 'milestoneId' => '', 'kpi' => '', 'startDate' => null, 'endDate' => null, 'canvasId' => 5, 'projectId' => 10, 'boardTitle' => 'Goals', 'milestoneHeadline' => null],
|
||
]);
|
||
$this->repository->method('getStatusUpdatesForProjects')->willReturn([]);
|
||
$this->repository->method('getHoursLoggedForProjects')->willReturn([]);
|
||
$this->repository->method('getProjectsMeta')->willReturn([
|
||
10 => (object) ['id' => 10, 'name' => 'Project', 'details' => '', 'clientId' => 1, 'state' => 0, 'start' => null, 'end' => null, 'type' => 'project', 'parent' => null, 'clientName' => 'Client'],
|
||
]);
|
||
$this->repository->method('getLatestStatusUpdateForProjects')->willReturn([]);
|
||
$this->projectService->method('getProjectProgress')->willReturn(['percent' => 10.0, 'estimatedCompletionDate' => false, 'plannedCompletionDate' => '']);
|
||
|
||
$report = $this->service->buildReport([10], $this->lastQuarter());
|
||
|
||
$this->assertSame(1, $report['stats']['completed']);
|
||
$this->assertSame(1, $report['deltas']['completedPrior']);
|
||
$this->assertSame(0, $report['deltas']['completedDelta']);
|
||
$this->assertCount(1, $report['needsAttention']['overdueMilestones']);
|
||
$this->assertCount(1, $report['needsAttention']['goalsAtRisk']);
|
||
// No status update ever -> the project is flagged as silent.
|
||
$this->assertCount(1, $report['needsAttention']['staleProjects']);
|
||
}
|
||
}
|