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,911 @@
<?php
namespace Unit\app\Domain\Goalcanvas\Services;
use Codeception\Stub\Expected;
use Leantime\Core\Auth\Permissions\PermissionService;
use Leantime\Core\Exceptions\AuthorizationException;
use Leantime\Domain\Goalcanvas\Repositories\Goalcanvas as GoalcanvaRepository;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas as GoalcanvasService;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Unit\TestCase;
/**
* Unit tests for the Goalcanvas service:
* - progress math: goalProgress is (currentValue - startValue) / (endValue - startValue) * 100,
* clamped to 0..100, and child-goal value aggregation for linkAndReport goals;
* - the fail-closed by-id board/item CRUD chokepoint (every by-id op resolves the entity's real
* project via the inherited resolvers, scoped to the "goalcanvas" type, and authorizes a
* goals.* verb — reads soft-deny without loading, writes throw without writing).
*/
class GoalcanvasServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
private function allowingPermissions(): PermissionService
{
return $this->make(PermissionService::class, [
'authorize' => fn () => null,
'currentUserCan' => fn () => true,
]);
}
private function service(GoalcanvaRepository $repo, ?PermissionService $perms = null, ?ProjectService $projects = null): GoalcanvasService
{
$service = new GoalcanvasService($repo, $projects ?? $this->projectsWithAccess([]));
$service->setPermissionService($perms ?? $this->allowingPermissions());
return $service;
}
/** A Projects service granting access to exactly the given project ids. */
private function projectsWithAccess(array $projectIds): ProjectService
{
return $this->make(ProjectService::class, [
'getProjectsUserHasAccessTo' => fn () => array_map(static fn ($id) => ['id' => $id], $projectIds),
]);
}
private function denyingPermissions(): PermissionService
{
return $this->make(PermissionService::class, [
'authorize' => function (): void {
throw new AuthorizationException;
},
'currentUserCan' => fn () => false,
]);
}
/**
* A repo where goal #7 lives in project 9 and returns the given milestone
* chip rows from getMilestonesForGoals.
*/
private function goalRepoWithMilestones(array $milestones, int $projectId = 9): GoalcanvaRepository
{
return $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn (...$args) => $projectId,
'getCanvasItemProjectIds' => fn (...$args) => [7 => (int) $projectId],
'getMilestonesForGoals' => fn (...$args) => [7 => $milestones],
]);
}
private function milestone(int $id, string $statusType, int $percentDone, ?string $from, ?string $to, int $projectId = 9): array
{
return [
'id' => $id, 'headline' => "MS $id", 'color' => '#ccc', 'projectId' => $projectId,
'editFrom' => $from, 'editTo' => $to, 'status' => 3, 'statusType' => $statusType,
'percentDone' => $percentDone,
];
}
public function test_computes_goal_progress_as_percentage_of_range(): void
{
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasProjectId' => fn () => 9,
'getMilestonesForGoals' => fn () => [],
'getCanvasItemsById' => fn () => [
['id' => 1, 'setting' => 'linkonly', 'startValue' => 0.0, 'endValue' => 100.0, 'currentValue' => 50.0],
],
]);
$goals = $this->service($repo)->getCanvasItemsById(1);
$this->assertEqualsWithDelta(50, $goals[0]['goalProgress'], 0.01);
}
public function test_clamps_progress_between_zero_and_one_hundred(): void
{
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasProjectId' => fn () => 9,
'getMilestonesForGoals' => fn () => [],
'getCanvasItemsById' => fn () => [
['id' => 1, 'setting' => 'linkonly', 'startValue' => 0.0, 'endValue' => 100.0, 'currentValue' => 150.0],
['id' => 2, 'setting' => 'linkonly', 'startValue' => 0.0, 'endValue' => 100.0, 'currentValue' => -20.0],
],
]);
$goals = $this->service($repo)->getCanvasItemsById(1);
$this->assertSame(100, $goals[0]['goalProgress']);
$this->assertSame(0, $goals[1]['goalProgress']);
}
public function test_zero_range_yields_zero_progress(): void
{
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasProjectId' => fn () => 9,
'getMilestonesForGoals' => fn () => [],
'getCanvasItemsById' => fn () => [
['id' => 1, 'setting' => 'linkonly', 'startValue' => 50.0, 'endValue' => 50.0, 'currentValue' => 50.0],
],
]);
$goals = $this->service($repo)->getCanvasItemsById(1);
$this->assertSame(0, $goals[0]['goalProgress']);
}
public function test_child_goal_reporting_sums_by_setting(): void
{
// linkonly children contribute their own currentValue; linkAndReport
// children contribute their rolled-up childCurrentValue.
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => 9,
'getCanvasItemsByKPI' => fn () => [
['setting' => 'linkonly', 'currentValue' => 10.0, 'childCurrentValue' => 999.0],
['setting' => 'linkAndReport', 'currentValue' => 0.0, 'childCurrentValue' => 5.0],
],
]);
$sum = $this->service($repo)->getChildGoalsForReporting(1);
$this->assertEqualsWithDelta(15.0, $sum, 0.01);
}
// ---------------------------------------------------------------------
// Fail-closed by-id board/item CRUD chokepoint.
// ---------------------------------------------------------------------
public function test_get_canvas_items_returns_empty_for_foreign_board_without_loading(): void
{
$loaded = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasProjectId' => fn () => null,
'getCanvasItemsById' => function () use (&$loaded) {
$loaded++;
return [['id' => 1]];
},
]);
$this->assertSame([], $this->service($repo)->getCanvasItemsById(999));
$this->assertSame(0, $loaded, 'A foreign/unknown board must not have its goals read');
}
public function test_child_goal_reporting_returns_zero_for_foreign_parent(): void
{
$loaded = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => null,
'getCanvasItemsByKPI' => function () use (&$loaded) {
$loaded++;
return [];
},
]);
$this->assertSame(0, $this->service($repo)->getChildGoalsForReporting(999));
$this->assertSame(0, $loaded, 'A foreign/unknown parent goal must not have its children read');
}
public function test_get_goal_item_soft_denies_when_view_not_permitted(): void
{
$loaded = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => 9,
'getSingleCanvasItem' => function () use (&$loaded) {
$loaded++;
return ['id' => 1];
},
]);
$perms = $this->make(PermissionService::class, ['currentUserCan' => fn () => false]);
$this->assertFalse($this->service($repo, $perms)->getGoalItem(1));
$this->assertSame(0, $loaded, 'An unauthorized item returns false without loading (no oracle)');
}
public function test_update_goal_item_resolves_project_from_item_id_not_payload_canvas_id(): void
{
$resolvedItemId = null;
$wrote = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => function ($id) use (&$resolvedItemId) {
$resolvedItemId = $id;
return 9;
},
'editCanvasItem' => function () use (&$wrote) {
$wrote++;
},
]);
$this->service($repo)->updateGoalItem(['itemId' => 42, 'canvasId' => 9999, 'description' => 'x']);
$this->assertSame(42, $resolvedItemId, 'Project resolved from itemId, not the payload canvasId');
$this->assertSame(1, $wrote);
}
public function test_patch_goal_item_throws_and_never_writes_for_unresolved_item(): void
{
$patched = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => null,
'patchCanvasItem' => function () use (&$patched) {
$patched++;
return true;
},
]);
try {
$this->service($repo)->patchGoalItem(5, ['status' => 'x']);
$this->fail('Expected AuthorizationException');
} catch (AuthorizationException) {
}
$this->assertSame(0, $patched);
}
public function test_delete_goal_item_throws_and_never_deletes_for_unresolved_item(): void
{
$deleted = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => null,
'delCanvasItem' => function () use (&$deleted) {
$deleted++;
},
]);
try {
$this->service($repo)->deleteGoalItem(5);
$this->fail('Expected AuthorizationException');
} catch (AuthorizationException) {
}
$this->assertSame(0, $deleted);
}
public function test_create_goal_item_throws_and_never_inserts_for_unknown_board(): void
{
$inserted = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasProjectId' => fn () => null,
'addCanvasItem' => function () use (&$inserted) {
$inserted++;
return '1';
},
]);
try {
$this->service($repo)->createGoalItem(['canvasId' => 9999, 'box' => 'goal']);
$this->fail('Expected AuthorizationException');
} catch (AuthorizationException) {
}
$this->assertSame(0, $inserted);
}
public function test_create_goal_api_throws_for_unknown_board(): void
{
$inserted = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasProjectId' => fn () => null,
'createGoal' => function () use (&$inserted) {
$inserted++;
return '1';
},
]);
try {
$this->service($repo)->createGoal(['canvasId' => 9999, 'box' => 'goal']);
$this->fail('Expected AuthorizationException');
} catch (AuthorizationException) {
}
$this->assertSame(0, $inserted);
}
public function test_delete_goal_board_throws_for_unresolved_board(): void
{
$deleted = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasProjectId' => fn () => null,
'deleteCanvas' => function () use (&$deleted) {
$deleted++;
},
]);
try {
$this->service($repo)->deleteGoalBoard(5);
$this->fail('Expected AuthorizationException');
} catch (AuthorizationException) {
}
$this->assertSame(0, $deleted);
}
public function test_update_goalboard_throws_for_unresolved_board(): void
{
$updated = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasProjectId' => fn () => null,
'updateCanvas' => function () use (&$updated) {
$updated++;
return 1;
},
]);
try {
$this->service($repo)->updateGoalboard(['id' => 5, 'title' => 'x']);
$this->fail('Expected AuthorizationException');
} catch (AuthorizationException) {
}
$this->assertSame(0, $updated);
}
public function test_merge_goal_board_requires_both_boards_to_resolve(): void
{
$merged = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasProjectId' => fn ($id) => $id === 1 ? 9 : null,
'mergeCanvas' => function () use (&$merged) {
$merged++;
return true;
},
]);
try {
$this->service($repo)->mergeGoalBoard(2, 1);
$this->fail('Expected AuthorizationException');
} catch (AuthorizationException) {
}
$this->assertSame(0, $merged);
}
public function test_copy_goal_board_throws_when_source_unresolved(): void
{
$copied = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasProjectId' => fn () => null,
'copyCanvas' => function () use (&$copied) {
$copied++;
return 1;
},
]);
try {
$this->service($repo)->copyGoalBoard(5, 7, 1, 'Copy');
$this->fail('Expected AuthorizationException');
} catch (AuthorizationException) {
}
$this->assertSame(0, $copied);
}
// ---------------------------------------------------------------------
// Dual-write: syncing tracked_by edges from a single milestoneId write.
// ---------------------------------------------------------------------
/**
* Build a repo whose edge writers record into $added / $removed (passed by
* reference), so a test can assert exactly which links were created/removed.
*
* @param array<int, int> $currentEdges Milestone ids the goal is already linked to
* @param array<string, callable> $extraStubs Extra repo method stubs (the write path)
* @param array<int, array{0:int,1:int}> $added Receives [goalId, milestoneId] per add
* @param array<int, int> $removed Receives milestoneId per remove
*/
private function edgeRepo(array $currentEdges, array $extraStubs, array &$added, array &$removed): GoalcanvaRepository
{
return $this->make(GoalcanvaRepository::class, array_merge([
'getCanvasProjectId' => fn () => 9,
'getCanvasItemProjectId' => fn () => 9,
'getMilestoneIdsForGoal' => fn () => $currentEdges,
'addGoalMilestoneLink' => function ($goalId, $milestoneId, $userId) use (&$added) {
// $userId is required (no default) so the tests fail loudly if
// production ever stops passing the author argument.
$added[] = [(int) $goalId, (int) $milestoneId];
return true;
},
'removeGoalMilestoneLink' => function ($goalId, $milestoneId) use (&$removed) {
$removed[] = (int) $milestoneId;
return true;
},
], $extraStubs));
}
public function test_create_goal_links_milestone_edge_when_milestone_id_present(): void
{
$added = [];
$removed = [];
$repo = $this->edgeRepo([], ['createGoal' => fn () => '50'], $added, $removed);
$this->service($repo)->createGoal(['canvasId' => 9, 'milestoneId' => 42]);
$this->assertSame([[50, 42]], $added, 'The new goal is linked to the given milestone');
$this->assertSame([], $removed);
}
public function test_create_goal_item_links_milestone_edge_when_milestone_id_present(): void
{
$added = [];
$removed = [];
$repo = $this->edgeRepo([], ['addCanvasItem' => fn () => '50'], $added, $removed);
$this->service($repo)->createGoalItem(['canvasId' => 9, 'box' => 'goal', 'milestoneId' => 42]);
$this->assertSame([[50, 42]], $added, 'A new goal item is linked to the given milestone');
$this->assertSame([], $removed);
}
public function test_update_goal_item_links_milestone_edge_when_milestone_id_present(): void
{
$added = [];
$removed = [];
$repo = $this->edgeRepo([], ['editCanvasItem' => fn () => null], $added, $removed);
$this->service($repo)->updateGoalItem(['itemId' => 7, 'milestoneId' => 42]);
$this->assertSame([[7, 42]], $added);
$this->assertSame([], $removed);
}
public function test_patch_goal_item_links_milestone_edge_when_milestone_id_present(): void
{
$added = [];
$removed = [];
$repo = $this->edgeRepo([], ['patchCanvasItem' => fn () => true], $added, $removed);
$this->service($repo)->patchGoalItem(7, ['milestoneId' => 42]);
$this->assertSame([[7, 42]], $added);
$this->assertSame([], $removed);
}
public function test_patch_goal_item_skips_edge_sync_when_patch_fails(): void
{
$added = [];
$removed = [];
// patchCanvasItem returns false → the milestoneId edge sync must NOT run,
// else the tracked_by edges would drift from the (unchanged) column.
$repo = $this->edgeRepo([], ['patchCanvasItem' => fn () => false], $added, $removed);
$result = $this->service($repo)->patchGoalItem(7, ['milestoneId' => 42]);
$this->assertFalse($result);
$this->assertSame([], $added, 'no edge added when the underlying patch failed');
$this->assertSame([], $removed);
}
public function test_patch_goal_item_ignores_non_numeric_milestone_id(): void
{
$added = [];
$removed = [];
$repo = $this->edgeRepo([], ['patchCanvasItem' => fn () => true], $added, $removed);
// '42abc' must not cast to milestone edge 42.
$this->service($repo)->patchGoalItem(7, ['milestoneId' => '42abc']);
$this->assertSame([], $added, 'a non-numeric milestoneId creates no edge');
$this->assertSame([], $removed);
}
public function test_delete_goal_item_removes_all_edges_on_successful_delete(): void
{
$deleted = 0;
$cleared = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => 9,
'delCanvasItem' => function () use (&$deleted) {
$deleted++;
},
'removeAllGoalMilestoneLinks' => function ($goalId) use (&$cleared) {
$cleared = (int) $goalId;
return true;
},
]);
$this->service($repo)->deleteGoalItem(5);
$this->assertSame(1, $deleted);
$this->assertSame(5, $cleared, 'deleting a goal item clears its tracked_by edges');
}
public function test_empty_milestone_id_clears_existing_edges_without_adding(): void
{
$added = [];
$removed = [];
$repo = $this->edgeRepo([11], ['editCanvasItem' => fn () => null], $added, $removed);
$this->service($repo)->updateGoalItem(['itemId' => 7, 'milestoneId' => '']);
$this->assertSame([], $added, 'An empty milestoneId adds nothing');
$this->assertSame([11], $removed, 'It unlinks the existing edge');
}
public function test_zero_milestone_id_clears_existing_edges_without_adding(): void
{
$added = [];
$removed = [];
$repo = $this->edgeRepo([11], ['patchCanvasItem' => fn () => true], $added, $removed);
$this->service($repo)->patchGoalItem(7, ['milestoneId' => '0']);
$this->assertSame([], $added, 'A "0" milestoneId adds nothing');
$this->assertSame([11], $removed, 'It unlinks the existing edge');
}
public function test_unchanged_milestone_id_does_not_churn_edges(): void
{
$added = [];
$removed = [];
$repo = $this->edgeRepo([42], ['editCanvasItem' => fn () => null], $added, $removed);
$this->service($repo)->updateGoalItem(['itemId' => 7, 'milestoneId' => 42]);
$this->assertSame([], $added, 'Re-saving the same milestone neither adds');
$this->assertSame([], $removed, 'nor removes an edge');
}
public function test_missing_milestone_id_key_leaves_edges_untouched(): void
{
// No milestoneId key at all (e.g. a description-only edit) must not
// touch edges — the sync only runs when the key is present.
$synced = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => 9,
'editCanvasItem' => fn () => null,
'getMilestoneIdsForGoal' => function () use (&$synced) {
$synced++;
return [];
},
]);
$this->service($repo)->updateGoalItem(['itemId' => 7, 'description' => 'x']);
$this->assertSame(0, $synced, 'Edge sync must not run when milestoneId is absent');
}
// Multi-milestone chip UI actions + report read (edge model).
// ---------------------------------------------------------------------
public function test_add_milestone_to_goal_authorizes_edit_and_links(): void
{
$linked = null;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => 9,
'addGoalMilestoneLink' => function ($goalId, $milestoneId, $userId = null) use (&$linked) {
$linked = [(int) $goalId, (int) $milestoneId];
return true;
},
]);
$this->assertTrue($this->service($repo)->addMilestoneToGoal(7, 42));
$this->assertSame([7, 42], $linked, 'The link is created against the resolved goal');
}
public function test_add_milestone_to_goal_throws_and_never_links_for_foreign_goal(): void
{
$linked = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => null,
'addGoalMilestoneLink' => function () use (&$linked) {
$linked++;
return true;
},
]);
try {
$this->service($repo)->addMilestoneToGoal(999, 42);
$this->fail('Expected AuthorizationException');
} catch (AuthorizationException) {
}
$this->assertSame(0, $linked, 'A foreign/unknown goal must not have a milestone linked');
}
public function test_add_milestone_to_goal_throws_and_never_links_when_edit_denied(): void
{
$linked = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => 9,
'addGoalMilestoneLink' => function () use (&$linked) {
$linked++;
return true;
},
]);
try {
$this->service($repo, $this->denyingPermissions())->addMilestoneToGoal(7, 42);
$this->fail('Expected AuthorizationException');
} catch (AuthorizationException) {
}
$this->assertSame(0, $linked, 'EDIT-denied on the goal project must not link');
}
public function test_remove_milestone_from_goal_authorizes_edit_and_unlinks(): void
{
$unlinked = null;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => 9,
'removeGoalMilestoneLink' => function ($goalId, $milestoneId) use (&$unlinked) {
$unlinked = [(int) $goalId, (int) $milestoneId];
return true;
},
]);
$this->assertTrue($this->service($repo)->removeMilestoneFromGoal(7, 42));
$this->assertSame([7, 42], $unlinked);
}
public function test_remove_milestone_from_goal_throws_and_never_unlinks_for_foreign_goal(): void
{
$unlinked = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => null,
'removeGoalMilestoneLink' => function () use (&$unlinked) {
$unlinked++;
return true;
},
]);
try {
$this->service($repo)->removeMilestoneFromGoal(999, 42);
$this->fail('Expected AuthorizationException');
} catch (AuthorizationException) {
}
$this->assertSame(0, $unlinked);
}
public function test_get_goal_milestones_returns_chips_and_summarizes_by_status(): void
{
// Chips carry their projectId and pass the accessible-projects strip.
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => 9,
'getMilestonesForGoals' => fn () => [
7 => [
['id' => 1, 'headline' => 'A', 'statusType' => 'DONE', 'projectId' => 9],
['id' => 2, 'headline' => 'B', 'statusType' => 'INPROGRESS', 'projectId' => 9],
['id' => 3, 'headline' => 'C', 'statusType' => 'NEW', 'projectId' => 9],
['id' => 4, 'headline' => 'D', 'statusType' => 'NEW', 'projectId' => 9],
],
],
]);
$result = $this->service($repo, projects: $this->projectsWithAccess([9]))->getGoalMilestones(7);
$this->assertCount(4, $result['milestones']);
$this->assertSame(
['total' => 4, 'done' => 1, 'inProgress' => 1, 'notStarted' => 2],
$result['summary'],
);
}
public function test_get_goal_milestones_strips_legacy_cross_project_chips_and_counts_only_shown(): void
{
// Same defensive strip as the rollup reads: a legacy cross-project row
// (milestone in project 8, caller can only access 9) must not surface
// in the editor chips, and the summary counts what is actually shown.
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => 9,
'getMilestonesForGoals' => fn () => [
7 => [
['id' => 1, 'headline' => 'Mine', 'statusType' => 'DONE', 'projectId' => 9],
['id' => 2, 'headline' => 'Foreign', 'statusType' => 'INPROGRESS', 'projectId' => 8],
],
],
]);
$result = $this->service($repo, projects: $this->projectsWithAccess([9]))->getGoalMilestones(7);
$this->assertCount(1, $result['milestones']);
$this->assertSame('Mine', $result['milestones'][0]['headline']);
$this->assertSame(
['total' => 1, 'done' => 1, 'inProgress' => 0, 'notStarted' => 0],
$result['summary'],
);
}
public function test_get_goal_milestones_soft_denies_foreign_goal_without_loading(): void
{
$loaded = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => null,
'getMilestonesForGoals' => function () use (&$loaded) {
$loaded++;
return [];
},
]);
$result = $this->service($repo)->getGoalMilestones(999);
$this->assertSame([], $result['milestones']);
$this->assertSame(['total' => 0, 'done' => 0, 'inProgress' => 0, 'notStarted' => 0], $result['summary']);
$this->assertSame(0, $loaded, 'A foreign/unknown goal must not have its milestones read (no oracle)');
}
public function test_get_goal_milestones_soft_denies_when_view_not_permitted(): void
{
$loaded = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => 9,
'getMilestonesForGoals' => function () use (&$loaded) {
$loaded++;
return [7 => [['id' => 1, 'headline' => 'A', 'statusType' => 'DONE']]];
},
]);
$perms = $this->make(PermissionService::class, ['currentUserCan' => fn () => false]);
$result = $this->service($repo, $perms)->getGoalMilestones(7);
$this->assertSame([], $result['milestones']);
$this->assertSame(0, $loaded, 'VIEW-denied returns the empty shape without loading');
}
public function test_get_milestones_for_goals_omits_unauthorized_goals(): void
{
// goal 1 lives in project 7 (VIEW allowed), goal 2 in project 8 (denied) —
// the report-read path must present the authorized goal and drop the rest.
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectIds' => fn () => [1 => 7, 2 => 8],
'getMilestonesForGoals' => fn (array $ids) => in_array(1, $ids, true)
? [1 => [['id' => 10, 'headline' => 'M1', 'projectId' => 7]]]
: [],
]);
$perms = $this->make(PermissionService::class, [
'currentUserCan' => fn (string $permission, ?int $projectId = null) => $projectId === 7,
]);
$result = $this->service($repo, $perms, $this->projectsWithAccess([7]))->getMilestonesForGoals([1, 2]);
$this->assertArrayHasKey(1, $result, 'authorized goal is present');
$this->assertArrayNotHasKey(2, $result, 'unauthorized goal is omitted');
$this->assertSame([['id' => 10, 'headline' => 'M1', 'projectId' => 7]], $result[1]);
}
public function test_get_goals_by_milestone_soft_denies_unknown_or_foreign_milestone(): void
{
// The MCP getGoalsByMilestone tool wraps this verbatim, so the service
// itself must gate: an unknown/non-milestone id returns [] without
// reading any goals (no oracle).
$read = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getMilestoneProjectId' => fn () => null,
'getGoalsByMilestone' => function () use (&$read) {
$read++;
return [['id' => 1]];
},
]);
$this->assertSame([], $this->service($repo)->getGoalsByMilestone(999));
$this->assertSame(0, $read, 'goals must not be read for an unresolvable milestone');
}
public function test_get_goals_by_milestone_soft_denies_when_view_not_permitted(): void
{
$read = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getMilestoneProjectId' => fn () => 8,
'getGoalsByMilestone' => function () use (&$read) {
$read++;
return [['id' => 1]];
},
]);
$perms = $this->make(PermissionService::class, ['currentUserCan' => fn () => false]);
$this->assertSame([], $this->service($repo, $perms)->getGoalsByMilestone(5));
$this->assertSame(0, $read, 'VIEW-denied returns [] without reading goals');
}
public function test_get_goals_by_milestone_returns_goals_for_an_accessible_milestone(): void
{
$repo = $this->make(GoalcanvaRepository::class, [
'getMilestoneProjectId' => fn () => 9,
'getGoalsByMilestone' => fn () => [['id' => 1, 'title' => 'G']],
]);
$this->assertSame(
[['id' => 1, 'title' => 'G']],
$this->service($repo)->getGoalsByMilestone(5)
);
}
public function test_get_milestones_for_goals_is_empty_safe(): void
{
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectIds' => fn () => [],
]);
$this->assertSame([], $this->service($repo)->getMilestonesForGoals([]));
}
// ─── Milestone rollup / mobile Progress (many-to-many) ────────────────
public function test_get_goal_rollup_aggregates_status_progress_and_span(): void
{
$repo = $this->goalRepoWithMilestones([
$this->milestone(1, 'INPROGRESS', 40, '2025-01-10 00:00:00', '2025-03-01 00:00:00'),
$this->milestone(2, 'NEW', 0, '2025-02-01 00:00:00', '2025-04-15 00:00:00'),
$this->milestone(3, 'DONE', 100, '2024-12-01 00:00:00', '2025-01-20 00:00:00'),
]);
$rollup = $this->service($repo, null, $this->projectsWithAccess([9]))->getGoalRollup(7);
$this->assertSame(3, $rollup['total']);
$this->assertSame(1, $rollup['done']);
$this->assertSame(1, $rollup['inProgress']);
$this->assertSame(1, $rollup['notStarted']);
$this->assertSame(47, $rollup['percentComplete']); // round((40+0+100)/3)
$this->assertSame('2024-12-01 00:00:00', $rollup['startDate']); // earliest start
$this->assertSame('2025-04-15 00:00:00', $rollup['endDate']); // latest due
$this->assertSame(1, $rollup['currentMilestoneId']); // first not-done
}
public function test_get_goal_rollup_skips_zero_sentinel_dates(): void
{
$repo = $this->goalRepoWithMilestones([
$this->milestone(1, 'INPROGRESS', 50, '0000-00-00 00:00:00', '2025-05-01 00:00:00'),
$this->milestone(2, 'NEW', 0, '2025-01-01 00:00:00', '0000-00-00 00:00:00'),
]);
$rollup = $this->service($repo, null, $this->projectsWithAccess([9]))->getGoalRollup(7);
$this->assertSame('2025-01-01 00:00:00', $rollup['startDate']); // m1 start skipped
$this->assertSame('2025-05-01 00:00:00', $rollup['endDate']); // m2 due skipped
}
public function test_get_milestones_by_goal_strips_inaccessible_project_milestones(): void
{
$repo = $this->goalRepoWithMilestones([
$this->milestone(1, 'NEW', 0, null, null, projectId: 9),
$this->milestone(2, 'NEW', 0, null, null, projectId: 99), // not accessible
]);
// goal is in project 9 (VIEW ok); only project 9 is accessible.
$list = $this->service($repo, null, $this->projectsWithAccess([9]))->getMilestonesByGoal(7);
$this->assertCount(1, $list);
$this->assertSame(1, $list[0]['id']);
}
public function test_get_milestones_by_goal_soft_denies_unauthorized_goal(): void
{
$repo = $this->goalRepoWithMilestones([
$this->milestone(1, 'NEW', 0, null, null),
]);
$list = $this->service($repo, $this->denyingPermissions(), $this->projectsWithAccess([9]))
->getMilestonesByGoal(7);
$this->assertSame([], $list);
}
public function test_get_goal_rollup_returns_empty_shape_for_foreign_goal(): void
{
// getCanvasItemProjectId returns null (missing/foreign/wrong type) -> deny.
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => null,
]);
$rollup = $this->service($repo)->getGoalRollup(7);
$this->assertSame(0, $rollup['total']);
$this->assertNull($rollup['currentMilestoneId']);
}
public function test_add_milestone_to_goal_does_not_unlink_others_many_to_many(): void
{
// Marcel's correction: a milestone can belong to multiple goals, so
// linking it to a goal must NOT unlink it from any other goal.
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => 9,
'addGoalMilestoneLink' => Expected::once(fn ($goalId, $milestoneId, $userId = null) => true),
'removeMilestoneFromAllGoals' => Expected::never(),
]);
$this->assertTrue($this->service($repo)->addMilestoneToGoal(7, 42));
}
}