Files
Leantime/tests/Unit/app/Domain/Goalcanvas/Repositories/GoalcanvasMilestoneLinkTest.php

293 lines
12 KiB
PHP

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