OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
175
tests/Unit/app/Domain/Reports/Models/ReportPeriodTest.php
Normal file
175
tests/Unit/app/Domain/Reports/Models/ReportPeriodTest.php
Normal file
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\App\Domain\Reports\Models;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\Support\CarbonMacros;
|
||||
use Leantime\Core\Support\DateTimeHelper;
|
||||
use Leantime\Domain\Reports\Models\ReportPeriod;
|
||||
use Unit\TestCase;
|
||||
|
||||
class ReportPeriodTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$environmentMock = $this->make(Environment::class, [
|
||||
'defaultTimezone' => 'America/Los_Angeles',
|
||||
'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);
|
||||
|
||||
// User calendar in LA (UTC-7 in summer) so quarter boundaries shift against UTC.
|
||||
CarbonImmutable::mixin(new CarbonMacros(
|
||||
'America/Los_Angeles',
|
||||
'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'));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
CarbonImmutable::setTestNow();
|
||||
app()->forgetInstance(DateTimeHelper::class);
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_this_quarter_resolves_user_calendar_quarter_in_utc(): void
|
||||
{
|
||||
$period = ReportPeriod::thisQuarter();
|
||||
|
||||
// Q3 2026 in LA: Jul 1 00:00 PDT = Jul 1 07:00 UTC, Sep 30 23:59:59 PDT = Oct 1 06:59:59 UTC.
|
||||
$this->assertSame('2026-07-01 07:00:00', $period->fromDbString());
|
||||
$this->assertSame('2026-10-01 06:59:59', $period->toDbString());
|
||||
$this->assertSame(ReportPeriod::PRESET_THIS_QUARTER, $period->preset);
|
||||
}
|
||||
|
||||
public function test_last_and_next_quarter_presets(): void
|
||||
{
|
||||
$lastQuarter = ReportPeriod::lastQuarter();
|
||||
// Q2 2026 in LA starts Apr 1 00:00 PDT = Apr 1 07:00 UTC.
|
||||
$this->assertSame('2026-04-01 07:00:00', $lastQuarter->fromDbString());
|
||||
$this->assertSame('2026-07-01 06:59:59', $lastQuarter->toDbString());
|
||||
|
||||
$nextQuarter = ReportPeriod::nextQuarter();
|
||||
// Q4 2026 in LA starts Oct 1 00:00 PDT = Oct 1 07:00 UTC.
|
||||
$this->assertSame('2026-10-01 07:00:00', $nextQuarter->fromDbString());
|
||||
// Dec 31 23:59:59 PST (UTC-8) = Jan 1 07:59:59 UTC.
|
||||
$this->assertSame('2027-01-01 07:59:59', $nextQuarter->toDbString());
|
||||
}
|
||||
|
||||
public function test_quarter_follows_user_timezone_across_utc_quarter_boundary(): void
|
||||
{
|
||||
// Jul 1 03:00 UTC is still Jun 30 in LA — the user's "this quarter" is Q2, not Q3.
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-07-01 03:00:00', 'UTC'));
|
||||
|
||||
$period = ReportPeriod::thisQuarter();
|
||||
|
||||
$this->assertSame('2026-04-01 07:00:00', $period->fromDbString());
|
||||
$this->assertSame('2026-07-01 06:59:59', $period->toDbString());
|
||||
}
|
||||
|
||||
public function test_from_request_parses_presets_and_custom_ranges(): void
|
||||
{
|
||||
$preset = ReportPeriod::fromRequest(['preset' => 'lastQuarter']);
|
||||
$this->assertSame(ReportPeriod::PRESET_LAST_QUARTER, $preset->preset);
|
||||
|
||||
$custom = ReportPeriod::fromRequest(['preset' => 'custom', 'from' => '04/01/2026', 'to' => '06/30/2026']);
|
||||
$this->assertSame(ReportPeriod::PRESET_CUSTOM, $custom->preset);
|
||||
$this->assertSame('2026-04-01 07:00:00', $custom->fromDbString());
|
||||
// End of day Jun 30 PDT.
|
||||
$this->assertSame('2026-07-01 06:59:59', $custom->toDbString());
|
||||
}
|
||||
|
||||
public function test_from_request_falls_back_to_this_quarter(): void
|
||||
{
|
||||
$this->assertSame(ReportPeriod::PRESET_THIS_QUARTER, ReportPeriod::fromRequest([])->preset);
|
||||
$this->assertSame(
|
||||
ReportPeriod::PRESET_THIS_QUARTER,
|
||||
ReportPeriod::fromRequest(['preset' => 'custom', 'from' => 'not-a-date', 'to' => '06/30/2026'])->preset
|
||||
);
|
||||
// Inverted range is rejected.
|
||||
$this->assertSame(
|
||||
ReportPeriod::PRESET_THIS_QUARTER,
|
||||
ReportPeriod::fromRequest(['preset' => 'custom', 'from' => '06/30/2026', 'to' => '04/01/2026'])->preset
|
||||
);
|
||||
}
|
||||
|
||||
public function test_prior_period_of_quarter_preset_is_previous_quarter(): void
|
||||
{
|
||||
$prior = ReportPeriod::thisQuarter()->priorPeriod();
|
||||
|
||||
$this->assertSame('2026-04-01 07:00:00', $prior->fromDbString());
|
||||
$this->assertSame('2026-07-01 06:59:59', $prior->toDbString());
|
||||
}
|
||||
|
||||
public function test_prior_period_of_custom_range_is_same_length_before(): void
|
||||
{
|
||||
$custom = ReportPeriod::fromRequest(['preset' => 'custom', 'from' => '06/21/2026', 'to' => '06/30/2026']);
|
||||
$prior = $custom->priorPeriod();
|
||||
|
||||
// Ten-day window directly preceding Jun 21–Jun 30.
|
||||
$this->assertSame('2026-06-11 07:00:00', $prior->fromDbString());
|
||||
$this->assertSame('2026-06-21 06:59:59', $prior->toDbString());
|
||||
}
|
||||
|
||||
public function test_upcoming_horizon_extends_two_quarters_past_period_end(): void
|
||||
{
|
||||
$horizon = ReportPeriod::thisQuarter()->upcomingHorizon();
|
||||
|
||||
// Two quarters past Q3 2026 = end of Q1 2027 (Mar 31 23:59:59 PDT = Apr 1 06:59:59 UTC).
|
||||
$this->assertSame('2027-04-01 06:59:59', $horizon->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
public function test_contains_is_inclusive_of_bounds(): void
|
||||
{
|
||||
$period = ReportPeriod::thisQuarter();
|
||||
|
||||
$this->assertTrue($period->contains($period->from));
|
||||
$this->assertTrue($period->contains($period->to));
|
||||
$this->assertFalse($period->contains($period->from->subSecond()));
|
||||
$this->assertFalse($period->contains($period->to->addSecond()));
|
||||
}
|
||||
|
||||
public function test_query_string_round_trips(): void
|
||||
{
|
||||
$this->assertSame('preset=thisQuarter', ReportPeriod::thisQuarter()->toQueryString());
|
||||
|
||||
$custom = ReportPeriod::fromRequest(['preset' => 'custom', 'from' => '04/01/2026', 'to' => '06/30/2026']);
|
||||
parse_str($custom->toQueryString(), $params);
|
||||
$roundTripped = ReportPeriod::fromRequest($params);
|
||||
|
||||
$this->assertSame($custom->fromDbString(), $roundTripped->fromDbString());
|
||||
$this->assertSame($custom->toDbString(), $roundTripped->toDbString());
|
||||
}
|
||||
|
||||
public function test_label_carries_quarter_shorthand_for_full_quarters(): void
|
||||
{
|
||||
$this->assertStringStartsWith('Q3 2026 · ', ReportPeriod::thisQuarter()->label());
|
||||
|
||||
$custom = ReportPeriod::fromRequest(['preset' => 'custom', 'from' => '06/21/2026', 'to' => '06/30/2026']);
|
||||
$this->assertStringNotContainsString('Q2', $custom->label());
|
||||
}
|
||||
}
|
||||
410
tests/Unit/app/Domain/Reports/Services/CapacityAnalyzerTest.php
Normal file
410
tests/Unit/app/Domain/Reports/Services/CapacityAnalyzerTest.php
Normal file
@@ -0,0 +1,410 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\App\Domain\Reports\Services;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\Resources\Models\PersonAllocation;
|
||||
use Leantime\Core\Resources\Models\ResourceSummary;
|
||||
use Leantime\Core\Support\CarbonMacros;
|
||||
use Leantime\Core\Support\DateTimeHelper;
|
||||
use Leantime\Domain\Reports\Models\ReportPeriod;
|
||||
use Leantime\Domain\Reports\Services\CapacityAnalyzer;
|
||||
use Leantime\Domain\Tickets\Repositories\Tickets as TicketsRepo;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Unit\TestCase;
|
||||
|
||||
class CapacityAnalyzerTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
private TicketsRepo&MockObject $ticketsRepo;
|
||||
|
||||
private CapacityAnalyzer $analyzer;
|
||||
|
||||
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(fn ($index) => [
|
||||
'language.dateformat' => 'm/d/Y',
|
||||
'language.timeformat' => 'h:i A',
|
||||
][$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->ticketsRepo = $this->createMock(TicketsRepo::class);
|
||||
$this->analyzer = new CapacityAnalyzer($this->ticketsRepo);
|
||||
|
||||
// Default status vocabulary: 3=NEW, 4=INPROGRESS, 0=DONE, -1=DONE(archived),
|
||||
// 7 = a CUSTOM done status with a positive id.
|
||||
$this->ticketsRepo->method('getStateLabels')->willReturn([
|
||||
3 => ['statusType' => 'NEW', 'name' => 'New'],
|
||||
4 => ['statusType' => 'INPROGRESS', 'name' => 'In Progress'],
|
||||
0 => ['statusType' => 'DONE', 'name' => 'Done'],
|
||||
-1 => ['statusType' => 'DONE', 'name' => 'Archived'],
|
||||
7 => ['statusType' => 'DONE', 'name' => 'Shipped'],
|
||||
]);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
CarbonImmutable::setTestNow();
|
||||
app()->forgetInstance(DateTimeHelper::class);
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* One-week period so availableHours equals the weekly allocation exactly.
|
||||
*/
|
||||
private function oneWeekPeriod(): ReportPeriod
|
||||
{
|
||||
return ReportPeriod::fromRequest(['preset' => 'custom', 'from' => '06/01/2026', 'to' => '06/07/2026']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $tickets
|
||||
*/
|
||||
private function withTickets(array $tickets, int $projectId = 10): void
|
||||
{
|
||||
// The analyzer batches its ticket reads into one getAllByProjectIds()
|
||||
// call keyed by projectId; the single-project cases here all use id 10.
|
||||
$this->ticketsRepo->method('getAllByProjectIds')->willReturn([$projectId => $tickets]);
|
||||
}
|
||||
|
||||
private function summaryWithWeeklyAllocation(int $projectId, float $weeklyHours, int $people = 1): ResourceSummary
|
||||
{
|
||||
$persons = [];
|
||||
for ($i = 0; $i < $people; $i++) {
|
||||
$persons[] = new PersonAllocation(
|
||||
itemId: $i + 1,
|
||||
userId: $i + 1,
|
||||
displayName: 'Person '.($i + 1),
|
||||
capacity: 40.0,
|
||||
allocations: [$projectId => $weeklyHours / $people],
|
||||
);
|
||||
}
|
||||
|
||||
return new ResourceSummary([$projectId], $persons, [], [], 40.0 * $people, $weeklyHours, 0.0, 0.0);
|
||||
}
|
||||
|
||||
public function test_custom_done_statuses_are_excluded_from_demand(): void
|
||||
{
|
||||
$this->withTickets([
|
||||
// Custom DONE status (positive id) — must NOT count as open demand.
|
||||
['id' => 1, 'status' => 7, 'planHours' => 100.0, 'storypoints' => 0],
|
||||
// Default done + archived — excluded.
|
||||
['id' => 2, 'status' => 0, 'planHours' => 50.0, 'storypoints' => 0],
|
||||
['id' => 3, 'status' => -1, 'planHours' => 25.0, 'storypoints' => 0],
|
||||
// Open work — the only demand.
|
||||
['id' => 4, 'status' => 3, 'planHours' => 10.0, 'storypoints' => 0],
|
||||
]);
|
||||
|
||||
$rows = $this->analyzer->analyzeProjects([10], $this->oneWeekPeriod(), $this->summaryWithWeeklyAllocation(10, 20.0), [10 => 'P']);
|
||||
|
||||
$this->assertSame(1, $rows[10]['openTicketCount']);
|
||||
$this->assertEqualsWithDelta(10.0, $rows[10]['budgetedHours'], 0.001);
|
||||
}
|
||||
|
||||
public function test_trust_signal_bands_drive_reference_demand(): void
|
||||
{
|
||||
// Low coverage (1 of 4 open tickets budgeted = 0.25 < 0.30) with effort present
|
||||
// -> trust 'effort', referenceDemand = storypoints × hoursPerPoint.
|
||||
$this->withTickets([
|
||||
['id' => 1, 'status' => 3, 'planHours' => 8.0, 'storypoints' => 5],
|
||||
['id' => 2, 'status' => 3, 'planHours' => 0, 'storypoints' => 5],
|
||||
['id' => 3, 'status' => 3, 'planHours' => 0, 'storypoints' => 5],
|
||||
['id' => 4, 'status' => 4, 'planHours' => 0, 'storypoints' => 5],
|
||||
]);
|
||||
|
||||
$rows = $this->analyzer->analyzeProjects([10], $this->oneWeekPeriod(), $this->summaryWithWeeklyAllocation(10, 20.0), [10 => 'P']);
|
||||
|
||||
$this->assertSame('effort', $rows[10]['trustSignal']);
|
||||
$this->assertEqualsWithDelta(20 * CapacityAnalyzer::DEFAULT_HOURS_PER_POINT, $rows[10]['referenceDemand'], 0.001);
|
||||
}
|
||||
|
||||
public function test_high_coverage_agreeing_estimates_trust_budgeted(): void
|
||||
{
|
||||
// Full coverage, divergence below threshold (40 budgeted vs 40 effort hours).
|
||||
$this->withTickets([
|
||||
['id' => 1, 'status' => 3, 'planHours' => 20.0, 'storypoints' => 5],
|
||||
['id' => 2, 'status' => 3, 'planHours' => 20.0, 'storypoints' => 5],
|
||||
]);
|
||||
|
||||
$rows = $this->analyzer->analyzeProjects([10], $this->oneWeekPeriod(), $this->summaryWithWeeklyAllocation(10, 20.0), [10 => 'P']);
|
||||
|
||||
$this->assertSame('budgeted', $rows[10]['trustSignal']);
|
||||
$this->assertEqualsWithDelta(40.0, $rows[10]['referenceDemand'], 0.001);
|
||||
}
|
||||
|
||||
public function test_diverging_estimates_flag_mixed_and_take_conservative_max(): void
|
||||
{
|
||||
// Full coverage but effort (10sp × 4h = 40h) vs budgeted (100h) diverge > 0.4 -> mixed, max() wins.
|
||||
$this->withTickets([
|
||||
['id' => 1, 'status' => 3, 'planHours' => 50.0, 'storypoints' => 5],
|
||||
['id' => 2, 'status' => 3, 'planHours' => 50.0, 'storypoints' => 5],
|
||||
]);
|
||||
|
||||
$rows = $this->analyzer->analyzeProjects([10], $this->oneWeekPeriod(), $this->summaryWithWeeklyAllocation(10, 20.0), [10 => 'P']);
|
||||
|
||||
$this->assertSame('mixed', $rows[10]['trustSignal']);
|
||||
$this->assertEqualsWithDelta(100.0, $rows[10]['referenceDemand'], 0.001);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider verdictBoundaryProvider
|
||||
*/
|
||||
public function test_verdict_boundaries(float $demandHours, float $weeklyAvailable, string $expectedVerdict): void
|
||||
{
|
||||
// Single fully-budgeted open ticket -> trust 'budgeted' -> referenceDemand = planHours.
|
||||
$this->withTickets([
|
||||
['id' => 1, 'status' => 3, 'planHours' => $demandHours, 'storypoints' => 0],
|
||||
]);
|
||||
|
||||
$rows = $this->analyzer->analyzeProjects([10], $this->oneWeekPeriod(), $this->summaryWithWeeklyAllocation(10, $weeklyAvailable), [10 => 'P']);
|
||||
|
||||
$this->assertSame($expectedVerdict, $rows[10]['verdict']);
|
||||
}
|
||||
|
||||
public static function verdictBoundaryProvider(): array
|
||||
{
|
||||
return [
|
||||
'no capacity' => [100.0, 0.0, 'no_capacity'],
|
||||
'critical: gap ratio > 0.25' => [130.0, 100.0, 'critical'],
|
||||
'tight: 0 < ratio <= 0.25' => [110.0, 100.0, 'tight'],
|
||||
'balanced: -0.5 <= ratio <= 0' => [90.0, 100.0, 'balanced'],
|
||||
'buffer: ratio < -0.5' => [40.0, 100.0, 'buffer'],
|
||||
];
|
||||
}
|
||||
|
||||
public function test_projects_without_work_or_people_are_skipped_as_noise(): void
|
||||
{
|
||||
$this->withTickets([]);
|
||||
|
||||
$rows = $this->analyzer->analyzeProjects([10], $this->oneWeekPeriod(), ResourceSummary::empty([10]), [10 => 'P']);
|
||||
|
||||
$this->assertSame([], $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* The N+1 guard: tickets for every analyzed project are pulled in a SINGLE
|
||||
* getAllByProjectIds() call, not one getAllByProjectId() per project. This
|
||||
* pins the batching so a future refactor can't quietly reintroduce the
|
||||
* per-project round-trip.
|
||||
*/
|
||||
public function test_tickets_are_fetched_in_one_batched_call_for_all_projects(): void
|
||||
{
|
||||
$this->ticketsRepo->expects($this->once())
|
||||
->method('getAllByProjectIds')
|
||||
->with($this->equalTo([10, 20, 30]))
|
||||
->willReturn([
|
||||
10 => [['id' => 1, 'status' => 3, 'planHours' => 10.0, 'storypoints' => 0]],
|
||||
20 => [['id' => 2, 'status' => 3, 'planHours' => 20.0, 'storypoints' => 0]],
|
||||
30 => [['id' => 3, 'status' => 3, 'planHours' => 30.0, 'storypoints' => 0]],
|
||||
]);
|
||||
|
||||
$summary = new ResourceSummary(
|
||||
[10, 20, 30],
|
||||
[new PersonAllocation(
|
||||
itemId: 1, userId: 1, displayName: 'P', capacity: 40.0,
|
||||
allocations: [10 => 10.0, 20 => 10.0, 30 => 10.0],
|
||||
)],
|
||||
[], [], 40.0, 30.0, 0.0, 0.0,
|
||||
);
|
||||
|
||||
$rows = $this->analyzer->analyzeProjects(
|
||||
[10, 20, 30],
|
||||
$this->oneWeekPeriod(),
|
||||
$summary,
|
||||
[10 => 'A', 20 => 'B', 30 => 'C'],
|
||||
);
|
||||
|
||||
$this->assertSame([10, 20, 30], array_keys($rows));
|
||||
$this->assertEqualsWithDelta(10.0, $rows[10]['budgetedHours'], 0.001);
|
||||
$this->assertEqualsWithDelta(30.0, $rows[30]['budgetedHours'], 0.001);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the reportable projects (those present in $projectNames) reach the
|
||||
* batched fetch — the skip that used to sit inside the per-project loop now
|
||||
* shapes the single WHERE IN, so we don't over-fetch container projects.
|
||||
*/
|
||||
public function test_only_reportable_projects_are_batched(): void
|
||||
{
|
||||
$this->ticketsRepo->expects($this->once())
|
||||
->method('getAllByProjectIds')
|
||||
->with($this->equalTo([10])) // 20 is not in $projectNames → excluded
|
||||
->willReturn([10 => [['id' => 1, 'status' => 3, 'planHours' => 10.0, 'storypoints' => 0]]]);
|
||||
|
||||
$this->analyzer->analyzeProjects(
|
||||
[10, 20],
|
||||
$this->oneWeekPeriod(),
|
||||
$this->summaryWithWeeklyAllocation(10, 20.0),
|
||||
[10 => 'A'], // 20 omitted on purpose
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Program rollup: supply is capacity, not booked hours ────────
|
||||
//
|
||||
// aggregateByProgram measures how much a program COULD do (capacity),
|
||||
// not how much is already booked against it. This keeps the rollup
|
||||
// consistent with the report's headline utilization tile, which reads
|
||||
// allocated/capacity — before this, a program with real headroom and
|
||||
// little booked reported no_capacity and could escalate as a false gap
|
||||
// on the strategy report. verdict()/trustSignal() themselves are
|
||||
// covered above; these pin the supply figure feeding them.
|
||||
|
||||
public function test_program_supply_uses_capacity_not_allocated_hours(): void
|
||||
{
|
||||
// 40h capacity, only 5h booked against the program → supply is 40.
|
||||
$row = $this->rollup(
|
||||
[$this->personCap(1, capacity: 40.0, allocations: [7 => 5.0])],
|
||||
childIds: [7],
|
||||
);
|
||||
|
||||
$this->assertEqualsWithDelta(40.0, $row['availableHours'], 0.001);
|
||||
$this->assertSame(1, $row['peopleCount']);
|
||||
}
|
||||
|
||||
public function test_program_supply_counts_a_person_on_two_child_projects_once(): void
|
||||
{
|
||||
$row = $this->rollup(
|
||||
[$this->personCap(1, capacity: 40.0, allocations: [7 => 5.0, 8 => 5.0])],
|
||||
childIds: [7, 8],
|
||||
);
|
||||
|
||||
$this->assertEqualsWithDelta(40.0, $row['availableHours'], 0.001);
|
||||
$this->assertSame(1, $row['peopleCount']);
|
||||
}
|
||||
|
||||
public function test_program_supply_deducts_commitments_outside_the_program(): void
|
||||
{
|
||||
// 40h capacity, 10h here, 15h on a project outside this program →
|
||||
// 25h is what this program could still claim.
|
||||
$row = $this->rollup(
|
||||
[$this->personCap(1, capacity: 40.0, allocations: [7 => 10.0, 99 => 15.0])],
|
||||
childIds: [7],
|
||||
);
|
||||
|
||||
$this->assertEqualsWithDelta(25.0, $row['availableHours'], 0.001);
|
||||
}
|
||||
|
||||
public function test_outside_commitments_never_push_a_person_below_zero(): void
|
||||
{
|
||||
// Person 1 is over-committed elsewhere beyond their capacity: they
|
||||
// bring nothing here, but must not subtract from person 2.
|
||||
$row = $this->rollup(
|
||||
[
|
||||
$this->personCap(1, capacity: 40.0, allocations: [7 => 1.0, 99 => 100.0]),
|
||||
$this->personCap(2, capacity: 20.0, allocations: [7 => 5.0]),
|
||||
],
|
||||
childIds: [7],
|
||||
);
|
||||
|
||||
$this->assertEqualsWithDelta(20.0, $row['availableHours'], 0.001, 'clamped at 0, not -60');
|
||||
}
|
||||
|
||||
public function test_people_not_allocated_to_the_program_contribute_nothing(): void
|
||||
{
|
||||
$row = $this->rollup(
|
||||
[$this->personCap(1, capacity: 40.0, allocations: [99 => 10.0])],
|
||||
childIds: [7],
|
||||
);
|
||||
|
||||
$this->assertEqualsWithDelta(0.0, $row['availableHours'], 0.001);
|
||||
$this->assertSame(0, $row['peopleCount']);
|
||||
}
|
||||
|
||||
/**
|
||||
* The regression this change exists for: a program with real headroom
|
||||
* and little booked used to read no_capacity because supply was measured
|
||||
* as hours-already-allocated. With capacity as supply it reads as buffer
|
||||
* — there IS room. (supply 40 vs demand 10 → ratio -0.75 → buffer.)
|
||||
*/
|
||||
public function test_program_with_headroom_and_light_booking_reads_as_buffer(): void
|
||||
{
|
||||
$row = $this->rollup(
|
||||
[$this->personCap(1, capacity: 40.0, allocations: [7 => 0.5])],
|
||||
childIds: [7],
|
||||
budgetedHours: 10.0,
|
||||
);
|
||||
|
||||
$this->assertNotSame('no_capacity', $row['verdict']);
|
||||
$this->assertSame('buffer', $row['verdict']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Work planned with nobody assigned stays no_capacity, distinct from
|
||||
* critical: it is an authoring gap ("assign people"), not a capacity
|
||||
* crisis, and painting it red trains readers to ignore red.
|
||||
*/
|
||||
public function test_program_with_no_people_is_no_capacity_not_critical(): void
|
||||
{
|
||||
$row = $this->rollup([], childIds: [7], budgetedHours: 400.0);
|
||||
|
||||
$this->assertSame('no_capacity', $row['verdict']);
|
||||
}
|
||||
|
||||
private function personCap(int $itemId, float $capacity, array $allocations): PersonAllocation
|
||||
{
|
||||
return new PersonAllocation(
|
||||
itemId: $itemId,
|
||||
userId: $itemId,
|
||||
displayName: 'Person '.$itemId,
|
||||
capacity: $capacity,
|
||||
allocations: $allocations,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs aggregateByProgram for one program over the given children,
|
||||
* feeding pre-built project rows so the ticket/demand side is fixed and
|
||||
* the assertions are about the capacity side. Demand sits on the first
|
||||
* child so totals are predictable regardless of child count.
|
||||
*
|
||||
* @param array<int, PersonAllocation> $people
|
||||
* @param int[] $childIds
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function rollup(array $people, array $childIds, float $budgetedHours = 50.0): array
|
||||
{
|
||||
$projectRows = [];
|
||||
foreach ($childIds as $cid) {
|
||||
$isFirst = $cid === $childIds[0];
|
||||
$projectRows[$cid] = [
|
||||
'projectId' => $cid,
|
||||
'name' => 'Child '.$cid,
|
||||
'openTicketCount' => $isFirst ? 10 : 0,
|
||||
'ticketsWithBudget' => $isFirst ? 10 : 0,
|
||||
'ticketsWithEffort' => 0,
|
||||
'budgetedHours' => $isFirst ? $budgetedHours : 0.0,
|
||||
'effortPoints' => 0.0,
|
||||
'verdict' => 'balanced', // aggregateByProgram sorts children by verdict rank
|
||||
];
|
||||
}
|
||||
|
||||
$resources = new ResourceSummary([7, 8], $people, [], [], 0.0, 0.0, 0.0, 0.0);
|
||||
|
||||
$rows = $this->analyzer->aggregateByProgram(
|
||||
$projectRows,
|
||||
[2 => $childIds],
|
||||
[2 => ['id' => 2, 'name' => 'Program 2']],
|
||||
$resources,
|
||||
$this->oneWeekPeriod(),
|
||||
);
|
||||
|
||||
return $rows[2];
|
||||
}
|
||||
}
|
||||
338
tests/Unit/app/Domain/Reports/Services/ReportEngineTest.php
Normal file
338
tests/Unit/app/Domain/Reports/Services/ReportEngineTest.php
Normal file
@@ -0,0 +1,338 @@
|
||||
<?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']);
|
||||
}
|
||||
}
|
||||
172
tests/Unit/app/Domain/Reports/Services/ReportsServiceTest.php
Normal file
172
tests/Unit/app/Domain/Reports/Services/ReportsServiceTest.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Reports\Services;
|
||||
|
||||
use Leantime\Core\Configuration\AppSettings as AppSettingCore;
|
||||
use Leantime\Core\Configuration\Environment as EnvironmentCore;
|
||||
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
|
||||
use Leantime\Domain\Reports\Repositories\Reports as ReportRepository;
|
||||
use Leantime\Domain\Reports\Services\Reports;
|
||||
use Leantime\Domain\Setting\Services\Setting as SettingsService;
|
||||
use Leantime\Domain\Sprints\Models\Sprints as SprintModel;
|
||||
use Leantime\Domain\Sprints\Repositories\Sprints as SprintRepository;
|
||||
use Leantime\Domain\Sprints\Services\Sprints as SprintService;
|
||||
use Leantime\Domain\Tickets\Repositories\Tickets as TicketRepository;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the sprint-burndown selection logic extracted from the
|
||||
* Reports\Controllers\Show controller into the Reports service, plus the permission-engine
|
||||
* security surface: the three by-projectId @api reads must stay gated against the REQUESTED
|
||||
* project, and the system/telemetry methods must never become RPC-reachable again.
|
||||
*/
|
||||
class ReportsServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/** Matches Jsonrpc::isApiMethod(): @api only at the start of a docblock line. */
|
||||
private function isApiExposed(string $method): bool
|
||||
{
|
||||
$doc = (new \ReflectionMethod(Reports::class, $method))->getDocComment();
|
||||
|
||||
return $doc !== false && preg_match('/^\s*\*\s*@api\b/m', $doc) === 1;
|
||||
}
|
||||
|
||||
/** The #[RequiresPermission] attribute instance on a method, or null. */
|
||||
private function permissionAttribute(string $method): ?\Leantime\Core\Auth\Permissions\RequiresPermission
|
||||
{
|
||||
$attributes = (new \ReflectionMethod(Reports::class, $method))
|
||||
->getAttributes(\Leantime\Core\Auth\Permissions\RequiresPermission::class);
|
||||
|
||||
return $attributes === [] ? null : $attributes[0]->newInstance();
|
||||
}
|
||||
|
||||
public function test_by_project_reads_are_rpc_exposed_and_gated_against_the_requested_project(): void
|
||||
{
|
||||
foreach (['getSprintBurndownForReport', 'getFullReport', 'getRealtimeReport'] as $method) {
|
||||
$this->assertTrue($this->isApiExposed($method), "$method should stay RPC-callable");
|
||||
|
||||
$attribute = $this->permissionAttribute($method);
|
||||
$this->assertNotNull($attribute, "$method must carry a #[RequiresPermission] dispatch gate");
|
||||
$this->assertSame('reports.view', $attribute->permission, $method);
|
||||
// projectIdParam binds the gate to the REQUESTED project — without it the enforcer
|
||||
// falls back to the session project and the cross-project RPC IDOR reopens.
|
||||
$this->assertSame('projectId', $attribute->projectIdParam, $method);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_system_and_telemetry_methods_are_not_rpc_reachable(): void
|
||||
{
|
||||
// dailyIngestion binds to session state; the others leak instance-wide aggregates or
|
||||
// mutate company-wide settings. None may carry a line-starting @api tag.
|
||||
foreach ([
|
||||
'dailyIngestion',
|
||||
'cronDailyIngestion',
|
||||
'getAnonymousTelemetry',
|
||||
'sendAnonymousTelemetry',
|
||||
'optOutTelemetry',
|
||||
'getProjectStatusReport',
|
||||
'generateTicketReactionsReport',
|
||||
] as $method) {
|
||||
$this->assertFalse($this->isApiExposed($method), "$method must NOT be RPC-callable");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the Reports service with every constructor dependency stubbed,
|
||||
* injecting the provided Sprints service (the only dependency the method
|
||||
* under test actually exercises).
|
||||
*/
|
||||
private function makeService(SprintService $sprintService): Reports
|
||||
{
|
||||
return new Reports(
|
||||
$this->make(AppSettingCore::class),
|
||||
$this->make(EnvironmentCore::class),
|
||||
$this->make(ProjectRepository::class),
|
||||
$this->make(SprintRepository::class),
|
||||
$this->make(ReportRepository::class),
|
||||
$this->make(SettingsService::class),
|
||||
$this->make(TicketRepository::class),
|
||||
$sprintService,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Sprints model with the given id.
|
||||
*/
|
||||
private function sprint(int $id): SprintModel
|
||||
{
|
||||
$sprint = new SprintModel;
|
||||
$sprint->id = $id;
|
||||
|
||||
return $sprint;
|
||||
}
|
||||
|
||||
public function test_returns_false_when_project_has_no_sprints(): void
|
||||
{
|
||||
$sprintService = $this->make(SprintService::class, [
|
||||
'getAllSprints' => fn () => [],
|
||||
]);
|
||||
|
||||
$result = $this->makeService($sprintService)->getSprintBurndownForReport(7, null);
|
||||
|
||||
$this->assertFalse($result['chart']);
|
||||
$this->assertFalse($result['currentSprintId']);
|
||||
}
|
||||
|
||||
public function test_uses_requested_sprint_id_and_echoes_it_back(): void
|
||||
{
|
||||
$sprintService = $this->make(SprintService::class, [
|
||||
'getAllSprints' => fn () => [$this->sprint(1), $this->sprint(2)],
|
||||
'getSprint' => fn ($id) => $this->sprint($id),
|
||||
'getSprintBurndown' => fn () => [['date' => '2024-01-01']],
|
||||
]);
|
||||
|
||||
$result = $this->makeService($sprintService)->getSprintBurndownForReport(7, 2);
|
||||
|
||||
$this->assertSame([['date' => '2024-01-01']], $result['chart']);
|
||||
$this->assertSame(2, $result['currentSprintId']);
|
||||
}
|
||||
|
||||
public function test_requested_sprint_id_is_echoed_even_when_sprint_missing(): void
|
||||
{
|
||||
$sprintService = $this->make(SprintService::class, [
|
||||
'getAllSprints' => fn () => [$this->sprint(1)],
|
||||
'getSprint' => fn () => false,
|
||||
]);
|
||||
|
||||
$result = $this->makeService($sprintService)->getSprintBurndownForReport(7, 99);
|
||||
|
||||
$this->assertFalse($result['chart']);
|
||||
$this->assertSame(99, $result['currentSprintId']);
|
||||
}
|
||||
|
||||
public function test_falls_back_to_current_sprint_when_none_requested(): void
|
||||
{
|
||||
$sprintService = $this->make(SprintService::class, [
|
||||
'getAllSprints' => fn () => [$this->sprint(1), $this->sprint(5)],
|
||||
'getCurrentSprintId' => fn () => 5,
|
||||
'getSprint' => fn ($id) => $this->sprint($id),
|
||||
'getSprintBurndown' => fn () => [['date' => '2024-02-02']],
|
||||
]);
|
||||
|
||||
$result = $this->makeService($sprintService)->getSprintBurndownForReport(7, null);
|
||||
|
||||
$this->assertSame([['date' => '2024-02-02']], $result['chart']);
|
||||
$this->assertSame(5, $result['currentSprintId']);
|
||||
}
|
||||
|
||||
public function test_falls_back_to_first_sprint_when_no_current_sprint(): void
|
||||
{
|
||||
$sprintService = $this->make(SprintService::class, [
|
||||
'getAllSprints' => fn () => [$this->sprint(11), $this->sprint(12)],
|
||||
'getCurrentSprintId' => fn () => false,
|
||||
'getSprintBurndown' => fn () => [['date' => '2024-03-03']],
|
||||
]);
|
||||
|
||||
$result = $this->makeService($sprintService)->getSprintBurndownForReport(7, null);
|
||||
|
||||
$this->assertSame([['date' => '2024-03-03']], $result['chart']);
|
||||
$this->assertSame(11, $result['currentSprintId']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user