OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
151
tests/Unit/app/Domain/Install/Services/InstallServiceTest.php
Normal file
151
tests/Unit/app/Domain/Install/Services/InstallServiceTest.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Install\Services;
|
||||
|
||||
use Leantime\Core\Configuration\AppSettings;
|
||||
use Leantime\Domain\Install\Repositories\Install as InstallRepository;
|
||||
use Leantime\Domain\Install\Services\Install as InstallService;
|
||||
use Leantime\Domain\Setting\Services\Setting as SettingService;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the Install service helpers extracted during the
|
||||
* thin-controller refactor (validateInstallInput, needsUpdate).
|
||||
*/
|
||||
class InstallServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/**
|
||||
* Builds a real Install service, allowing each dependency to be
|
||||
* overridden with a stub.
|
||||
*/
|
||||
private function makeService(
|
||||
?AppSettings $appSettings = null,
|
||||
?InstallRepository $installRepo = null,
|
||||
?SettingService $settingService = null,
|
||||
): InstallService {
|
||||
return new InstallService(
|
||||
$appSettings ?? $this->make(AppSettings::class),
|
||||
$installRepo ?? $this->make(InstallRepository::class),
|
||||
$settingService ?? $this->make(SettingService::class),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_validate_install_input_passes_for_complete_values(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
$service->validateInstallInput([
|
||||
'email' => 'admin@example.com',
|
||||
'firstname' => 'Ada',
|
||||
'lastname' => 'Lovelace',
|
||||
'company' => 'Analytical Engines',
|
||||
]);
|
||||
|
||||
// No exception thrown means success.
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_validate_install_input_throws_email_key_first(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
try {
|
||||
$service->validateInstallInput([
|
||||
'email' => '',
|
||||
'firstname' => '',
|
||||
'lastname' => '',
|
||||
'company' => '',
|
||||
]);
|
||||
$this->fail('Expected InvalidArgumentException was not thrown');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$this->assertSame('notification.enter_email', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_validate_install_input_throws_firstname_key_when_only_email_present(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
try {
|
||||
$service->validateInstallInput([
|
||||
'email' => 'admin@example.com',
|
||||
'firstname' => '',
|
||||
'lastname' => '',
|
||||
'company' => '',
|
||||
]);
|
||||
$this->fail('Expected InvalidArgumentException was not thrown');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$this->assertSame('notification.enter_firstname', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_validate_install_input_throws_lastname_key_when_company_also_missing(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
try {
|
||||
$service->validateInstallInput([
|
||||
'email' => 'admin@example.com',
|
||||
'firstname' => 'Ada',
|
||||
'lastname' => '',
|
||||
'company' => '',
|
||||
]);
|
||||
$this->fail('Expected InvalidArgumentException was not thrown');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$this->assertSame('notification.enter_lastname', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_validate_install_input_throws_company_key_last(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
try {
|
||||
$service->validateInstallInput([
|
||||
'email' => 'admin@example.com',
|
||||
'firstname' => 'Ada',
|
||||
'lastname' => 'Lovelace',
|
||||
'company' => '',
|
||||
]);
|
||||
$this->fail('Expected InvalidArgumentException was not thrown');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$this->assertSame('notification.enter_company', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_needs_update_is_true_when_versions_differ(): void
|
||||
{
|
||||
$appSettings = $this->make(AppSettings::class);
|
||||
$appSettings->dbVersion = '3.5.1';
|
||||
|
||||
$settingService = $this->make(SettingService::class, [
|
||||
'getSetting' => fn () => '3.5.0',
|
||||
]);
|
||||
|
||||
$needsUpdate = $this->makeService(
|
||||
appSettings: $appSettings,
|
||||
settingService: $settingService,
|
||||
)->needsUpdate();
|
||||
|
||||
$this->assertTrue($needsUpdate);
|
||||
}
|
||||
|
||||
public function test_needs_update_is_false_when_versions_match(): void
|
||||
{
|
||||
$appSettings = $this->make(AppSettings::class);
|
||||
$appSettings->dbVersion = '3.5.1';
|
||||
|
||||
$settingService = $this->make(SettingService::class, [
|
||||
'getSetting' => fn () => '3.5.1',
|
||||
]);
|
||||
|
||||
$needsUpdate = $this->makeService(
|
||||
appSettings: $appSettings,
|
||||
settingService: $settingService,
|
||||
)->needsUpdate();
|
||||
|
||||
$this->assertFalse($needsUpdate);
|
||||
}
|
||||
}
|
||||
89
tests/Unit/app/Domain/Install/UpdateSql30504Test.php
Normal file
89
tests/Unit/app/Domain/Install/UpdateSql30504Test.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Install;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Leantime\Domain\Install\Repositories\Install;
|
||||
use Leantime\Domain\Install\Services\SchemaBuilder;
|
||||
use Mockery;
|
||||
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Regression tests for migration update_sql_30504 (#3706) — the push-notification
|
||||
* columns on zp_access_tokens.
|
||||
*
|
||||
* The migration used to ALTER the table unguarded. Installs whose zp_access_tokens
|
||||
* was never created (update_sql_30400 swallowed a failed CREATE and still returned
|
||||
* success, so 3.4.0 was recorded as applied) then hit
|
||||
* "1146 Table 'zp_access_tokens' doesn't exist" here and could not upgrade at all.
|
||||
*
|
||||
* These pin the self-heal: create the table when it is missing, and leave it alone
|
||||
* when it is not — no DB, facades faked.
|
||||
*/
|
||||
class UpdateSql30504Test extends TestCase
|
||||
{
|
||||
use MockeryPHPUnitIntegration;
|
||||
|
||||
/**
|
||||
* Run update_sql_30504 with the Schema/DB facades faked.
|
||||
*
|
||||
* @param bool $tableExists what Schema::hasTable reports for zp_access_tokens
|
||||
* @return array{result: mixed, recreated: bool}
|
||||
*/
|
||||
private function runMigration(bool $tableExists): array
|
||||
{
|
||||
// swap() rather than shouldReceive(): the latter resolves the real facade root
|
||||
// first, and unit tests run with `database.default => []`, so building the
|
||||
// DatabaseManager blows up before any expectation is set.
|
||||
$schema = Mockery::mock();
|
||||
$schema->shouldReceive('hasTable')->with('zp_access_tokens')->andReturn($tableExists);
|
||||
// The column/index work is not under test here: accept the calls and skip the
|
||||
// closures so no Blueprint is needed.
|
||||
$schema->shouldReceive('table')->andReturnNull();
|
||||
$schema->shouldReceive('hasColumn')->andReturn(true);
|
||||
Schema::swap($schema);
|
||||
|
||||
$db = Mockery::mock();
|
||||
$db->shouldReceive('select')->andReturn([]);
|
||||
DB::swap($db);
|
||||
|
||||
$recreated = false;
|
||||
$builder = Mockery::mock(SchemaBuilder::class);
|
||||
$builder->shouldReceive('createAccessTokensTable')
|
||||
->andReturnUsing(function () use (&$recreated): void {
|
||||
$recreated = true;
|
||||
});
|
||||
app()->instance(SchemaBuilder::class, $builder);
|
||||
|
||||
$install = (new \ReflectionClass(Install::class))->newInstanceWithoutConstructor();
|
||||
|
||||
return ['result' => $install->update_sql_30504(), 'recreated' => $recreated];
|
||||
}
|
||||
|
||||
public function test_recreates_the_table_when_it_is_missing_instead_of_failing(): void
|
||||
{
|
||||
$run = $this->runMigration(tableExists: false);
|
||||
|
||||
$this->assertTrue(
|
||||
$run['recreated'],
|
||||
'A missing zp_access_tokens must be recreated, not left for the ALTER to die on (#3706)'
|
||||
);
|
||||
$this->assertTrue(
|
||||
$run['result'],
|
||||
'The migration must succeed on an install that reached 30504 without the table'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_leaves_an_existing_table_alone(): void
|
||||
{
|
||||
$run = $this->runMigration(tableExists: true);
|
||||
|
||||
$this->assertFalse(
|
||||
$run['recreated'],
|
||||
'An install that already has zp_access_tokens must not have it recreated'
|
||||
);
|
||||
$this->assertTrue($run['result'], 'The migration must still report success');
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
139
tests/Unit/app/Domain/Install/UpdateSql30526Test.php
Normal file
139
tests/Unit/app/Domain/Install/UpdateSql30526Test.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?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_30526 — the hygiene pass that
|
||||
* deletes goal↔milestone `tracked_by` edges violating the same-project product
|
||||
* rule (promoted by the pre-guard 30524/30525 backfill) and edges orphaned by
|
||||
* the pre-cascade generic ticket delete. Behavior pinned with a faked
|
||||
* connection — no DB.
|
||||
*
|
||||
* The migration issues two pluck reads on the aliased edge table (cross-project
|
||||
* first, then orphans) and one chunked whereIn-delete on the plain table; the
|
||||
* fake serves them in that order.
|
||||
*/
|
||||
class UpdateSql30526Test extends TestCase
|
||||
{
|
||||
use MockeryPHPUnitIntegration;
|
||||
|
||||
/**
|
||||
* @param int[] $crossProjectIds edge ids the cross-project read returns
|
||||
* @param int[] $orphanIds edge ids the orphan read returns
|
||||
* @return array{result: mixed, deleted: int[]}
|
||||
*/
|
||||
private function runMigration(array $crossProjectIds, array $orphanIds, bool $tablesExist = true): array
|
||||
{
|
||||
$deleted = [];
|
||||
// The two aliased pluck reads arrive in a fixed order (cross-project,
|
||||
// then orphans) — serve them from a queue shared across builder
|
||||
// instances.
|
||||
$pluckQueue = new \ArrayObject([$crossProjectIds, $orphanIds]);
|
||||
|
||||
$schema = Mockery::mock();
|
||||
$schema->shouldReceive('hasTable')->andReturn($tablesExist);
|
||||
|
||||
$conn = Mockery::mock(ConnectionInterface::class);
|
||||
$conn->shouldReceive('getSchemaBuilder')->andReturn($schema);
|
||||
$conn->shouldReceive('table')->andReturnUsing(
|
||||
function (string $table) use ($pluckQueue, &$deleted) {
|
||||
return new class($pluckQueue, $deleted)
|
||||
{
|
||||
private array $whereInIds = [];
|
||||
|
||||
public function __construct(private \ArrayObject $pluckQueue, private array &$deleted) {}
|
||||
|
||||
public function join(...$a): static
|
||||
{
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function leftJoin(...$a): static
|
||||
{
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function where(...$a): static
|
||||
{
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function whereColumn(...$a): static
|
||||
{
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function whereIn($column, $ids): static
|
||||
{
|
||||
$this->whereInIds = $ids;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function pluck($column)
|
||||
{
|
||||
$sets = $this->pluckQueue->getArrayCopy();
|
||||
$next = array_shift($sets);
|
||||
$this->pluckQueue->exchangeArray($sets);
|
||||
|
||||
return collect($next ?? []);
|
||||
}
|
||||
|
||||
public function delete(): int
|
||||
{
|
||||
foreach ($this->whereInIds as $id) {
|
||||
$this->deleted[] = (int) $id;
|
||||
}
|
||||
|
||||
return count($this->whereInIds);
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
$install = (new \ReflectionClass(Install::class))->newInstanceWithoutConstructor();
|
||||
$prop = new \ReflectionProperty(Install::class, 'connection');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue($install, $conn);
|
||||
|
||||
return ['result' => $install->update_sql_30526(), 'deleted' => $deleted];
|
||||
}
|
||||
|
||||
public function test_deletes_cross_project_and_orphaned_edges(): void
|
||||
{
|
||||
$out = $this->runMigration(crossProjectIds: [11, 12], orphanIds: [13]);
|
||||
|
||||
$this->assertTrue($out['result']);
|
||||
$this->assertSame([11, 12, 13], $out['deleted']);
|
||||
}
|
||||
|
||||
public function test_deduplicates_an_edge_that_is_both_cross_project_and_orphaned(): void
|
||||
{
|
||||
$out = $this->runMigration(crossProjectIds: [11], orphanIds: [11, 12]);
|
||||
|
||||
$this->assertTrue($out['result']);
|
||||
$this->assertSame([11, 12], $out['deleted'], 'an id in both sets is deleted once');
|
||||
}
|
||||
|
||||
public function test_a_clean_graph_deletes_nothing(): void
|
||||
{
|
||||
$out = $this->runMigration(crossProjectIds: [], orphanIds: []);
|
||||
|
||||
$this->assertTrue($out['result']);
|
||||
$this->assertSame([], $out['deleted']);
|
||||
}
|
||||
|
||||
public function test_is_a_no_op_when_the_required_tables_are_absent(): void
|
||||
{
|
||||
$out = $this->runMigration(crossProjectIds: [11], orphanIds: [12], tablesExist: false);
|
||||
|
||||
$this->assertTrue($out['result']);
|
||||
$this->assertSame([], $out['deleted'], 'missing schema short-circuits before any delete');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user