OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
281
tests/Unit/app/Domain/Install/UpdateSql30524Test.php
Normal file
281
tests/Unit/app/Domain/Install/UpdateSql30524Test.php
Normal file
@@ -0,0 +1,281 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Install;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Leantime\Domain\Install\Repositories\Install;
|
||||
use Mockery;
|
||||
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Regression tests for migration update_sql_30524 (#3686) — the load-bearing
|
||||
* backfill that copies each goal's legacy zp_canvas_items.milestoneId into a
|
||||
* `tracked_by` edge on zp_entity_relationship. A mis-backfill silently corrupts
|
||||
* goal↔milestone links for every existing install (and it already had a
|
||||
* near-miss: it shipped unregistered in $dbUpdates once), so its behavior is
|
||||
* pinned here with a faked connection — no DB.
|
||||
*
|
||||
* Covers: correct edge direction, junk/non-numeric skipped, deleted /
|
||||
* non-milestone tickets skipped, SAME-PROJECT enforcement (a legacy
|
||||
* cross-project row is never promoted to an edge), NULL author for unknown,
|
||||
* idempotent re-run (existing edge not duplicated), and the table-guard no-op.
|
||||
*/
|
||||
class UpdateSql30524Test extends TestCase
|
||||
{
|
||||
use MockeryPHPUnitIntegration;
|
||||
|
||||
/**
|
||||
* Run update_sql_30524 against a fully-faked, TABLE-AWARE connection.
|
||||
*
|
||||
* @param array<int, object> $canvasGoals rows shaped {id, milestoneId, author, canvasId}
|
||||
* @param array<int, int> $liveMilestones ticket id => projectId for live milestones
|
||||
* @param array<int, int> $canvasProjects canvas id => projectId
|
||||
* @param array<int, array{0:int,1:int}> $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<int, array<string, mixed>>}
|
||||
*/
|
||||
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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user