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,229 @@
<?php
namespace Unit\app\Domain\Projects\Repositories;
use Illuminate\Database\MySqlConnection;
use Leantime\Core\Db\DatabaseHelper;
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
use Unit\TestCase;
/**
* Access-logic tests for the Projects repository (#3710 / #3709).
*
* The bug: an admin/owner with zero project memberships and no public projects
* saw an empty sidebar, because getProjectsUserHasAccessTo() lacked the
* admin/owner blanket-access branch that its sibling getUserProjects() already
* had. #3710 extracts the shared rule into accessibleProjectPredicate() so the
* two paths can't drift again.
*
* These tests pin the predicate's exact composition (member OR public OR
* client-scoped OR admin/owner) and prove both callers feed it the right
* client clause — without a live DB, by capturing the access closure each
* query builds and running it through a recording spy. This is the
* authorization surface Marcel flagged as the merge gate (CR #1): the admin
* blanket must be present (case a), no extra branch may over-grant to a
* non-admin non-member (case b), and the client-scope must use the intended
* key (case c).
*/
class ProjectsAccessTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
/**
* A minimal query-builder stand-in that records where/orWhere-style calls
* and returns itself so a fluent chain can run against it.
*/
private function clauseSpy(): object
{
return new class
{
/** @var array<int, array{0: string, 1: array<int, mixed>}> */
public array $calls = [];
public function where(...$args): static
{
$this->calls[] = ['where', $args];
return $this;
}
public function orWhere(...$args): static
{
$this->calls[] = ['orWhere', $args];
return $this;
}
public function whereColumn(...$args): static
{
$this->calls[] = ['whereColumn', $args];
return $this;
}
public function orWhereNull(...$args): static
{
$this->calls[] = ['orWhereNull', $args];
return $this;
}
};
}
/**
* A query-builder stand-in that runs every where(Closure) it receives
* through a probe and, when it recognises the access-predicate group (the
* one that adds the admin/owner branch), captures that closure and
* short-circuits the rest of the query build via a sentinel exception.
*/
private function capturingBuilder(): object
{
$test = $this;
return new class($test)
{
public ?\Closure $accessClosure = null;
private object $test;
public function __construct(object $test)
{
$this->test = $test;
}
public function where($arg = null): static
{
if ($arg instanceof \Closure) {
$probe = $this->test->probeClosure($arg);
foreach ($probe->calls as $call) {
if ($call === ['orWhere', ['requestingUser.role', '>=', 40]]) {
$this->accessClosure = $arg;
// Stop the query build here — the rest is irrelevant.
throw new \RuntimeException('LT_ACCESS_CAPTURED');
}
}
}
return $this;
}
public function __call(string $name, array $args): static
{
return $this;
}
};
}
/** Run a closure through a fresh clause spy and return the spy. */
public function probeClosure(\Closure $closure): object
{
$spy = $this->clauseSpy();
$closure($spy);
return $spy;
}
/**
* Build a Projects repo whose connection hands back the capturing builder
* and whose db helper returns a harmless wrapped column, so a real access
* query can be built up to (and only to) the access predicate.
*/
private function repoWithCapturingQuery(object $builder): ProjectRepository
{
$connection = $this->make(MySqlConnection::class, [
'table' => fn ($table = null) => $builder,
'raw' => fn ($value) => $value,
]);
$repo = $this->make(ProjectRepository::class, []);
$connProp = new \ReflectionProperty(ProjectRepository::class, 'connection');
$connProp->setAccessible(true);
$connProp->setValue($repo, $connection);
$helperProp = new \ReflectionProperty(ProjectRepository::class, 'dbHelper');
$helperProp->setAccessible(true);
$helperProp->setValue($repo, $this->make(DatabaseHelper::class, [
'wrapColumn' => fn ($column) => '`'.$column.'`',
]));
return $repo;
}
public function test_shared_predicate_grants_member_public_admin_and_delegates_client(): void
{
$repo = $this->make(ProjectRepository::class, []);
$method = new \ReflectionMethod(ProjectRepository::class, 'accessibleProjectPredicate');
$method->setAccessible(true);
$q = $this->clauseSpy();
$clientClause = fn ($q2) => null;
$method->invoke($repo, $q, 42, $clientClause);
// Exactly four access branches — a fifth would silently widen access.
$this->assertCount(4, $q->calls, 'The access predicate must add exactly four branches.');
$this->assertSame(['where', ['relation.userId', 42]], $q->calls[0], 'member');
$this->assertSame(['orWhere', ['project.psettings', 'all']], $q->calls[1], 'public');
$this->assertSame('orWhere', $q->calls[2][0], 'client (delegated to caller clause)');
$this->assertSame($clientClause, $q->calls[2][1][0], 'the caller-supplied client clause is forwarded unchanged');
$this->assertSame(['orWhere', ['requestingUser.role', '>=', 40]], $q->calls[3], 'admin/owner blanket');
}
public function test_get_projects_user_has_access_to_scopes_client_clause_to_passed_client_id(): void
{
$builder = $this->capturingBuilder();
$repo = $this->repoWithCapturingQuery($builder);
try {
$repo->getProjectsUserHasAccessTo(42, 'all', 7);
} catch (\RuntimeException $e) {
$this->assertSame('LT_ACCESS_CAPTURED', $e->getMessage());
}
$this->assertInstanceOf(\Closure::class, $builder->accessClosure, 'access predicate group was not built');
$clientClause = $this->clientClauseFrom($builder->accessClosure);
$sub = $this->clauseSpy();
$clientClause($sub);
$this->assertSame(['where', ['project.psettings', 'clients']], $sub->calls[0]);
$this->assertSame(['where', ['project.clientId', 7]], $sub->calls[1], 'client-shared access must scope to the passed client id');
}
public function test_get_user_projects_all_scopes_client_clause_to_own_client_column(): void
{
$builder = $this->capturingBuilder();
$repo = $this->repoWithCapturingQuery($builder);
try {
$repo->getUserProjects(42, 'all', null, 'all');
} catch (\RuntimeException $e) {
$this->assertSame('LT_ACCESS_CAPTURED', $e->getMessage());
}
$this->assertInstanceOf(\Closure::class, $builder->accessClosure, 'access predicate group was not built');
$clientClause = $this->clientClauseFrom($builder->accessClosure);
$sub = $this->clauseSpy();
$clientClause($sub);
$this->assertSame(['where', ['project.psettings', 'clients']], $sub->calls[0]);
$this->assertSame(
['whereColumn', ['project.clientId', 'requestingUser.clientId']],
$sub->calls[1],
'getUserProjects(all) must match the requesting user\'s own client column'
);
}
/** Pull the delegated client clause (the 3rd branch) out of an access closure. */
private function clientClauseFrom(\Closure $accessClosure): \Closure
{
$spy = $this->probeClosure($accessClosure);
$this->assertSame('orWhere', $spy->calls[2][0]);
$this->assertInstanceOf(\Closure::class, $spy->calls[2][1][0]);
return $spy->calls[2][1][0];
}
}

View File

@@ -0,0 +1,265 @@
<?php
namespace Unit\app\Domain\Projects\Services;
use Leantime\Domain\Notifications\Models\Notification;
use PHPUnit\Framework\TestCase;
/**
* Tests the notification filtering helper methods that were extracted
* from Projects\Services\Projects. These methods are private, so we
* test the logic by reimplementing the core algorithms against the
* Notification model — verifying the model's contract that the service depends on.
*
* The actual service integration (filterUsersByProjectRelevance etc.)
* is tested via acceptance tests that exercise the full notification flow.
*/
class NotificationFilteringTest extends TestCase
{
/**
* Helper: determines if a user is involved in a notification entity (same logic as the private method).
*/
private function isUserInvolved(int $userId, Notification $notification): bool
{
$entity = $notification->entity;
if (is_array($entity)) {
if (isset($entity['editorId']) && (int) $entity['editorId'] === $userId) {
return true;
}
if (isset($entity['userId']) && (int) $entity['userId'] === $userId) {
return true;
}
if (isset($entity['author']) && (int) $entity['author'] === $userId) {
return true;
}
}
return false;
}
/**
* Helper: determines project relevance level from settings (same logic as the private method).
*/
private function getProjectRelevanceLevel(int $userId, int $projectId, array $preloadedSettings, string $companyDefault): string
{
$newKey = 'usersettings.'.$userId.'.projectNotificationLevels';
$newSetting = $preloadedSettings[$newKey] ?? false;
if (! empty($newSetting) && $newSetting !== false) {
$levels = json_decode($newSetting, true);
if (is_array($levels) && isset($levels[$projectId])) {
$level = $levels[$projectId];
if (Notification::isValidRelevanceLevel($level)) {
return $level;
}
}
}
$oldKey = 'usersettings.'.$userId.'.projectMutedNotifications';
$oldSetting = $preloadedSettings[$oldKey] ?? false;
if (! empty($oldSetting) && $oldSetting !== false) {
$mutedIds = json_decode($oldSetting, true);
if (is_array($mutedIds) && in_array($projectId, $mutedIds)) {
return Notification::RELEVANCE_MUTED;
}
}
return $companyDefault;
}
// -----------------------------------------------------------------------
// Tests for relevance level resolution
// -----------------------------------------------------------------------
public function test_new_format_setting_is_used(): void
{
$settings = [
'usersettings.1.projectNotificationLevels' => json_encode([5 => 'my_work', 10 => 'muted']),
];
$this->assertSame('my_work', $this->getProjectRelevanceLevel(1, 5, $settings, 'all'));
$this->assertSame('muted', $this->getProjectRelevanceLevel(1, 10, $settings, 'all'));
}
public function test_falls_back_to_company_default_when_no_setting(): void
{
$settings = [];
$this->assertSame('all', $this->getProjectRelevanceLevel(1, 5, $settings, 'all'));
$this->assertSame('my_work', $this->getProjectRelevanceLevel(1, 5, $settings, 'my_work'));
}
public function test_project_not_in_levels_map_uses_company_default(): void
{
$settings = [
'usersettings.1.projectNotificationLevels' => json_encode([10 => 'muted']),
];
// Project 5 is not in the map, should fall back to company default
$this->assertSame('all', $this->getProjectRelevanceLevel(1, 5, $settings, 'all'));
}
public function test_legacy_muted_format_is_recognized(): void
{
$settings = [
'usersettings.1.projectMutedNotifications' => json_encode([5, 10, 15]),
];
$this->assertSame('muted', $this->getProjectRelevanceLevel(1, 5, $settings, 'all'));
$this->assertSame('muted', $this->getProjectRelevanceLevel(1, 10, $settings, 'all'));
$this->assertSame('all', $this->getProjectRelevanceLevel(1, 20, $settings, 'all'));
}
public function test_new_format_takes_precedence_over_legacy(): void
{
$settings = [
'usersettings.1.projectNotificationLevels' => json_encode([5 => 'all']),
'usersettings.1.projectMutedNotifications' => json_encode([5]), // legacy says muted
];
// New format wins
$this->assertSame('all', $this->getProjectRelevanceLevel(1, 5, $settings, 'muted'));
}
public function test_invalid_level_in_settings_falls_back_to_company_default(): void
{
$settings = [
'usersettings.1.projectNotificationLevels' => json_encode([5 => 'invalid_level']),
];
$this->assertSame('all', $this->getProjectRelevanceLevel(1, 5, $settings, 'all'));
}
// -----------------------------------------------------------------------
// Tests for user involvement detection
// -----------------------------------------------------------------------
public function test_user_is_involved_when_assigned_via_editor_id(): void
{
$notification = new Notification;
$notification->entity = ['editorId' => 42, 'userId' => 99];
$this->assertTrue($this->isUserInvolved(42, $notification));
// userId 99 is the reporter -- also involved (tested in next test)
$this->assertTrue($this->isUserInvolved(99, $notification));
}
public function test_user_is_involved_when_creator_via_user_id(): void
{
$notification = new Notification;
$notification->entity = ['editorId' => 42, 'userId' => 99];
$this->assertTrue($this->isUserInvolved(99, $notification));
}
public function test_user_is_involved_when_canvas_author(): void
{
$notification = new Notification;
$notification->entity = ['author' => 77];
$this->assertTrue($this->isUserInvolved(77, $notification));
}
public function test_user_not_involved_when_unrelated(): void
{
$notification = new Notification;
$notification->entity = ['editorId' => 42, 'userId' => 99, 'author' => 77];
$this->assertFalse($this->isUserInvolved(1, $notification));
}
public function test_user_not_involved_when_entity_is_null(): void
{
$notification = new Notification;
$notification->entity = null;
$this->assertFalse($this->isUserInvolved(1, $notification));
}
public function test_user_not_involved_when_entity_has_no_user_fields(): void
{
$notification = new Notification;
$notification->entity = ['headline' => 'Test', 'description' => 'No user fields'];
$this->assertFalse($this->isUserInvolved(1, $notification));
}
public function test_editor_id_string_matches_integer_user(): void
{
$notification = new Notification;
$notification->entity = ['editorId' => '42'];
$this->assertTrue($this->isUserInvolved(42, $notification));
}
// -----------------------------------------------------------------------
// Tests for the category-to-module mapping with new structure
// -----------------------------------------------------------------------
public function test_category_filtering_with_restructured_categories(): void
{
// Verify getCategoryForModule still works with the new {modules: [...], description: '...'} structure
$this->assertSame('tasks', Notification::getCategoryForModule('tickets'));
$this->assertSame('comments', Notification::getCategoryForModule('comments'));
$this->assertSame('goals', Notification::getCategoryForModule('goalcanvas'));
$this->assertSame('boards', Notification::getCategoryForModule('leancanvas'));
$this->assertSame('boards', Notification::getCategoryForModule('retroscanvas'));
$this->assertNull(Notification::getCategoryForModule('unknownModule'));
}
// -----------------------------------------------------------------------
// Integration-style test: full filtering decision
// -----------------------------------------------------------------------
public function test_muted_user_would_be_filtered_out(): void
{
$settings = [
'usersettings.1.projectNotificationLevels' => json_encode([5 => 'muted']),
];
$level = $this->getProjectRelevanceLevel(1, 5, $settings, 'all');
$this->assertSame('muted', $level);
// In the actual service, muted -> user is excluded (return false from filter)
}
public function test_my_work_user_kept_when_assigned(): void
{
$settings = [
'usersettings.1.projectNotificationLevels' => json_encode([5 => 'my_work']),
];
$notification = new Notification;
$notification->entity = ['editorId' => 1, 'userId' => 99];
$notification->projectId = 5;
$level = $this->getProjectRelevanceLevel(1, 5, $settings, 'all');
$this->assertSame('my_work', $level);
$this->assertTrue($this->isUserInvolved(1, $notification));
}
public function test_my_work_user_excluded_when_unrelated(): void
{
$settings = [
'usersettings.1.projectNotificationLevels' => json_encode([5 => 'my_work']),
];
$notification = new Notification;
$notification->entity = ['editorId' => 99, 'userId' => 88];
$notification->projectId = 5;
$level = $this->getProjectRelevanceLevel(1, 5, $settings, 'all');
$this->assertSame('my_work', $level);
$this->assertFalse($this->isUserInvolved(1, $notification));
}
public function test_all_activity_user_always_kept(): void
{
$settings = [
'usersettings.1.projectNotificationLevels' => json_encode([5 => 'all']),
];
$level = $this->getProjectRelevanceLevel(1, 5, $settings, 'muted');
$this->assertSame('all', $level);
// In the actual service, 'all' -> user is always kept (return true from filter)
}
}

View File

@@ -0,0 +1,995 @@
<?php
namespace Unit\app\Domain\Projects\Services;
use Carbon\CarbonImmutable;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Response;
use Leantime\Core\Configuration\Environment as EnvironmentCore;
use Leantime\Core\Exceptions\AuthorizationException;
use Leantime\Core\Language as LanguageCore;
use Leantime\Core\Support\Avatarcreator;
use Leantime\Core\Support\CarbonMacros;
use Leantime\Domain\Clients\Repositories\Clients as ClientRepository;
use Leantime\Domain\Comments\Repositories\Comments as CommentRepository;
use Leantime\Domain\Files\Services\Files as FileService;
use Leantime\Domain\Notifications\Services\Messengers;
use Leantime\Domain\Notifications\Services\Notifications as NotificationService;
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Leantime\Domain\Queue\Repositories\Queue as QueueRepository;
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
use Leantime\Domain\Tickets\Repositories\Tickets as TicketRepository;
use Leantime\Domain\Users\Repositories\Users as UserRepository;
use Unit\TestCase;
/**
* Unit tests for the business logic extracted from the Projects domain
* controllers into the Projects service during the thin-controller refactor:
* getProjectHubData, notifyProjectCreated, saveZulipWebhook,
* getProjectIntegrationSettings and getProjectCardData.
*/
class ProjectsServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
protected function setUp(): void
{
parent::setUp();
// Session + macros needed because getUsersAssignedToProject() uses dtHelper().
session(['usersettings.timezone' => 'UTC']);
session(['usersettings.language' => 'en-US']);
session(['usersettings.date_format' => 'Y-m-d']);
session(['usersettings.time_format' => 'H:i']);
session(['userdata.id' => 1]);
$envMock = $this->make(EnvironmentCore::class, [
'defaultTimezone' => 'UTC',
'language' => 'en-US',
]);
app()->instance(EnvironmentCore::class, $envMock);
CarbonImmutable::mixin(new CarbonMacros('UTC', 'en-US', 'Y-m-d', 'H:i'));
}
/**
* Builds a real Projects service, allowing each dependency to be overridden
* with a stub so we can observe persistence/queue calls.
*/
private function makeService(
?ProjectRepository $projectRepo = null,
?TicketRepository $ticketRepo = null,
?SettingRepository $settingsRepo = null,
?QueueRepository $queueRepo = null,
?UserRepository $userRepo = null,
?CommentRepository $commentRepo = null,
?ClientRepository $clientRepo = null,
?LanguageCore $language = null,
?Client $httpClient = null,
): ProjectService {
$language ??= $this->make(LanguageCore::class, [
'__' => fn ($key) => $key,
]);
return new ProjectService(
$projectRepo ?? $this->make(ProjectRepository::class),
$ticketRepo ?? $this->make(TicketRepository::class),
$settingsRepo ?? $this->make(SettingRepository::class),
$language,
$this->make(Messengers::class),
$this->make(NotificationService::class),
$this->make(FileService::class),
$this->make(Avatarcreator::class),
$queueRepo ?? $this->make(QueueRepository::class),
$userRepo ?? $this->make(UserRepository::class),
$commentRepo ?? $this->make(CommentRepository::class),
$clientRepo ?? $this->make(ClientRepository::class),
$httpClient ?? $this->make(Client::class),
);
}
public function test_get_project_hub_data_builds_unique_client_map_and_returns_all_projects_when_no_filter(): void
{
$projectRepo = $this->make(ProjectRepository::class, [
'getUserProjects' => fn () => [
['id' => 1, 'clientId' => 10, 'clientName' => 'Acme'],
['id' => 2, 'clientId' => 10, 'clientName' => 'Acme'],
['id' => 3, 'clientId' => 20, 'clientName' => 'Globex'],
],
]);
$result = $this->makeService(projectRepo: $projectRepo)->getProjectHubData(1, null);
$this->assertCount(3, $result['allProjects']);
$this->assertCount(2, $result['clients'], 'Duplicate clients must be collapsed into a unique map');
$this->assertSame('Acme', $result['clients'][10]['name']);
$this->assertSame('Globex', $result['clients'][20]['name']);
$this->assertSame('', $result['currentClientName']);
$this->assertSame('', $result['currentClient']);
}
public function test_get_project_hub_data_filters_projects_by_selected_client(): void
{
$projectRepo = $this->make(ProjectRepository::class, [
'getUserProjects' => fn () => [
['id' => 1, 'clientId' => 10, 'clientName' => 'Acme'],
['id' => 2, 'clientId' => 20, 'clientName' => 'Globex'],
],
]);
$clientRepo = $this->make(ClientRepository::class, [
'getClient' => fn () => ['id' => 10, 'name' => 'Acme'],
]);
$result = $this->makeService(projectRepo: $projectRepo, clientRepo: $clientRepo)->getProjectHubData(1, 10);
$this->assertCount(1, $result['allProjects'], 'Only projects of the selected client are returned');
$this->assertSame(1, $result['allProjects'][0]['id']);
$this->assertCount(2, $result['clients'], 'The client map is still built from all projects');
$this->assertSame('Acme', $result['currentClientName']);
$this->assertSame(10, $result['currentClient']);
}
public function test_notify_project_created_queues_only_users_who_opted_in(): void
{
$projectRepo = $this->make(ProjectRepository::class, [
'getUsersAssignedToProject' => fn () => [
['username' => 'wants@example.com', 'notifications' => 1, 'modified' => ''],
['username' => 'muted@example.com', 'notifications' => 0, 'modified' => ''],
],
]);
$captured = null;
$queueRepo = $this->make(QueueRepository::class, [
'queueMessageToUsers' => function ($recipients, $message, $subject, $projectId) use (&$captured) {
$captured = compact('recipients', 'message', 'subject', 'projectId');
},
]);
$this->makeService(projectRepo: $projectRepo, queueRepo: $queueRepo)
->notifyProjectCreated(42, 'My Project', 'Author');
$this->assertNotNull($captured, 'A message must be queued');
$this->assertSame(['wants@example.com'], $captured['recipients'], 'Users with notifications=0 are excluded');
$this->assertSame(42, $captured['projectId']);
}
public function test_save_zulip_webhook_persists_when_all_fields_present(): void
{
$savedKey = null;
$settingsRepo = $this->make(SettingRepository::class, [
'saveSetting' => function ($key, $value) use (&$savedKey) {
$savedKey = $key;
return true;
},
]);
$result = $this->makeService(settingsRepo: $settingsRepo)->saveZulipWebhook(7, [
'zulipURL' => 'https://zulip.example.com',
'zulipEmail' => 'bot@example.com',
'zulipBotKey' => 'key123',
'zulipStream' => 'general',
'zulipTopic' => 'updates',
]);
$this->assertTrue($result['saved']);
$this->assertSame('projectsettings.7.zulipHook', $savedKey);
$this->assertSame('https://zulip.example.com', $result['hook']['zulipURL']);
}
public function test_save_zulip_webhook_does_not_persist_when_a_field_is_missing(): void
{
$saveCalls = 0;
$settingsRepo = $this->make(SettingRepository::class, [
'saveSetting' => function () use (&$saveCalls) {
$saveCalls++;
return true;
},
]);
$result = $this->makeService(settingsRepo: $settingsRepo)->saveZulipWebhook(7, [
'zulipURL' => 'https://zulip.example.com',
'zulipEmail' => '',
'zulipBotKey' => 'key123',
'zulipStream' => 'general',
'zulipTopic' => 'updates',
]);
$this->assertFalse($result['saved']);
$this->assertSame(0, $saveCalls, 'Incomplete zulip config must not be persisted');
$this->assertSame('', $result['hook']['zulipEmail']);
}
public function test_get_project_integration_settings_returns_empty_zulip_hook_when_unset(): void
{
$settingsRepo = $this->make(SettingRepository::class, [
'getSetting' => fn () => '',
]);
$settings = $this->makeService(settingsRepo: $settingsRepo)->getProjectIntegrationSettings(5);
$this->assertSame('', $settings['mattermostWebhookURL']);
$this->assertArrayHasKey('discordWebhookURL1', $settings);
$this->assertArrayHasKey('discordWebhookURL3', $settings);
$this->assertSame([
'zulipURL' => '',
'zulipEmail' => '',
'zulipBotKey' => '',
'zulipStream' => '',
'zulipTopic' => '',
], $settings['zulipHook']);
}
public function test_get_project_integration_settings_unserializes_stored_zulip_hook(): void
{
$storedHook = serialize(['zulipURL' => 'https://z.example.com', 'zulipTopic' => 't']);
$settingsRepo = $this->make(SettingRepository::class, [
'getSetting' => fn ($key) => str_ends_with($key, 'zulipHook') ? $storedHook : '',
]);
$settings = $this->makeService(settingsRepo: $settingsRepo)->getProjectIntegrationSettings(5);
$this->assertSame('https://z.example.com', $settings['zulipHook']['zulipURL']);
$this->assertSame('t', $settings['zulipHook']['zulipTopic']);
}
public function test_save_telegram_webhook_does_not_persist_without_token(): void
{
$saveCalls = 0;
$settingsRepo = $this->make(SettingRepository::class, [
'saveSetting' => function () use (&$saveCalls) {
$saveCalls++;
return true;
},
]);
$result = $this->makeService(settingsRepo: $settingsRepo)->saveTelegramWebhook(7, [
'telegramBotToken' => '',
'telegramChatId' => '12345',
'telegramTopicId' => '',
]);
$this->assertFalse($result['saved']);
$this->assertSame('missing_token', $result['error']);
$this->assertSame(0, $saveCalls);
}
public function test_save_telegram_webhook_auto_detects_chat_id_when_blank(): void
{
$savedValue = null;
$settingsRepo = $this->make(SettingRepository::class, [
'saveSetting' => function ($type, $value) use (&$savedValue) {
$savedValue = $value;
return true;
},
]);
$httpClient = $this->make(Client::class, [
'get' => function ($url, $options) {
return new Response(200, [], json_encode([
'ok' => true,
'result' => [
[
'message' => [
'chat' => [
'id' => 987654321,
],
],
],
],
]));
},
]);
$result = $this->makeService(settingsRepo: $settingsRepo, httpClient: $httpClient)->saveTelegramWebhook(7, [
'telegramBotToken' => '123456:ABC',
'telegramChatId' => '',
'telegramTopicId' => '',
]);
$this->assertTrue($result['saved']);
$this->assertNull($result['error']);
$this->assertSame('987654321', $result['hook']['telegramChatId']);
$this->assertNotNull($savedValue);
$unserialized = safe_unserialize($savedValue, []);
$this->assertSame('987654321', $unserialized['telegramChatId']);
}
public function test_save_telegram_webhook_uses_provided_chat_and_topic_id_without_calling_get_updates(): void
{
$getCalled = false;
$httpClient = $this->make(Client::class, [
'get' => function () use (&$getCalled) {
$getCalled = true;
return new Response(200);
},
]);
$savedValue = null;
$settingsRepo = $this->make(SettingRepository::class, [
'saveSetting' => function ($type, $value) use (&$savedValue) {
$savedValue = $value;
return true;
},
]);
$result = $this->makeService(settingsRepo: $settingsRepo, httpClient: $httpClient)->saveTelegramWebhook(7, [
'telegramBotToken' => '123456:ABC',
'telegramChatId' => '-1001234567890',
'telegramTopicId' => '10',
]);
$this->assertFalse($getCalled, 'getUpdates API must not be called when chat_id is provided directly');
$this->assertTrue($result['saved']);
$this->assertSame('-1001234567890', $result['hook']['telegramChatId']);
$this->assertSame('10', $result['hook']['telegramTopicId']);
}
public function test_save_telegram_webhook_reports_chat_not_found_when_auto_detect_fails(): void
{
$saveCalls = 0;
$settingsRepo = $this->make(SettingRepository::class, [
'saveSetting' => function () use (&$saveCalls) {
$saveCalls++;
return true;
},
]);
$httpClient = $this->make(Client::class, [
'get' => function () {
return new Response(200, [], json_encode([
'ok' => true,
'result' => [],
]));
},
]);
$result = $this->makeService(settingsRepo: $settingsRepo, httpClient: $httpClient)->saveTelegramWebhook(7, [
'telegramBotToken' => '123456:ABC',
'telegramChatId' => '',
'telegramTopicId' => '',
]);
$this->assertFalse($result['saved']);
$this->assertSame('chat_not_found', $result['error']);
$this->assertSame(0, $saveCalls);
}
public function test_get_project_integration_settings_returns_empty_telegram_hook_when_unset(): void
{
$settingsRepo = $this->make(SettingRepository::class, [
'getSetting' => fn () => '',
]);
$settings = $this->makeService(settingsRepo: $settingsRepo)->getProjectIntegrationSettings(5);
$this->assertSame([
'telegramBotToken' => '',
'telegramChatId' => '',
'telegramTopicId' => '',
], $settings['telegramHook']);
}
public function test_get_project_integration_settings_unserializes_stored_telegram_hook(): void
{
$storedHook = serialize(['telegramBotToken' => 'tok', 'telegramChatId' => '123', 'telegramTopicId' => '1']);
$settingsRepo = $this->make(SettingRepository::class, [
'getSetting' => fn ($key) => str_ends_with($key, 'telegramHook') ? $storedHook : '',
]);
$settings = $this->makeService(settingsRepo: $settingsRepo)->getProjectIntegrationSettings(5);
$this->assertSame('tok', $settings['telegramHook']['telegramBotToken']);
$this->assertSame('123', $settings['telegramHook']['telegramChatId']);
$this->assertSame('1', $settings['telegramHook']['telegramTopicId']);
}
public function test_get_project_card_data_sets_last_update_and_status_from_first_comment(): void
{
$ticketRepo = $this->make(TicketRepository::class, [
'getAverageTodoSize' => fn () => 0,
'getFirstTicket' => fn () => null,
]);
$projectRepo = $this->make(ProjectRepository::class, [
'getUsersAssignedToProject' => fn () => [],
]);
$commentRepo = $this->make(CommentRepository::class, [
'getComments' => fn () => [
['id' => 99, 'status' => 'on_track', 'text' => 'Looking good'],
],
]);
$card = $this->makeService(
projectRepo: $projectRepo,
ticketRepo: $ticketRepo,
commentRepo: $commentRepo,
)->getProjectCardData(3);
$this->assertSame(3, $card['id']);
$this->assertSame('on_track', $card['status']);
$this->assertIsArray($card['lastUpdate']);
$this->assertSame(99, $card['lastUpdate']['id']);
}
public function test_get_project_card_data_defaults_when_no_comments(): void
{
$ticketRepo = $this->make(TicketRepository::class, [
'getAverageTodoSize' => fn () => 0,
'getFirstTicket' => fn () => null,
]);
$projectRepo = $this->make(ProjectRepository::class, [
'getUsersAssignedToProject' => fn () => [],
]);
$commentRepo = $this->make(CommentRepository::class, [
'getComments' => fn () => [],
]);
$card = $this->makeService(
projectRepo: $projectRepo,
ticketRepo: $ticketRepo,
commentRepo: $commentRepo,
)->getProjectCardData(3);
$this->assertFalse($card['lastUpdate']);
$this->assertSame('', $card['status']);
}
// ---------------------------------------------------------------------
// Authorized JSON-RPC entry points for project sort/status/patch.
// The /api/projects controller (which had a route-level gate) was retired,
// so these wrappers must self-authorize: manager+ AND access to each project.
// ---------------------------------------------------------------------
public function test_user_can_manage_project_allows_admin_without_explicit_assignment(): void
{
session(['userdata.role' => 'admin']);
$projectRepo = $this->make(ProjectRepository::class, [
'isUserAssignedToProject' => fn () => false,
]);
// Admins/owners manage every project regardless of assignment.
$this->assertTrue($this->makeService(projectRepo: $projectRepo)->userCanManageProject(99));
}
public function test_user_can_manage_project_requires_assignment_for_managers(): void
{
session(['userdata.role' => 'manager']);
$projectRepo = $this->make(ProjectRepository::class, [
'isUserAssignedToProject' => fn () => false,
]);
$this->assertFalse($this->makeService(projectRepo: $projectRepo)->userCanManageProject(99));
}
public function test_patch_project_status_and_sorting_rejects_non_manager(): void
{
session(['userdata.role' => 'editor']);
$patchCalls = 0;
$projectRepo = $this->make(ProjectRepository::class, [
'isUserAssignedToProject' => fn () => true,
'patch' => function () use (&$patchCalls) {
$patchCalls++;
return true;
},
]);
$thrown = null;
try {
$this->makeService(projectRepo: $projectRepo)
->patchProjectStatusAndSorting(['3' => 'item[]=5']);
} catch (AuthorizationException $e) {
$thrown = $e;
}
$this->assertInstanceOf(AuthorizationException::class, $thrown, 'Editors must not be able to re-status projects');
$this->assertSame(0, $patchCalls, 'Unauthorized request must not persist any sorting');
}
public function test_patch_project_status_and_sorting_rejects_manager_without_project_access(): void
{
session(['userdata.role' => 'manager']);
$patchCalls = 0;
$projectRepo = $this->make(ProjectRepository::class, [
'isUserAssignedToProject' => fn () => false,
'patch' => function () use (&$patchCalls) {
$patchCalls++;
return true;
},
]);
$thrown = null;
try {
$this->makeService(projectRepo: $projectRepo)
->patchProjectStatusAndSorting(['3' => 'item[]=5']);
} catch (AuthorizationException $e) {
$thrown = $e;
}
$this->assertInstanceOf(AuthorizationException::class, $thrown, 'A manager smuggling a project they cannot access must be blocked');
$this->assertSame(0, $patchCalls);
}
public function test_patch_project_status_and_sorting_allows_manager_with_access(): void
{
session(['userdata.role' => 'manager']);
$patched = [];
$projectRepo = $this->make(ProjectRepository::class, [
'isUserAssignedToProject' => fn () => true,
'patch' => function ($id, $values) use (&$patched) {
$patched[] = ['id' => $id, 'values' => $values];
return true;
},
]);
$result = $this->makeService(projectRepo: $projectRepo)
->patchProjectStatusAndSorting(['3' => 'item[]=5&item[]=6']);
$this->assertTrue($result);
$this->assertCount(2, $patched, 'Both serialized projects must be re-sorted');
$this->assertSame('5', $patched[0]['id']);
$this->assertSame(3, (int) $patched[0]['values']['state']);
}
public function test_sort_projects_rejects_when_user_cannot_manage_target_project(): void
{
session(['userdata.role' => 'manager']);
$projectRepo = $this->make(ProjectRepository::class, [
'isUserAssignedToProject' => fn () => false,
]);
$thrown = null;
try {
$this->makeService(projectRepo: $projectRepo)->sortProjects(['pgm-5' => 1]);
} catch (AuthorizationException $e) {
$thrown = $e;
}
$this->assertInstanceOf(AuthorizationException::class, $thrown);
}
public function test_sort_projects_resolves_ticket_to_its_project_for_authorization(): void
{
session(['userdata.role' => 'manager']);
$checkedProjectId = null;
$projectRepo = $this->make(ProjectRepository::class, [
'isUserAssignedToProject' => function ($userId, $projectId) use (&$checkedProjectId) {
$checkedProjectId = $projectId;
return false; // deny so we stop before delegating to the Tickets service
},
]);
$ticket = new \Leantime\Domain\Tickets\Models\Tickets;
$ticket->projectId = 9;
$ticketRepo = $this->make(TicketRepository::class, [
'getTicket' => fn () => $ticket,
]);
$thrown = null;
try {
$this->makeService(projectRepo: $projectRepo, ticketRepo: $ticketRepo)
->sortProjects(['ticket-7' => 1]);
} catch (AuthorizationException $e) {
$thrown = $e;
}
$this->assertInstanceOf(AuthorizationException::class, $thrown);
$this->assertSame(9, $checkedProjectId, 'Authorization must check the ticket\'s project, not the ticket id');
}
public function test_patch_project_rejects_non_manager(): void
{
session(['userdata.role' => 'editor']);
$patchCalls = 0;
$projectRepo = $this->make(ProjectRepository::class, [
'isUserAssignedToProject' => fn () => true,
'patch' => function () use (&$patchCalls) {
$patchCalls++;
return true;
},
]);
$thrown = null;
try {
$this->makeService(projectRepo: $projectRepo)->patchProject(5, ['sortIndex' => 2]);
} catch (AuthorizationException $e) {
$thrown = $e;
}
$this->assertInstanceOf(AuthorizationException::class, $thrown);
$this->assertSame(0, $patchCalls);
}
public function test_patch_project_allows_manager_and_strips_control_fields(): void
{
session(['userdata.role' => 'manager']);
$patchedValues = null;
$projectRepo = $this->make(ProjectRepository::class, [
'isUserAssignedToProject' => fn () => true,
'patch' => function ($id, $values) use (&$patchedValues) {
$patchedValues = $values;
return true;
},
]);
$result = $this->makeService(projectRepo: $projectRepo)
->patchProject(5, ['act' => 'projects.x', 'id' => 5, 'sortIndex' => 2, 'start' => '2026-01-01']);
$this->assertTrue($result);
$this->assertArrayNotHasKey('act', $patchedValues, 'Control fields must be stripped before persisting');
$this->assertArrayNotHasKey('id', $patchedValues);
$this->assertSame(2, $patchedValues['sortIndex']);
}
// ---- permission-engine: recursion guardrail ---------------------------
/**
* THE recursion guardrail. The permission engine calls isUserAssignedToProject() and
* getProjectRole() during every project-scoped authorization, so those two methods must never
* invoke the engine in-body — otherwise authorize() → currentUserCan() → isUserAssignedToProject()
* → authorize() → ∞. A PermissionService stub that fails the test if touched proves it.
*/
public function test_access_resolution_methods_never_invoke_the_permission_engine(): void
{
$projectRepo = $this->make(ProjectRepository::class, [
'isUserAssignedToProject' => fn () => true,
'getUserProjectRelation' => fn () => [['projectRole' => 'editor']],
]);
$tripwire = $this->make(\Leantime\Core\Auth\Permissions\PermissionService::class, [
'currentUserCan' => fn () => $this->fail('isUserAssignedToProject/getProjectRole must NOT call the permission engine (infinite-recursion guard).'),
'authorize' => fn () => $this->fail('access-resolution methods must NOT authorize in-body (infinite-recursion guard).'),
]);
$service = $this->makeService(projectRepo: $projectRepo);
$service->setPermissionService($tripwire);
// Neither call may touch the engine.
$this->assertTrue($service->isUserAssignedToProject(1, 5));
$this->assertSame('editor', $service->getProjectRole(1, 5));
}
/**
* getProjectRole() must resolve "no explicit role" to '' so callers fall back to the global
* role. This locks in the fix for the "Inherit" lockout: the legacy 0 role (written when
* "inherit" was cast to int), a missing relation, unknown/junk keys, and admin/owner keys all
* map to '', while a real assignable key is returned unchanged.
*
* @dataProvider projectRoleResolutionProvider
*/
public function test_get_project_role_resolves_inherit_and_junk_to_empty(mixed $stored, string $expected): void
{
$relation = $stored === '__none__' ? [] : [['projectRole' => $stored]];
$projectRepo = $this->make(ProjectRepository::class, [
'getUserProjectRelation' => fn () => $relation,
]);
$service = $this->makeService(projectRepo: $projectRepo);
$this->assertSame($expected, $service->getProjectRole(1, 5));
}
public static function projectRoleResolutionProvider(): array
{
return [
'legacy int 0 -> inherit' => [0, ''],
'legacy string 0 -> inherit' => ['0', ''],
'empty string -> inherit' => ['', ''],
'no relation row -> inherit' => ['__none__', ''],
'unknown numeric key -> inherit' => ['999', ''],
'admin key not assignable -> inherit' => ['40', ''],
'owner key not assignable -> inherit' => ['50', ''],
'valid editor key preserved' => ['20', '20'],
'valid readonly key preserved' => ['5', '5'],
'legacy inherit sentinel -> inherit' => ['inherit', ''],
'legacy inherited sentinel -> inherit' => ['inherited', ''],
'legacy uppercase Inherit sentinel -> inherit' => ['Inherit', ''],
'legacy role name preserved' => ['editor', 'editor'],
];
}
/**
* Reflection lock: the engine-reachable access methods must carry NO #[RequiresPermission]
* dispatch attribute (a dispatch gate on them would re-enter the engine), and the mutations/reads
* must carry the expected gate. Locks the recursion-safe contract in CI.
*/
public function test_rpc_surface_contract(): void
{
$gate = function (string $method): ?array {
$attrs = (new \ReflectionMethod(ProjectService::class, $method))
->getAttributes(\Leantime\Core\Auth\Permissions\RequiresPermission::class);
if ($attrs === []) {
return null;
}
$a = $attrs[0]->newInstance();
return ['permission' => $a->permission, 'global' => $a->global, 'projectIdParam' => $a->projectIdParam];
};
// Engine-reachable / access-resolution: MUST be ungated (the recursion guard). Note
// getUsersAssignedToProject is NOT in this set — the engine never calls it, so it is safely
// view-gated below to close its member-list IDOR.
foreach (['getProjectRole', 'isUserAssignedToProject', 'getUserProjectRelation', 'userCanManageProject', 'getProjectsUserHasAccessTo'] as $m) {
$this->assertNull($gate($m), "$m must carry NO #[RequiresPermission] (recursion guard)");
}
// Mutations: global manager+.
foreach (['addProject' => 'projects.create', 'duplicateProject' => 'projects.create', 'editProject' => 'projects.edit', 'patch' => 'projects.edit', 'patchProject' => 'projects.edit', 'updateProjectUsers' => 'projects.edit', 'saveSlackWebhook' => 'projects.edit', 'saveZulipWebhook' => 'projects.edit', 'saveTelegramWebhook' => 'projects.edit', 'deleteProject' => 'projects.delete', 'editUserProjectRelations' => 'projects.edit', 'addUserToProject' => 'projects.edit'] as $m => $perm) {
$g = $gate($m);
$this->assertNotNull($g, "$m must be gated");
$this->assertSame($perm, $g['permission'], $m);
$this->assertTrue($g['global'], "$m must be global-scoped (manager+ company-wide)");
}
// By-id reads: project-scoped view.
foreach (['getProject', 'getProjectProgress', 'getProjectName', 'getProjectIntegrationSettings', 'getProjectCardData', 'getUsersAssignedToProject'] as $m) {
$g = $gate($m);
$this->assertNotNull($g, "$m must be gated");
$this->assertSame('projects.view', $g['permission'], $m);
$this->assertNotNull($g['projectIdParam'], "$m must bind to the requested project id");
}
}
/**
* The $userId-param reads pin to the SESSION user for non-admins, closing the cross-user spoof
* (an RPC caller could otherwise list another user's projects by passing a foreign id).
*/
public function test_assigned_to_user_reads_pin_to_session_user_for_non_admins(): void
{
session(['userdata.id' => 1]); // non-admin session user
$capturedUserId = null;
$projectRepo = $this->make(ProjectRepository::class, [
'getUserProjectRelation' => function ($userId) use (&$capturedUserId) {
$capturedUserId = $userId;
return [];
},
]);
// Caller passes a FOREIGN userId (99); the read must be scoped to the session user (1).
$this->makeService(projectRepo: $projectRepo)->getProjectIdAssignedToUser(99);
$this->assertSame(1, $capturedUserId, 'a non-admin must not be able to read another user\'s project assignments');
}
// ---- Project hierarchy safety (#3540: cyclic parents hung every page via the project selector) ----
public function test_find_my_children_builds_nested_hierarchy(): void
{
$projects = [
['id' => 1, 'parent' => 0, 'name' => 'Program'],
['id' => 2, 'parent' => 1, 'name' => 'Project'],
['id' => 3, 'parent' => 2, 'name' => 'Subproject'],
['id' => 4, 'parent' => 0, 'name' => 'Standalone'],
];
$hierarchy = $this->makeService()->findMyChildren(0, $projects);
$this->assertCount(2, $hierarchy);
$this->assertSame(2, $hierarchy[0]['children'][0]['id']);
$this->assertSame(3, $hierarchy[0]['children'][0]['children'][0]['id']);
$this->assertArrayNotHasKey('children', $hierarchy[1]);
}
public function test_find_my_children_does_not_recurse_on_self_referential_parent(): void
{
$projects = [
['id' => 1, 'parent' => 0, 'name' => 'Root'],
['id' => 2, 'parent' => 2, 'name' => 'Self-parented'],
];
$hierarchy = $this->makeService()->findMyChildren(0, $projects);
$this->assertCount(1, $hierarchy, 'must terminate instead of recursing on a self-parented project');
$this->assertSame(1, $hierarchy[0]['id']);
}
public function test_clean_parent_relationship_reroots_self_parent_and_cycles(): void
{
$projects = [
['id' => 1, 'parent' => 1, 'name' => 'Self-parented'],
['id' => 2, 'parent' => 3, 'name' => 'Cycle A'],
['id' => 3, 'parent' => 2, 'name' => 'Cycle B'],
['id' => 4, 'parent' => 99, 'name' => 'Orphan'],
['id' => 5, 'parent' => 1, 'name' => 'Valid child'],
];
$service = $this->makeService();
$clean = $service->cleanParentRelationship($projects);
$byId = array_column($clean, null, 'id');
$this->assertSame(0, $byId[1]['parent'], 'self-parent must be re-rooted');
$this->assertSame(0, $byId[2]['parent'], 'cycle members must be re-rooted');
$this->assertSame(0, $byId[3]['parent'], 'cycle members must be re-rooted');
$this->assertSame(0, $byId[4]['parent'], 'orphans must be re-rooted');
$this->assertSame(1, $byId[5]['parent'], 'valid parent links must be preserved');
// The full pipeline must terminate and surface every project.
$hierarchy = $service->findMyChildren(0, $clean);
$this->assertCount(4, $hierarchy);
}
/**
* Regression for #3617: a child whose parent is a top-level strategy/program (parent = NULL)
* must stay nested. isset() reports false for a NULL parent value, which previously re-rooted
* every such child to 0 and dropped it out of its strategy group in the Projects dropdown.
*/
public function test_clean_parent_relationship_keeps_children_of_top_level_parents(): void
{
$projects = [
['id' => 1, 'parent' => null, 'name' => 'Strategy'], // top-level container
['id' => 2, 'parent' => 1, 'name' => 'Project under strategy'],
['id' => 3, 'parent' => 1, 'name' => 'Plan under strategy'],
];
$service = $this->makeService();
$byId = array_column($service->cleanParentRelationship($projects), null, 'id');
$this->assertSame(1, $byId[2]['parent'], 'child of a NULL-parent strategy must stay nested');
$this->assertSame(1, $byId[3]['parent'], 'plan of a NULL-parent strategy must stay nested');
// And the child must actually appear under the strategy in the assembled hierarchy.
$hierarchy = $service->findMyChildren(0, array_values($byId));
$this->assertCount(1, $hierarchy, 'only the top-level strategy sits at the root');
$this->assertSame(1, $hierarchy[0]['id']);
$this->assertCount(2, $hierarchy[0]['children'], 'project and plan nest under the strategy');
}
/**
* addUserToProject() must be ADDITIVE. The sibling
* editUserProjectRelations() is a full replace that deletes any
* relation not in the array it is handed, so the whole point of this
* method is that it never touches a user's other memberships.
*/
public function test_add_user_to_project_inserts_when_not_a_member(): void
{
$added = [];
$repo = $this->makeEmpty(ProjectRepository::class, [
'getProject' => fn () => ['id' => 42],
'isUserMemberOfProject' => fn () => false,
'addProjectRelation' => function ($userId, $projectId, $role) use (&$added) {
$added[] = [$userId, $projectId, $role];
},
'editUserProjectRelations' => fn () => throw new \LogicException(
'addUserToProject must never call the destructive full-replace method'
),
]);
$result = $this->makeService($repo, userRepo: $this->validUserRepo())->addUserToProject(7, 42, 'contributor');
$this->assertTrue($result, 'a new membership reports true');
$this->assertSame([[7, 42, 'contributor']], $added);
}
/**
* Membership is not access. isUserAssignedToProject() returns true for
* every admin and owner whether or not a relation row exists, so using
* it as the idempotence check would make this method a permanent
* no-op for exactly those users: an admin could never be put on a
* project team, and the caller would be told "already a member" about
* someone who is not on the team at all.
*/
public function test_add_user_to_project_adds_admin_who_has_access_but_no_membership(): void
{
$added = [];
$repo = $this->makeEmpty(ProjectRepository::class, [
'getProject' => fn () => ['id' => 42],
// An admin: reaches every project...
'isUserAssignedToProject' => fn () => true,
// ...but holds no relation row for this one.
'isUserMemberOfProject' => fn () => false,
'addProjectRelation' => function ($userId, $projectId, $role) use (&$added) {
$added[] = [$userId, $projectId, $role];
},
]);
$this->assertTrue($this->makeService($repo, userRepo: $this->validUserRepo())->addUserToProject(7, 42));
$this->assertSame([[7, 42, '']], $added, 'access must not be mistaken for membership');
}
/**
* Idempotence guard. zp_relationuserproject has no unique index on
* (userId, projectId), so a blind insert would duplicate the row and
* show the person twice on the project team.
*/
public function test_add_user_to_project_is_idempotent_for_existing_member(): void
{
$addCalls = 0;
$repo = $this->makeEmpty(ProjectRepository::class, [
'getProject' => fn () => ['id' => 42],
'isUserMemberOfProject' => fn () => true,
'addProjectRelation' => function () use (&$addCalls) {
$addCalls++;
},
]);
$result = $this->makeService($repo, userRepo: $this->validUserRepo())->addUserToProject(7, 42);
$this->assertFalse($result, 'an existing membership reports false');
$this->assertSame(0, $addCalls, 'must not insert a duplicate relation row');
}
/**
* Invalid ids must fail before touching persistence — a 0 userId
* reaching addProjectRelation would create an orphan relation row.
*/
public function test_add_user_to_project_rejects_invalid_ids(): void
{
$touched = 0;
$repo = $this->makeEmpty(ProjectRepository::class, [
'isUserMemberOfProject' => function () use (&$touched) {
$touched++;
return false;
},
'addProjectRelation' => function () use (&$touched) {
$touched++;
},
]);
$service = $this->makeService($repo);
$this->assertFalse($service->addUserToProject(0, 42));
$this->assertFalse($service->addUserToProject(7, 0));
$this->assertSame(0, $touched, 'invalid ids must not reach the repository');
}
/**
* Non-zero ids that don't resolve to real rows must also fail closed —
* isUserMemberOfProject() returns false for a missing user/project, so
* without the existence guard addProjectRelation() would write an orphan
* relation row for a user or project that isn't there.
*/
public function test_add_user_to_project_rejects_nonexistent_user_or_project(): void
{
$added = 0;
$mkRepo = fn (bool $projectExists) => $this->makeEmpty(ProjectRepository::class, [
'getProject' => fn () => $projectExists ? ['id' => 42] : false,
'isUserMemberOfProject' => fn () => false,
'addProjectRelation' => function () use (&$added) {
$added++;
},
]);
$missingUser = $this->makeEmpty(UserRepository::class, ['getUser' => fn () => false]);
// User missing (project resolves fine).
$this->assertFalse(
$this->makeService($mkRepo(true), userRepo: $missingUser)->addUserToProject(999, 42)
);
// Project missing (user resolves fine).
$this->assertFalse(
$this->makeService($mkRepo(false), userRepo: $this->validUserRepo())->addUserToProject(7, 999)
);
$this->assertSame(0, $added, 'a non-existent user or project must never reach addProjectRelation');
}
/**
* A UserRepository stub whose getUser() resolves to a real row, for the
* addUserToProject() tests that need the existence guard to pass.
*/
private function validUserRepo(): UserRepository
{
return $this->makeEmpty(UserRepository::class, ['getUser' => fn () => ['id' => 7]]);
}
}