OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View 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);
}
}

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

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

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