$canvasGoals rows shaped {id, milestoneId, author, canvasId} * @param array $liveMilestones ticket id => projectId for live milestones * @param array $canvasProjects canvas id => projectId * @param array $existingEdges [goalId, milestoneId] pairs already linked * @param bool $tablesExist Schema-guard toggle: false makes hasTable/hasColumn report missing tables (the no-op path) * @return array{result: mixed, inserted: array>} */ private function runMigration( array $canvasGoals, array $liveMilestones, array $canvasProjects, array $existingEdges, bool $tablesExist = true ): array { $inserted = []; $capture = function ($rows) use (&$inserted): void { foreach ($rows as $row) { $inserted[] = $row; } }; $schema = Mockery::mock(); $schema->shouldReceive('hasTable')->andReturn($tablesExist); $schema->shouldReceive('hasColumn')->andReturn($tablesExist); $conn = Mockery::mock(ConnectionInterface::class); $conn->shouldReceive('getSchemaBuilder')->andReturn($schema); $conn->shouldReceive('table')->andReturnUsing( fn (string $table) => $this->fakeBuilder($table, $canvasGoals, $liveMilestones, $canvasProjects, $existingEdges, $capture) ); $install = (new \ReflectionClass(Install::class))->newInstanceWithoutConstructor(); $prop = new \ReflectionProperty(Install::class, 'connection'); $prop->setAccessible(true); $prop->setValue($install, $conn); return ['result' => $install->update_sql_30524(), 'inserted' => $inserted]; } /** * The builder is table-aware: the migration reads goal rows (chunkById on * zp_canvas_items), live milestones with their project (get on zp_tickets), * goal projects via canvases (get on zp_canvas), and existing edges * (get on zp_entity_relationship) — each table serves its own shape. */ private function fakeBuilder( string $table, array $canvasGoals, array $liveMilestones, array $canvasProjects, array $existingEdges, callable $capture ): object { return new class($table, $canvasGoals, $liveMilestones, $canvasProjects, $existingEdges, $capture) { public function __construct( private string $table, private array $canvasGoals, private array $liveMilestones, private array $canvasProjects, private array $existingEdges, private $capture ) {} public function where(...$a): static { return $this; } public function whereNotNull(...$a): static { return $this; } public function whereIn(...$a): static { return $this; } public function select(...$a): static { return $this; } public function orderBy(...$a): static { return $this; } public function chunkById($count, $callback): bool { $callback(collect($this->canvasGoals)); return true; } public function get($columns = ['*']) { if ($this->table === 'zp_tickets') { return collect(array_map( fn ($id, $projectId) => (object) ['id' => $id, 'projectId' => $projectId], array_keys($this->liveMilestones), array_values($this->liveMilestones) )); } if ($this->table === 'zp_canvas') { return collect(array_map( fn ($id, $projectId) => (object) ['id' => $id, 'projectId' => $projectId], array_keys($this->canvasProjects), array_values($this->canvasProjects) )); } // zp_entity_relationship — the existing-edge dedup read. return collect(array_map( fn ($e) => (object) ['entityA' => $e[0], 'entityB' => $e[1]], $this->existingEdges )); } public function insert($rows): bool { ($this->capture)($rows); return true; } }; } private function goal(int $id, ?string $milestoneId, ?int $author, int $canvasId = 1): object { return (object) ['id' => $id, 'milestoneId' => $milestoneId, 'author' => $author, 'canvasId' => $canvasId]; } public function test_backfills_a_tracked_by_edge_in_the_correct_direction(): void { $out = $this->runMigration( canvasGoals: [$this->goal(5, '42', 7)], liveMilestones: [42 => 1], canvasProjects: [1 => 1], existingEdges: [], ); $this->assertTrue($out['result']); $this->assertCount(1, $out['inserted']); $edge = $out['inserted'][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_skips_junk_and_non_numeric_milestone_ids(): void { $out = $this->runMigration( canvasGoals: [$this->goal(6, 'abc', 1), $this->goal(7, ' ', 1), $this->goal(8, '4x', 1)], liveMilestones: [42 => 1], canvasProjects: [1 => 1], existingEdges: [], ); $this->assertSame([], $out['inserted'], 'non-numeric milestoneId values are skipped'); } public function test_skips_deleted_or_non_milestone_tickets(): void { // Goal points at ticket 99, which is not in the live-milestone set // (deleted, or demoted to a task). $out = $this->runMigration( canvasGoals: [$this->goal(9, '99', 1)], liveMilestones: [], canvasProjects: [1 => 1], existingEdges: [], ); $this->assertSame([], $out['inserted'], 'a milestone that is not live is not backfilled'); } public function test_skips_cross_project_legacy_rows(): void { // Product rule: goal↔milestone links are SAME-PROJECT only. A legacy // column row pointing at another project's milestone must not be // promoted into a first-class edge (goal's canvas 1 -> project 1; // milestone 42 lives in project 2). $out = $this->runMigration( canvasGoals: [$this->goal(5, '42', 7, canvasId: 1)], liveMilestones: [42 => 2], canvasProjects: [1 => 1], existingEdges: [], ); $this->assertSame([], $out['inserted'], 'a cross-project legacy row is never promoted to an edge'); } public function test_migrates_same_project_rows_alongside_skipped_cross_project_ones(): void { // Mixed chunk: goal 5's milestone is same-project (migrates), goal 6's // is cross-project (skipped) — the guard is per-pair, not per-chunk. $out = $this->runMigration( canvasGoals: [$this->goal(5, '42', 7, canvasId: 1), $this->goal(6, '43', 7, canvasId: 1)], liveMilestones: [42 => 1, 43 => 2], canvasProjects: [1 => 1], existingEdges: [], ); $this->assertCount(1, $out['inserted']); $this->assertSame(5, $out['inserted'][0]['entityA']); $this->assertSame(42, $out['inserted'][0]['entityB']); } public function test_is_idempotent_when_the_edge_already_exists(): void { $out = $this->runMigration( canvasGoals: [$this->goal(5, '42', 7)], liveMilestones: [42 => 1], canvasProjects: [1 => 1], existingEdges: [[5, 42]], ); $this->assertSame([], $out['inserted'], 'a re-run does not duplicate an existing edge'); } public function test_unknown_author_is_stored_as_null_not_zero(): void { $out = $this->runMigration( canvasGoals: [$this->goal(9, '42', null)], liveMilestones: [42 => 1], canvasProjects: [1 => 1], existingEdges: [], ); $this->assertCount(1, $out['inserted']); $this->assertNull($out['inserted'][0]['createdBy'], 'unknown author stays NULL, never 0'); } public function test_is_a_no_op_when_the_required_tables_are_absent(): void { $out = $this->runMigration( canvasGoals: [$this->goal(5, '42', 7)], liveMilestones: [42 => 1], canvasProjects: [1 => 1], existingEdges: [], tablesExist: false, ); $this->assertTrue($out['result']); $this->assertSame([], $out['inserted'], 'missing schema short-circuits before any write'); } }