OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View 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 21Jun 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());
}
}

View 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];
}
}

View 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']);
}
}

View 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']);
}
}