OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Goalcanvas\Repositories;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Leantime\Domain\Goalcanvas\Repositories\Goalcanvas;
|
||||
use Mockery;
|
||||
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Repository-level regression tests for the goal↔milestone link chokepoints.
|
||||
* Two contracts here are load-bearing and were previously untested (every
|
||||
* service test mocks the repository away):
|
||||
*
|
||||
* 1. addGoalMilestoneLink fails CLOSED — the same-project product rule
|
||||
* (goal↔milestone links never cross projects) plus the live-milestone
|
||||
* type check are enforced in real SQL here and nowhere else.
|
||||
* 2. removeGoalMilestoneLink / removeAllGoalMilestoneLinks keep the legacy
|
||||
* milestoneId column in sync — without the clear, the column-union in
|
||||
* getGoalsByMilestone() resurrects an explicitly unlinked goal.
|
||||
*
|
||||
* Faked connection, no DB — the fakes model each table's terminal calls.
|
||||
*/
|
||||
class GoalcanvasMilestoneLinkTest extends TestCase
|
||||
{
|
||||
use MockeryPHPUnitIntegration;
|
||||
|
||||
/** @var array<int, array<string, mixed>> rows captured by edge inserts */
|
||||
private array $insertedEdges = [];
|
||||
|
||||
/** @var array<int, array<string, mixed>> update payloads captured on zp_canvas_items */
|
||||
private array $columnUpdates = [];
|
||||
|
||||
/**
|
||||
* Build a Goalcanvas repo whose dbConnection serves the given per-table
|
||||
* behavior.
|
||||
*
|
||||
* @param int|null $goalProjectId value('cb.projectId') for the goal lookup (null = goal missing/foreign)
|
||||
* @param object|null $milestoneRow first(['projectId']) for the milestone lookup (null = not a live milestone)
|
||||
* @param bool $edgeExists exists() for the dedup check inside the insert transaction
|
||||
* @param int $edgeDeleteCount delete() return for edge removals
|
||||
* @param int $columnUpdateCount update() return for the legacy-column clear
|
||||
*/
|
||||
private function repo(
|
||||
?int $goalProjectId = null,
|
||||
?object $milestoneRow = null,
|
||||
bool $edgeExists = false,
|
||||
int $edgeDeleteCount = 0,
|
||||
int $columnUpdateCount = 0
|
||||
): Goalcanvas {
|
||||
$this->insertedEdges = [];
|
||||
$this->columnUpdates = [];
|
||||
$inserted = &$this->insertedEdges;
|
||||
$updates = &$this->columnUpdates;
|
||||
|
||||
$builderFor = function (string $table) use ($goalProjectId, $milestoneRow, $edgeExists, $edgeDeleteCount, $columnUpdateCount, &$inserted, &$updates) {
|
||||
return new class($table, $goalProjectId, $milestoneRow, $edgeExists, $edgeDeleteCount, $columnUpdateCount, $inserted, $updates)
|
||||
{
|
||||
public function __construct(
|
||||
private string $table,
|
||||
private ?int $goalProjectId,
|
||||
private ?object $milestoneRow,
|
||||
private bool $edgeExists,
|
||||
private int $edgeDeleteCount,
|
||||
private int $columnUpdateCount,
|
||||
array &$inserted,
|
||||
array &$updates
|
||||
) {
|
||||
// Explicit reference assignment (not promotion) so the
|
||||
// captures stay version-proof across supported PHP.
|
||||
$this->inserted = &$inserted;
|
||||
$this->updates = &$updates;
|
||||
}
|
||||
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
private array $inserted;
|
||||
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
private array $updates;
|
||||
|
||||
/** Stands in for addGoalMilestoneLink's zp_canvas_items↔zp_canvas join. */
|
||||
public function join(...$a): static
|
||||
{
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function where(...$a): static
|
||||
{
|
||||
// Record scalar predicates so tests can pin WHICH rows an
|
||||
// update/delete was scoped to (a fake that discards its
|
||||
// where() arguments can't catch a lost predicate).
|
||||
if (count($a) === 2 && is_scalar($a[1])) {
|
||||
$this->wheres[$a[0]] = $a[1];
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> scalar where() predicates seen by this builder */
|
||||
public array $wheres = [];
|
||||
|
||||
/** Stands in for the in-transaction dedup's ->lockForUpdate(). */
|
||||
public function lockForUpdate(): static
|
||||
{
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** Stands in for addGoalMilestoneLink's ->value('cb.projectId') goal-project resolve. */
|
||||
public function value($column)
|
||||
{
|
||||
return $this->goalProjectId;
|
||||
}
|
||||
|
||||
/** Stands in for addGoalMilestoneLink's zp_tickets ->first(['projectId']) milestone lookup. */
|
||||
public function first($columns = ['*'])
|
||||
{
|
||||
return $this->milestoneRow;
|
||||
}
|
||||
|
||||
/** Stands in for the edge-dedup ->exists() inside the insert transaction. */
|
||||
public function exists(): bool
|
||||
{
|
||||
return $this->edgeExists;
|
||||
}
|
||||
|
||||
/** Stands in for the zp_entity_relationship edge insert. */
|
||||
public function insert($row): bool
|
||||
{
|
||||
$this->inserted[] = $row;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Stands in for the tracked_by edge ->delete() in the removal methods. */
|
||||
public function delete(): int
|
||||
{
|
||||
return $this->edgeDeleteCount;
|
||||
}
|
||||
|
||||
/** Stands in for the legacy zp_canvas_items.milestoneId column clear. */
|
||||
public function update(array $values): int
|
||||
{
|
||||
$this->updates[] = ['table' => $this->table, 'values' => $values, 'wheres' => $this->wheres];
|
||||
|
||||
return $this->columnUpdateCount;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
$conn = Mockery::mock(ConnectionInterface::class);
|
||||
$conn->shouldReceive('table')->andReturnUsing($builderFor);
|
||||
$conn->shouldReceive('transaction')->andReturnUsing(fn (callable $fn) => $fn());
|
||||
|
||||
$repo = (new \ReflectionClass(Goalcanvas::class))->newInstanceWithoutConstructor();
|
||||
$prop = new \ReflectionProperty(Goalcanvas::class, 'dbConnection');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue($repo, $conn);
|
||||
|
||||
return $repo;
|
||||
}
|
||||
|
||||
// ── addGoalMilestoneLink: the fail-closed same-project chokepoint ──
|
||||
|
||||
public function test_add_link_rejects_a_cross_project_milestone(): void
|
||||
{
|
||||
// Goal lives in project 1, milestone in project 2 — the product rule
|
||||
// (links never cross projects) must fail the write closed.
|
||||
$repo = $this->repo(goalProjectId: 1, milestoneRow: (object) ['projectId' => 2]);
|
||||
|
||||
$this->assertFalse($repo->addGoalMilestoneLink(5, 42, 7));
|
||||
$this->assertSame([], $this->insertedEdges, 'no edge may be written for a cross-project link');
|
||||
}
|
||||
|
||||
public function test_add_link_rejects_a_dead_or_non_milestone_ticket(): void
|
||||
{
|
||||
// The milestone lookup filters type='milestone' AND status<>-1 — a
|
||||
// task id or a soft-deleted milestone resolves to null.
|
||||
$repo = $this->repo(goalProjectId: 1, milestoneRow: null);
|
||||
|
||||
$this->assertFalse($repo->addGoalMilestoneLink(5, 42, 7));
|
||||
$this->assertSame([], $this->insertedEdges);
|
||||
}
|
||||
|
||||
public function test_add_link_rejects_an_unknown_or_non_goal_item(): void
|
||||
{
|
||||
// The goal lookup filters box='goal' + canvas type='goalcanvas' — a
|
||||
// foreign/shared-table id resolves to null (fail closed, no oracle).
|
||||
$repo = $this->repo(goalProjectId: null, milestoneRow: (object) ['projectId' => 1]);
|
||||
|
||||
$this->assertFalse($repo->addGoalMilestoneLink(5, 42, 7));
|
||||
$this->assertSame([], $this->insertedEdges);
|
||||
}
|
||||
|
||||
public function test_add_link_writes_a_correct_edge_for_a_same_project_milestone(): void
|
||||
{
|
||||
$repo = $this->repo(goalProjectId: 1, milestoneRow: (object) ['projectId' => 1]);
|
||||
|
||||
$this->assertTrue($repo->addGoalMilestoneLink(5, 42, 7));
|
||||
$this->assertCount(1, $this->insertedEdges);
|
||||
$edge = $this->insertedEdges[0];
|
||||
$this->assertSame(5, $edge['entityA']);
|
||||
$this->assertSame('GoalItem', $edge['entityAType']);
|
||||
$this->assertSame(42, $edge['entityB']);
|
||||
$this->assertSame('Ticket', $edge['entityBType']);
|
||||
$this->assertSame('tracked_by', $edge['relationship']);
|
||||
$this->assertSame(7, $edge['createdBy']);
|
||||
}
|
||||
|
||||
public function test_add_link_is_idempotent_when_the_edge_exists(): void
|
||||
{
|
||||
$repo = $this->repo(goalProjectId: 1, milestoneRow: (object) ['projectId' => 1], edgeExists: true);
|
||||
|
||||
$this->assertTrue($repo->addGoalMilestoneLink(5, 42, 7), 'an existing link reports success');
|
||||
$this->assertSame([], $this->insertedEdges, 'but is not duplicated');
|
||||
}
|
||||
|
||||
public function test_add_link_stores_unknown_author_as_null_not_zero(): void
|
||||
{
|
||||
$repo = $this->repo(goalProjectId: 1, milestoneRow: (object) ['projectId' => 1]);
|
||||
|
||||
$repo->addGoalMilestoneLink(5, 42, 0);
|
||||
|
||||
$this->assertNull($this->insertedEdges[0]['createdBy']);
|
||||
}
|
||||
|
||||
// ── removeGoalMilestoneLink / removeAll: the clear-on-unlink dual-write ──
|
||||
|
||||
public function test_unlink_clears_the_legacy_column_alongside_the_edge(): void
|
||||
{
|
||||
$repo = $this->repo(edgeDeleteCount: 1, columnUpdateCount: 1);
|
||||
|
||||
$this->assertTrue($repo->removeGoalMilestoneLink(5, 42));
|
||||
$this->assertCount(1, $this->columnUpdates, 'the legacy milestoneId column must be cleared with the edge');
|
||||
$this->assertSame(['milestoneId' => ''], $this->columnUpdates[0]['values']);
|
||||
// The clear must be SCOPED: only the goal's row, and only when the
|
||||
// column still points at the milestone being unlinked — dropping the
|
||||
// milestoneId predicate would blank an unrelated newer link.
|
||||
$this->assertSame(5, $this->columnUpdates[0]['wheres']['id'] ?? null);
|
||||
$this->assertSame('42', $this->columnUpdates[0]['wheres']['milestoneId'] ?? null);
|
||||
}
|
||||
|
||||
public function test_unlink_reports_success_when_only_the_stale_column_held_the_link(): void
|
||||
{
|
||||
// No edge row (already gone), but the legacy column still pointed at
|
||||
// the milestone — clearing it is a real unlink and must not read as a
|
||||
// failure.
|
||||
$repo = $this->repo(edgeDeleteCount: 0, columnUpdateCount: 1);
|
||||
|
||||
$this->assertTrue($repo->removeGoalMilestoneLink(5, 42));
|
||||
}
|
||||
|
||||
public function test_unlink_reports_failure_when_neither_store_held_the_link(): void
|
||||
{
|
||||
$repo = $this->repo(edgeDeleteCount: 0, columnUpdateCount: 0);
|
||||
|
||||
$this->assertFalse($repo->removeGoalMilestoneLink(5, 42));
|
||||
}
|
||||
|
||||
public function test_remove_all_links_clears_edges_and_the_legacy_column(): void
|
||||
{
|
||||
$repo = $this->repo(edgeDeleteCount: 3, columnUpdateCount: 1);
|
||||
|
||||
$this->assertTrue($repo->removeAllGoalMilestoneLinks(5));
|
||||
$this->assertCount(1, $this->columnUpdates);
|
||||
$this->assertSame(['milestoneId' => ''], $this->columnUpdates[0]['values']);
|
||||
}
|
||||
|
||||
public function test_remove_milestone_from_all_goals_clears_edges_and_columns(): void
|
||||
{
|
||||
$repo = $this->repo(edgeDeleteCount: 2, columnUpdateCount: 2);
|
||||
|
||||
$this->assertTrue($repo->removeMilestoneFromAllGoals(42));
|
||||
$this->assertNotEmpty($this->columnUpdates, 'the milestone-delete cascade must clear matching legacy columns');
|
||||
$this->assertSame(['milestoneId' => ''], $this->columnUpdates[0]['values']);
|
||||
$this->assertSame('42', $this->columnUpdates[0]['wheres']['milestoneId'] ?? null, 'only columns pointing at THIS milestone are cleared');
|
||||
}
|
||||
|
||||
public function test_remove_milestone_from_all_goals_reports_only_edge_deletions(): void
|
||||
{
|
||||
// PINS CURRENT BEHAVIOR: unlike removeGoalMilestoneLink /
|
||||
// removeAllGoalMilestoneLinks (which return deleted OR columnCleared),
|
||||
// the cascade returns only whether edges were deleted — a stale-column
|
||||
// -only cleanup reads as false. No caller branches on this today; if
|
||||
// the sibling semantics are ever unified, this test should fail and be
|
||||
// updated deliberately.
|
||||
$repo = $this->repo(edgeDeleteCount: 0, columnUpdateCount: 3);
|
||||
|
||||
$this->assertFalse($repo->removeMilestoneFromAllGoals(42));
|
||||
$this->assertNotEmpty($this->columnUpdates, 'stale columns are still cleared even though the return is false');
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user