OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
96
tests/Unit/app/Core/ApiClientTest.php
Normal file
96
tests/Unit/app/Core/ApiClientTest.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace Test\Unit;
|
||||
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use Leantime\Core\Http\Client\ApiClient;
|
||||
|
||||
class ApiClientTest extends \Unit\TestCase
|
||||
{
|
||||
public function test_o_auth2(): void
|
||||
{
|
||||
$baseUri = 'http://test.com';
|
||||
$stack = HandlerStack::create();
|
||||
$requestDefaults = [];
|
||||
|
||||
$client = ApiClient::oAuth2($baseUri, $stack, $requestDefaults);
|
||||
|
||||
$this->assertEquals('http://test.com', $client->getConfig('base_uri'));
|
||||
$this->assertSame($stack, $client->getConfig('handler'));
|
||||
$this->assertEquals('oauth', $client->getConfig('auth'));
|
||||
}
|
||||
|
||||
public function test_o_auth2_grants(): void
|
||||
{
|
||||
$baseUri = 'http://test.com';
|
||||
$creds = [
|
||||
'client_id' => 'testclient',
|
||||
'client_secret' => 'testsecret',
|
||||
];
|
||||
|
||||
$stack = ApiClient::oAuth2Grants($baseUri, $creds);
|
||||
|
||||
$this->assertInstanceOf(HandlerStack::class, $stack);
|
||||
}
|
||||
|
||||
public function test_o_auth1(): void
|
||||
{
|
||||
$baseUri = 'http://test.com';
|
||||
$creds = [
|
||||
'consumer_key' => 'testconsumer',
|
||||
'consumer_secret' => 'testsecret',
|
||||
'token' => 'testtoken',
|
||||
'token_secret' => 'testtokensecret',
|
||||
];
|
||||
|
||||
$client = ApiClient::oAuth1($baseUri, $creds);
|
||||
|
||||
$this->assertEquals('http://test.com', $client->getConfig('base_uri'));
|
||||
$this->assertEquals('oauth', $client->getConfig('auth'));
|
||||
}
|
||||
|
||||
public function test_basic_auth(): void
|
||||
{
|
||||
$baseUri = 'http://test.com';
|
||||
$creds = [
|
||||
'username' => 'testuser',
|
||||
'password' => 'testpass',
|
||||
];
|
||||
|
||||
$client = ApiClient::basicAuth($baseUri, $creds);
|
||||
|
||||
$this->assertEquals('http://test.com', $client->getConfig('base_uri'));
|
||||
$this->assertEquals($creds, $client->getConfig('auth'));
|
||||
}
|
||||
|
||||
public function test_digest(): void
|
||||
{
|
||||
$baseUri = 'http://test.com';
|
||||
$creds = [
|
||||
'username' => 'testuser',
|
||||
'password' => 'testpass',
|
||||
'digest' => 'testdigest',
|
||||
];
|
||||
|
||||
$client = ApiClient::digest($baseUri, $creds);
|
||||
|
||||
$config = $client->getConfig();
|
||||
$this->assertEquals('http://test.com', $config[1]['base_uri']);
|
||||
$this->assertEquals($creds, $config[1]['auth']);
|
||||
}
|
||||
|
||||
public function test_ntlm(): void
|
||||
{
|
||||
$baseUri = 'http://test.com';
|
||||
$creds = [
|
||||
'username' => 'testuser',
|
||||
'password' => 'testpass',
|
||||
'ntlm' => 'testntlm',
|
||||
];
|
||||
|
||||
$client = ApiClient::ntlm($baseUri, $creds);
|
||||
|
||||
$this->assertEquals('http://test.com', $client->getConfig('base_uri'));
|
||||
$this->assertEquals($creds, $client->getConfig('auth'));
|
||||
}
|
||||
}
|
||||
109
tests/Unit/app/Core/ApplicationUrlTest.php
Normal file
109
tests/Unit/app/Core/ApplicationUrlTest.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\App\Core;
|
||||
|
||||
use Leantime\Core\Application;
|
||||
use Leantime\Core\Bootstrap\LoadConfig;
|
||||
use Leantime\Core\Bootstrap\SetRequestForConsole;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
|
||||
class ApplicationUrlTest extends \Unit\TestCase
|
||||
{
|
||||
protected $app;
|
||||
|
||||
protected $config;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
|
||||
parent::setUp();
|
||||
|
||||
$this->bootstrapApplication();
|
||||
|
||||
}
|
||||
|
||||
protected function bootstrapApplication()
|
||||
{
|
||||
|
||||
$this->app = new Application(APP_ROOT);
|
||||
|
||||
$this->app->bootstrapWith([LoadConfig::class, SetRequestForConsole::class]);
|
||||
$this->app->boot();
|
||||
|
||||
$this->config = $this->app['config'];
|
||||
}
|
||||
|
||||
public function test_base_url_is_set_correctly_from_config(): void
|
||||
{
|
||||
// BASE_URL constant is set from LEAN_APP_URL in the environment.
|
||||
// Verify the config matches whatever BASE_URL was resolved to.
|
||||
$this->assertEquals(BASE_URL, $this->config->get('app.url'));
|
||||
|
||||
// Test with LEAN_APP_URL set to a known value
|
||||
putenv('LEAN_APP_URL=https://example.com');
|
||||
$_ENV['LEAN_APP_URL'] = 'https://example.com';
|
||||
|
||||
// Reinitialize application to test new environment
|
||||
$this->bootstrapApplication();
|
||||
|
||||
$this->assertEquals('https://example.com', $this->config->get('app.url'));
|
||||
$this->assertEquals('https://example.com', $this->config->get('appUrl'));
|
||||
}
|
||||
|
||||
public function test_base_url_handles_trailing_slash(): void
|
||||
{
|
||||
|
||||
$_ENV['LEAN_APP_URL'] = 'https://example.com/';
|
||||
|
||||
$this->bootstrapApplication();
|
||||
|
||||
$this->assertEquals('https://example.com', $this->config->get('app.url'));
|
||||
$this->assertEquals('https://example.com', $this->config->get('appUrl'));
|
||||
}
|
||||
|
||||
public function test_base_url_handles_subdirectory(): void
|
||||
{
|
||||
|
||||
$_ENV['LEAN_APP_URL'] = 'https://example.com/leantime';
|
||||
|
||||
$this->bootstrapApplication();
|
||||
|
||||
$this->assertEquals('https://example.com/leantime', $this->config->get('app.url'));
|
||||
$this->assertEquals('https://example.com/leantime', $this->config->get('appUrl'));
|
||||
}
|
||||
|
||||
public function test_base_url_handles_port(): void
|
||||
{
|
||||
|
||||
$_ENV['LEAN_APP_URL'] = 'https://example.com:8443';
|
||||
|
||||
$this->bootstrapApplication();
|
||||
|
||||
$this->assertEquals('https://example.com:8443', $this->config->get('app.url'));
|
||||
$this->assertEquals('https://example.com:8443', $this->config->get('appUrl'));
|
||||
}
|
||||
|
||||
public function test_base_url_handles_reverse_proxy(): void
|
||||
{
|
||||
// Simulate reverse proxy headers
|
||||
$_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https';
|
||||
$_SERVER['HTTP_X_FORWARDED_HOST'] = 'example.com';
|
||||
|
||||
$_ENV['LEAN_APP_URL'] = 'https://example.com';
|
||||
|
||||
$this->bootstrapApplication();
|
||||
|
||||
$this->assertEquals('https://example.com', $this->config->get('app.url'));
|
||||
$this->assertEquals('https://example.com', $this->config->get('appUrl'));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
parent::tearDown();
|
||||
|
||||
// Clean up environment
|
||||
putenv('LEAN_APP_URL');
|
||||
unset($_SERVER['HTTP_X_FORWARDED_PROTO']);
|
||||
unset($_SERVER['HTTP_X_FORWARDED_HOST']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\app\Core\Auth\Permissions;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\DefaultRolePermissions;
|
||||
use Leantime\Core\Auth\Permissions\Permission;
|
||||
|
||||
/**
|
||||
* Locks the built-in role -> permission matrix against a representative catalog so a change
|
||||
* to DefaultRolePermissions that would over- or under-grant a role fails loudly. This is the
|
||||
* grant-equivalence guard for the pilot: it proves the seeded grants match the documented
|
||||
* role capabilities (readonly view-only; commenter comment/upload; editor content CRUD;
|
||||
* manager moderation + all project perms; admin everything-but-company-settings; owner all).
|
||||
*/
|
||||
class DefaultRolePermissionsTest extends \Unit\TestCase
|
||||
{
|
||||
/** @return array<int, Permission> */
|
||||
private function catalog(): array
|
||||
{
|
||||
return [
|
||||
new Permission('tickets.view', 'View', true),
|
||||
new Permission('tickets.comment', 'Comment', true),
|
||||
new Permission('tickets.upload', 'Upload', true),
|
||||
new Permission('tickets.create', 'Create', true),
|
||||
new Permission('tickets.edit', 'Edit', true),
|
||||
new Permission('tickets.delete', 'Delete', true),
|
||||
new Permission('sprints.view', 'View', true),
|
||||
new Permission('sprints.create', 'Create', true),
|
||||
new Permission('sprints.edit', 'Edit', true),
|
||||
new Permission('sprints.delete', 'Delete', true),
|
||||
new Permission('wiki.view', 'View', true),
|
||||
new Permission('wiki.create', 'Create', true),
|
||||
new Permission('wiki.edit', 'Edit', true),
|
||||
new Permission('wiki.delete', 'Delete', true),
|
||||
new Permission('ideas.view', 'View', true),
|
||||
new Permission('ideas.create', 'Create', true),
|
||||
new Permission('ideas.edit', 'Edit', true),
|
||||
new Permission('ideas.delete', 'Delete', true),
|
||||
new Permission('blueprints.view', 'View', true),
|
||||
new Permission('blueprints.create', 'Create', true),
|
||||
new Permission('blueprints.edit', 'Edit', true),
|
||||
new Permission('blueprints.delete', 'Delete', true),
|
||||
new Permission('goals.view', 'View', true),
|
||||
new Permission('goals.create', 'Create', true),
|
||||
new Permission('goals.edit', 'Edit', true),
|
||||
new Permission('goals.delete', 'Delete', true),
|
||||
new Permission('files.view', 'View', true),
|
||||
new Permission('files.upload', 'Upload', true),
|
||||
new Permission('files.delete', 'Delete', true),
|
||||
new Permission('reports.view', 'View', true),
|
||||
// Calendar: project-scoped capability verbs (view→readonly+, create/edit/delete→editor+)
|
||||
// + a GLOBAL manage verb (admin+ cross-user override; managers do NOT get it).
|
||||
new Permission('calendar.view', 'View', true),
|
||||
new Permission('calendar.create', 'Create', true),
|
||||
new Permission('calendar.edit', 'Edit', true),
|
||||
new Permission('calendar.delete', 'Delete', true),
|
||||
new Permission('calendar.manage', 'Manage any calendar', false),
|
||||
new Permission('comments.view', 'View', true),
|
||||
new Permission('comments.create', 'Create', true),
|
||||
new Permission('comments.moderate', 'Moderate', true),
|
||||
// Company-wide (not project-scoped):
|
||||
new Permission('users.view', 'View users', false),
|
||||
new Permission('users.create', 'Invite/create users', false),
|
||||
new Permission('users.edit', 'Edit users', false),
|
||||
new Permission('users.delete', 'Delete users', false),
|
||||
new Permission('users.import', 'Import users', false),
|
||||
new Permission('clients.view', 'View clients', false),
|
||||
new Permission('clients.create', 'Create clients', false),
|
||||
new Permission('clients.edit', 'Edit clients', false),
|
||||
new Permission('clients.delete', 'Delete clients', false),
|
||||
new Permission('company.settings.view', 'View company settings', false),
|
||||
new Permission('company.settings.edit', 'Edit company settings', false),
|
||||
// Timesheets are company-wide (global): editor gets own-time view/create/edit/delete,
|
||||
// manager+ gets manage (cross-user invoicing/reports).
|
||||
new Permission('timesheets.view', 'View timesheets', false),
|
||||
new Permission('timesheets.create', 'Log time', false),
|
||||
new Permission('timesheets.edit', 'Edit timesheets', false),
|
||||
new Permission('timesheets.delete', 'Delete timesheets', false),
|
||||
new Permission('timesheets.manage', 'Manage timesheets', false),
|
||||
// Project-scoped (rename a project's ticket/idea state labels — manager+ in project):
|
||||
new Permission('projectsettings.labels.manage', 'Rename project labels', true),
|
||||
// Projects: view is project-scoped (readonly+ data read); create/edit/delete are GLOBAL
|
||||
// company actions (manager+; editors do NOT get them since global perms aren't matched
|
||||
// by the editor project-verb rule — same shape as the timesheets globals).
|
||||
new Permission('projects.view', 'View a project', true),
|
||||
new Permission('projects.create', 'Create projects', false),
|
||||
new Permission('projects.edit', 'Edit projects', false),
|
||||
new Permission('projects.delete', 'Delete projects', false),
|
||||
];
|
||||
}
|
||||
|
||||
private function grantsFor(string $role): array
|
||||
{
|
||||
return DefaultRolePermissions::grantsFor($role, $this->catalog());
|
||||
}
|
||||
|
||||
public function test_readonly_can_only_view_project_content(): void
|
||||
{
|
||||
$this->assertEqualsCanonicalizing(['tickets.view', 'comments.view', 'sprints.view', 'wiki.view', 'ideas.view', 'blueprints.view', 'goals.view', 'files.view', 'reports.view', 'calendar.view', 'projects.view'], $this->grantsFor('readonly'));
|
||||
}
|
||||
|
||||
public function test_commenter_adds_comment_upload_and_can_create_comments(): void
|
||||
{
|
||||
$grants = $this->grantsFor('commenter');
|
||||
|
||||
$this->assertContains('tickets.view', $grants); // inherited
|
||||
$this->assertContains('tickets.comment', $grants);
|
||||
$this->assertContains('tickets.upload', $grants);
|
||||
$this->assertContains('comments.create', $grants); // explicit commenter grant
|
||||
$this->assertNotContains('tickets.create', $grants);
|
||||
$this->assertNotContains('tickets.delete', $grants);
|
||||
$this->assertNotContains('sprints.create', $grants); // commenter views but cannot create
|
||||
$this->assertContains('sprints.view', $grants); // inherited from readonly
|
||||
$this->assertNotContains('wiki.create', $grants); // commenter views but cannot create
|
||||
$this->assertContains('wiki.view', $grants); // inherited from readonly
|
||||
$this->assertNotContains('ideas.create', $grants); // commenter views but cannot create
|
||||
$this->assertContains('ideas.view', $grants); // inherited from readonly
|
||||
$this->assertNotContains('blueprints.create', $grants); // commenter views but cannot create
|
||||
$this->assertContains('blueprints.view', $grants); // inherited from readonly
|
||||
$this->assertNotContains('goals.create', $grants); // commenter views but cannot create
|
||||
$this->assertContains('goals.view', $grants); // inherited from readonly
|
||||
// Files: a commenter inherits view and gains the standard upload verb (attachments), but
|
||||
// cannot delete (editor+).
|
||||
$this->assertContains('files.view', $grants); // inherited from readonly
|
||||
$this->assertContains('files.upload', $grants); // commenter upload verb
|
||||
$this->assertNotContains('files.delete', $grants); // editor+
|
||||
// Reports: view-only feature, inherited from readonly (maintainer-approved loosening of
|
||||
// the legacy editor+ page gate — it only aggregates readonly-visible data).
|
||||
$this->assertContains('reports.view', $grants);
|
||||
// Timesheets are editor+ (global); a commenter logs no time.
|
||||
$this->assertNotContains('timesheets.view', $grants);
|
||||
$this->assertNotContains('timesheets.create', $grants);
|
||||
$this->assertNotContains('comments.moderate', $grants);
|
||||
}
|
||||
|
||||
public function test_editor_gets_content_crud_but_not_moderation_or_company(): void
|
||||
{
|
||||
$grants = $this->grantsFor('editor');
|
||||
|
||||
$this->assertContains('tickets.create', $grants);
|
||||
$this->assertContains('tickets.edit', $grants);
|
||||
$this->assertContains('tickets.delete', $grants);
|
||||
// Sprints uses the same standard project verbs, so editor auto-gets create/edit/delete.
|
||||
$this->assertContains('sprints.create', $grants);
|
||||
$this->assertContains('sprints.edit', $grants);
|
||||
$this->assertContains('sprints.delete', $grants);
|
||||
// Wiki uses the same standard project verbs, so editor auto-gets create/edit/delete.
|
||||
$this->assertContains('wiki.create', $grants);
|
||||
$this->assertContains('wiki.edit', $grants);
|
||||
$this->assertContains('wiki.delete', $grants);
|
||||
// Ideas uses the same standard project verbs, so editor auto-gets create/edit/delete.
|
||||
$this->assertContains('ideas.create', $grants);
|
||||
$this->assertContains('ideas.edit', $grants);
|
||||
$this->assertContains('ideas.delete', $grants);
|
||||
// Blueprints (canvas) uses the same standard project verbs, so editor auto-gets create/edit/delete.
|
||||
$this->assertContains('blueprints.create', $grants);
|
||||
$this->assertContains('blueprints.edit', $grants);
|
||||
$this->assertContains('blueprints.delete', $grants);
|
||||
$this->assertContains('goals.create', $grants);
|
||||
$this->assertContains('goals.edit', $grants);
|
||||
$this->assertContains('goals.delete', $grants);
|
||||
// Files uses standard project verbs, so editor auto-gets upload + delete (and view).
|
||||
$this->assertContains('files.view', $grants);
|
||||
$this->assertContains('files.upload', $grants);
|
||||
$this->assertContains('files.delete', $grants);
|
||||
// Timesheets are GLOBAL-scoped, so the project verb rule does NOT match them — editor gets
|
||||
// its own-time keys explicitly (view/create/edit/delete) but NOT the manager-only `manage`.
|
||||
$this->assertContains('timesheets.view', $grants);
|
||||
$this->assertContains('timesheets.create', $grants);
|
||||
$this->assertContains('timesheets.edit', $grants);
|
||||
$this->assertContains('timesheets.delete', $grants);
|
||||
$this->assertNotContains('timesheets.manage', $grants);
|
||||
// Calendar uses standard PROJECT verbs, so editor auto-gets view/create/edit/delete; the
|
||||
// GLOBAL manage verb (cross-user override) stays admin+.
|
||||
$this->assertContains('calendar.view', $grants);
|
||||
$this->assertContains('calendar.create', $grants);
|
||||
$this->assertContains('calendar.edit', $grants);
|
||||
$this->assertContains('calendar.delete', $grants);
|
||||
$this->assertNotContains('calendar.manage', $grants);
|
||||
// Projects: editor can VIEW projects (inherited from readonly) but project create/edit/delete
|
||||
// are GLOBAL company actions reserved for manager+ (editors do NOT manage projects).
|
||||
$this->assertContains('projects.view', $grants);
|
||||
$this->assertNotContains('projects.create', $grants);
|
||||
$this->assertNotContains('projects.edit', $grants);
|
||||
$this->assertNotContains('projects.delete', $grants);
|
||||
$this->assertContains('comments.create', $grants); // inherited
|
||||
$this->assertNotContains('comments.moderate', $grants); // manager+ only
|
||||
$this->assertNotContains('users.view', $grants); // company-wide, admin+
|
||||
$this->assertNotContains('users.create', $grants); // company-wide, manager+
|
||||
$this->assertNotContains('clients.view', $grants); // company-wide, admin+
|
||||
$this->assertNotContains('company.settings.view', $grants);
|
||||
// Label renaming uses the 'manage' verb (not 'edit'), so it stays manager+ and does NOT
|
||||
// leak to editor via the project create/edit/delete grant.
|
||||
$this->assertNotContains('projectsettings.labels.manage', $grants);
|
||||
$this->assertNotContains('company.settings.edit', $grants);
|
||||
}
|
||||
|
||||
public function test_manager_moderates_and_holds_all_project_perms_but_no_company(): void
|
||||
{
|
||||
$grants = $this->grantsFor('manager');
|
||||
|
||||
$this->assertContains('comments.moderate', $grants);
|
||||
$this->assertContains('tickets.delete', $grants);
|
||||
// Timesheets: manager gets the company-wide manage verb AND inherits editor's own-time keys.
|
||||
$this->assertContains('timesheets.manage', $grants);
|
||||
$this->assertContains('timesheets.view', $grants);
|
||||
$this->assertContains('timesheets.edit', $grants);
|
||||
// Calendar: manager holds all four project capability verbs (project '*' rule) but NOT the
|
||||
// cross-user override — calendar.manage is GLOBAL-scoped and admin-only (legacy override was
|
||||
// Auth::userIsAtLeast(admin)).
|
||||
$this->assertContains('calendar.view', $grants);
|
||||
$this->assertContains('calendar.create', $grants);
|
||||
$this->assertContains('calendar.edit', $grants);
|
||||
$this->assertContains('calendar.delete', $grants);
|
||||
$this->assertNotContains('calendar.manage', $grants);
|
||||
// Projects: manager gets the GLOBAL project-management keys (the matrix edit) + inherits view.
|
||||
$this->assertContains('projects.view', $grants);
|
||||
$this->assertContains('projects.create', $grants);
|
||||
$this->assertContains('projects.edit', $grants);
|
||||
$this->assertContains('projects.delete', $grants);
|
||||
// Managers may INVITE users (within their own client — scoped in the controller), but
|
||||
// cannot view the roster, edit, delete, or import accounts (those stay admin+).
|
||||
$this->assertContains('users.create', $grants);
|
||||
$this->assertNotContains('users.view', $grants);
|
||||
$this->assertNotContains('users.edit', $grants);
|
||||
$this->assertNotContains('users.delete', $grants);
|
||||
$this->assertNotContains('users.import', $grants);
|
||||
// Client management stays admin+ (managers have no real client access today — ShowAll
|
||||
// redirects them and ShowClient 403s them), so a manager gets NO clients.* —
|
||||
// grant-equivalent with the current behavior, not the aspirational target matrix.
|
||||
$this->assertNotContains('clients.view', $grants);
|
||||
$this->assertNotContains('clients.create', $grants);
|
||||
$this->assertNotContains('clients.edit', $grants);
|
||||
$this->assertNotContains('clients.delete', $grants);
|
||||
// Renaming a project's labels is a manager-in-project capability (project '*' grant).
|
||||
$this->assertContains('projectsettings.labels.manage', $grants);
|
||||
$this->assertNotContains('company.settings.view', $grants);
|
||||
$this->assertNotContains('company.settings.edit', $grants);
|
||||
}
|
||||
|
||||
public function test_admin_gets_company_wide_including_company_settings(): void
|
||||
{
|
||||
$grants = $this->grantsFor('admin');
|
||||
|
||||
$this->assertContains('users.view', $grants);
|
||||
$this->assertContains('users.create', $grants);
|
||||
$this->assertContains('users.edit', $grants);
|
||||
$this->assertContains('users.delete', $grants);
|
||||
$this->assertContains('users.import', $grants); // full user management
|
||||
$this->assertContains('clients.view', $grants);
|
||||
$this->assertContains('clients.create', $grants);
|
||||
$this->assertContains('clients.edit', $grants);
|
||||
$this->assertContains('clients.delete', $grants); // full client management
|
||||
$this->assertContains('projectsettings.labels.manage', $grants);
|
||||
$this->assertContains('comments.moderate', $grants);
|
||||
$this->assertContains('tickets.delete', $grants);
|
||||
$this->assertContains('calendar.manage', $grants); // cross-user calendar override (admin+)
|
||||
// Per policy (admin views + edits company settings), admins hold both company.settings
|
||||
// keys via an explicit grant alongside the wildcard-with-exclude rule.
|
||||
$this->assertContains('company.settings.view', $grants);
|
||||
$this->assertContains('company.settings.edit', $grants);
|
||||
}
|
||||
|
||||
public function test_owner_gets_everything_including_company_settings(): void
|
||||
{
|
||||
$grants = $this->grantsFor('owner');
|
||||
|
||||
$this->assertContains('company.settings.view', $grants);
|
||||
$this->assertContains('company.settings.edit', $grants);
|
||||
$this->assertContains('projectsettings.labels.manage', $grants);
|
||||
$this->assertContains('clients.delete', $grants);
|
||||
$this->assertContains('users.view', $grants);
|
||||
$this->assertContains('comments.moderate', $grants);
|
||||
$this->assertContains('tickets.delete', $grants);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression: a rule that combines an explicit `keys` allow-list with an `exclude` list must
|
||||
* still honor the exclude. matches() previously returned early for `keys` rules and bypassed
|
||||
* the exclude entirely, which could over-grant an excluded permission.
|
||||
*/
|
||||
public function test_keys_rule_still_honors_exclude(): void
|
||||
{
|
||||
$matches = new \ReflectionMethod(DefaultRolePermissions::class, 'matches');
|
||||
$matches->setAccessible(true);
|
||||
|
||||
$rule = [
|
||||
'scope' => 'global',
|
||||
'keys' => ['company.settings.view', 'company.settings.edit'],
|
||||
'exclude' => ['company.settings.edit'],
|
||||
];
|
||||
|
||||
$included = new Permission('company.settings.view', 'View', false);
|
||||
$excluded = new Permission('company.settings.edit', 'Edit', false);
|
||||
|
||||
$this->assertTrue($matches->invoke(null, $included, $rule), 'A keys-listed, non-excluded permission still matches');
|
||||
$this->assertFalse($matches->invoke(null, $excluded, $rule), 'A keys-listed permission that is also excluded must NOT match');
|
||||
}
|
||||
}
|
||||
206
tests/Unit/app/Core/Auth/Permissions/PermissionEnforcerTest.php
Normal file
206
tests/Unit/app/Core/Auth/Permissions/PermissionEnforcerTest.php
Normal file
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\app\Core\Auth\Permissions;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\PermissionEnforcer;
|
||||
use Leantime\Core\Auth\Permissions\PermissionService;
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
|
||||
/**
|
||||
* Verifies the PermissionEnforcer resolves the project scope correctly for each
|
||||
* RequiresPermission mode: entityScoped defers (the method self-authorizes its loaded entity),
|
||||
* global checks the company-wide role, projectIdParam reads the named request param, and the
|
||||
* default falls back to the session project. A method with no attribute is a complete no-op.
|
||||
*/
|
||||
class PermissionEnforcerTest extends \Unit\TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/**
|
||||
* Build an enforcer whose engine records every currentUserCan(...) call into $calls and
|
||||
* answers $allow, so we can assert exactly what the enforcer asked the engine.
|
||||
*
|
||||
* @param array<int, array{key: string, projectId: ?int, forceGlobal: bool}> $calls
|
||||
*/
|
||||
private function spyEnforcer(array &$calls, bool $allow = true): PermissionEnforcer
|
||||
{
|
||||
$permissions = $this->make(PermissionService::class, [
|
||||
'currentUserCan' => function (string $key, ?int $projectId = null, ?bool $forceGlobal = false) use (&$calls, $allow): bool {
|
||||
$calls[] = ['key' => $key, 'projectId' => $projectId, 'forceGlobal' => (bool) $forceGlobal];
|
||||
|
||||
return $allow;
|
||||
},
|
||||
]);
|
||||
|
||||
return new PermissionEnforcer($permissions);
|
||||
}
|
||||
|
||||
public function test_entity_scoped_defers_and_never_calls_the_engine(): void
|
||||
{
|
||||
// entityScoped methods authorize their loaded entity's project in their own body, so the
|
||||
// enforcer must not run a check here (it can't see the entity, would use the wrong
|
||||
// project). Even a denying engine must produce no call and no throw.
|
||||
$calls = [];
|
||||
$enforcer = $this->spyEnforcer($calls, allow: false);
|
||||
|
||||
$enforcer->enforce(PermissionEnforcerFixture::class, 'entityScopedAction', ['id' => 5]);
|
||||
|
||||
$this->assertSame([], $calls, 'entityScoped should defer to the in-method authorize()');
|
||||
}
|
||||
|
||||
public function test_global_checks_company_role_not_a_project(): void
|
||||
{
|
||||
$calls = [];
|
||||
$enforcer = $this->spyEnforcer($calls);
|
||||
|
||||
$enforcer->enforce(PermissionEnforcerFixture::class, 'globalAction', []);
|
||||
|
||||
$this->assertSame([['key' => 'users.create', 'projectId' => null, 'forceGlobal' => true]], $calls);
|
||||
}
|
||||
|
||||
public function test_project_id_param_is_read_from_the_named_argument(): void
|
||||
{
|
||||
$calls = [];
|
||||
$enforcer = $this->spyEnforcer($calls);
|
||||
|
||||
$enforcer->enforce(PermissionEnforcerFixture::class, 'paramAction', ['projectId' => 42]);
|
||||
|
||||
$this->assertSame([['key' => 'tickets.view', 'projectId' => 42, 'forceGlobal' => false]], $calls);
|
||||
}
|
||||
|
||||
public function test_default_falls_back_to_the_session_project(): void
|
||||
{
|
||||
session(['currentProject' => 7]);
|
||||
|
||||
$calls = [];
|
||||
$enforcer = $this->spyEnforcer($calls);
|
||||
|
||||
$enforcer->enforce(PermissionEnforcerFixture::class, 'sessionAction', []);
|
||||
|
||||
$this->assertSame([['key' => 'tickets.view', 'projectId' => 7, 'forceGlobal' => false]], $calls);
|
||||
}
|
||||
|
||||
public function test_unannotated_method_is_a_noop(): void
|
||||
{
|
||||
$calls = [];
|
||||
$enforcer = $this->spyEnforcer($calls, allow: false);
|
||||
|
||||
$enforcer->enforce(PermissionEnforcerFixture::class, 'plainAction', []);
|
||||
|
||||
$this->assertSame([], $calls);
|
||||
}
|
||||
|
||||
public function test_mandatory_project_param_absent_fails_closed(): void
|
||||
{
|
||||
// paramAction declares projectIdParam:'projectId' and types it `int` (no default) — the
|
||||
// project is mandatory. With it absent, the enforcer must NOT fall back to the session
|
||||
// project (which would authorize the wrong project); it denies without consulting the
|
||||
// engine. allow:true proves the denial comes from the unresolved-project path, not a
|
||||
// negative engine answer.
|
||||
config(['permissions.enforce' => true]);
|
||||
|
||||
$calls = [];
|
||||
$enforcer = $this->spyEnforcer($calls, allow: true);
|
||||
|
||||
$threw = false;
|
||||
try {
|
||||
$enforcer->enforce(PermissionEnforcerFixture::class, 'paramAction', []);
|
||||
} catch (\Leantime\Core\Exceptions\AuthorizationException) {
|
||||
$threw = true;
|
||||
}
|
||||
|
||||
$this->assertTrue($threw, 'an unresolvable mandatory project param must deny');
|
||||
$this->assertSame([], $calls, 'the engine must not be consulted when the project is unresolvable');
|
||||
}
|
||||
|
||||
public function test_mandatory_project_param_explicit_null_fails_closed(): void
|
||||
{
|
||||
// isset() was the original bug: it is false for an explicit null, so a null projectId
|
||||
// silently fell through to the session project. A mandatory param passed null now denies.
|
||||
config(['permissions.enforce' => true]);
|
||||
|
||||
$calls = [];
|
||||
$enforcer = $this->spyEnforcer($calls, allow: true);
|
||||
|
||||
$threw = false;
|
||||
try {
|
||||
$enforcer->enforce(PermissionEnforcerFixture::class, 'paramAction', ['projectId' => null]);
|
||||
} catch (\Leantime\Core\Exceptions\AuthorizationException) {
|
||||
$threw = true;
|
||||
}
|
||||
|
||||
$this->assertTrue($threw, 'an explicit-null mandatory project param must deny');
|
||||
$this->assertSame([], $calls);
|
||||
}
|
||||
|
||||
public function test_invalid_project_param_is_treated_as_unresolved(): void
|
||||
{
|
||||
// A bare (int) cast would mis-resolve every one of these: [7] (array) → 1, '-5' → -5,
|
||||
// '7abc' → 7, and an out-of-range digit string → PHP_INT_MAX. None name a real project,
|
||||
// so each must be unresolved → deny for a mandatory param, never silently coerced.
|
||||
config(['permissions.enforce' => true]);
|
||||
|
||||
$invalid = [
|
||||
['projectId' => [7]], // non-scalar
|
||||
['projectId' => '-5'], // negative
|
||||
['projectId' => '7abc'], // non-numeric
|
||||
['projectId' => '999999999999999999999999'], // overflows the platform int range
|
||||
];
|
||||
|
||||
foreach ($invalid as $params) {
|
||||
$calls = [];
|
||||
$enforcer = $this->spyEnforcer($calls, allow: true);
|
||||
|
||||
$threw = false;
|
||||
try {
|
||||
$enforcer->enforce(PermissionEnforcerFixture::class, 'paramAction', $params);
|
||||
} catch (\Leantime\Core\Exceptions\AuthorizationException) {
|
||||
$threw = true;
|
||||
}
|
||||
|
||||
$this->assertTrue($threw, 'non-positive-integer project param must be unresolved → deny: '.json_encode($params));
|
||||
$this->assertSame([], $calls);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_optional_project_param_keeps_the_session_fallback(): void
|
||||
{
|
||||
// optionalParamAction defaults projectId to null ("current project"), so an absent value
|
||||
// is legitimate — the enforcer authorizes against the session project, exactly as the
|
||||
// method itself will operate. This is what makes the poll/dashboard endpoints keep working.
|
||||
session(['currentProject' => 7]);
|
||||
|
||||
$calls = [];
|
||||
$enforcer = $this->spyEnforcer($calls);
|
||||
|
||||
$enforcer->enforce(PermissionEnforcerFixture::class, 'optionalParamAction', []);
|
||||
|
||||
$this->assertSame([['key' => 'tickets.view', 'projectId' => 7, 'forceGlobal' => false]], $calls);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture exercising each RequiresPermission resolution mode. Bodies are intentionally empty —
|
||||
* only the attributes matter to the enforcer.
|
||||
*/
|
||||
class PermissionEnforcerFixture
|
||||
{
|
||||
#[RequiresPermission('tickets.edit', entityScoped: true)]
|
||||
public function entityScopedAction(int $id): void {}
|
||||
|
||||
#[RequiresPermission('users.create', global: true)]
|
||||
public function globalAction(): void {}
|
||||
|
||||
#[RequiresPermission('tickets.view', projectIdParam: 'projectId')]
|
||||
public function paramAction(int $projectId): void {}
|
||||
|
||||
// Same attribute, but the project param is OPTIONAL (defaults to null) — "current project"
|
||||
// semantics. An absent/null value must keep the session fallback, not deny.
|
||||
#[RequiresPermission('tickets.view', projectIdParam: 'projectId')]
|
||||
public function optionalParamAction(?int $projectId = null): void {}
|
||||
|
||||
#[RequiresPermission('tickets.view')]
|
||||
public function sessionAction(): void {}
|
||||
|
||||
public function plainAction(): void {}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Controller;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Leantime\Core\Auth\Permissions\PermissionEnforcer;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Regression coverage for stale route-cache entries. Routes resolved by the
|
||||
* Frontcontroller are cached across requests in the installation store and can
|
||||
* outlive a deploy: a controller whose run() was replaced by get()/post() left
|
||||
* a cached ['method' => 'run'] entry behind, and callAction('run') then hit
|
||||
* __call() and produced a 500 (seen in production on /calendar/showMyCalendar
|
||||
* and /timesheets/showMy). A cached entry must only be trusted if its class and
|
||||
* method still exist; otherwise it gets dropped and the route re-resolved.
|
||||
*/
|
||||
class FrontcontrollerRouteCacheTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// The cached-route read path is only taken when debug is off (writes happen
|
||||
// regardless), so the validation under test requires debug to be disabled.
|
||||
config(['debug' => false]);
|
||||
}
|
||||
|
||||
private function frontcontroller(): Frontcontroller
|
||||
{
|
||||
// Built by hand: container resolution would pull in the real
|
||||
// PermissionEnforcer, which needs a database connection.
|
||||
return new Frontcontroller(
|
||||
IncomingRequest::create('/calendar/showMyCalendar', 'GET'),
|
||||
$this->createMock(PermissionEnforcer::class),
|
||||
);
|
||||
}
|
||||
|
||||
private function cacheKey(string $module, string $action, string $method): string
|
||||
{
|
||||
return 'routes.'.$module.'.Controllers.'.$action.'.'.$method;
|
||||
}
|
||||
|
||||
public function test_stale_cached_method_is_dropped_and_route_reresolved(): void
|
||||
{
|
||||
// Simulate a pre-deploy cache entry pointing at the removed run() method.
|
||||
$key = $this->cacheKey('Calendar', 'ShowMyCalendar', 'get');
|
||||
Cache::store('installation')->set($key, [
|
||||
'class' => \Leantime\Domain\Calendar\Controllers\ShowMyCalendar::class,
|
||||
'method' => 'run',
|
||||
]);
|
||||
|
||||
$result = $this->frontcontroller()->getValidControllerCall('calendar', 'showMyCalendar', 'get', 'Controllers');
|
||||
|
||||
$this->assertSame('get', $result['method']);
|
||||
$this->assertSame(\Leantime\Domain\Calendar\Controllers\ShowMyCalendar::class, $result['class']);
|
||||
|
||||
// The stale entry must have been replaced with the fresh resolution.
|
||||
$this->assertSame($result, Cache::store('installation')->get($key));
|
||||
}
|
||||
|
||||
public function test_cached_entry_with_missing_class_is_dropped(): void
|
||||
{
|
||||
$key = $this->cacheKey('Calendar', 'ShowMyCalendar', 'get');
|
||||
Cache::store('installation')->set($key, [
|
||||
'class' => 'Leantime\\Domain\\Calendar\\Controllers\\NoLongerExists',
|
||||
'method' => 'get',
|
||||
]);
|
||||
|
||||
$result = $this->frontcontroller()->getValidControllerCall('calendar', 'showMyCalendar', 'get', 'Controllers');
|
||||
|
||||
$this->assertSame(\Leantime\Domain\Calendar\Controllers\ShowMyCalendar::class, $result['class']);
|
||||
$this->assertSame('get', $result['method']);
|
||||
}
|
||||
|
||||
public function test_valid_cached_entry_is_returned_as_is(): void
|
||||
{
|
||||
$key = $this->cacheKey('Calendar', 'ShowMyCalendar', 'get');
|
||||
$cached = [
|
||||
'class' => \Leantime\Domain\Calendar\Controllers\ShowMyCalendar::class,
|
||||
'method' => 'get',
|
||||
];
|
||||
Cache::store('installation')->set($key, $cached);
|
||||
|
||||
$result = $this->frontcontroller()->getValidControllerCall('calendar', 'showMyCalendar', 'get', 'Controllers');
|
||||
|
||||
$this->assertSame($cached, $result);
|
||||
}
|
||||
}
|
||||
395
tests/Unit/app/Core/Events/ClassEventDispatchTest.php
Normal file
395
tests/Unit/app/Core/Events/ClassEventDispatchTest.php
Normal file
@@ -0,0 +1,395 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Events;
|
||||
|
||||
use Leantime\Core\Events\Concerns\InteractsWithEvents;
|
||||
use Leantime\Core\Events\Concerns\InteractsWithFilters;
|
||||
use Leantime\Core\Events\Contracts\LeantimeEvent;
|
||||
use Leantime\Core\Events\Contracts\LeantimeFilter;
|
||||
use Leantime\Core\Events\EventDispatcher;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Fixture event mirroring a migrated domain event: typed payload plus the
|
||||
* `legacyHook: __FUNCTION__` discriminator pattern — each dispatch rebuilds the single
|
||||
* historical name of its emit site (never a static list of all sites).
|
||||
*/
|
||||
class FixtureThingUpdated implements LeantimeEvent
|
||||
{
|
||||
use InteractsWithEvents;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $thingId,
|
||||
private readonly ?string $legacyHook = null,
|
||||
) {}
|
||||
|
||||
public function legacyHooks(): array
|
||||
{
|
||||
if ($this->legacyHook === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ['leantime.domain.things.services.things.'.$this->legacyHook.'.thing_updated'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture event without legacy hooks (an event introduced after the class-based system).
|
||||
*/
|
||||
class FixtureThingCreated implements LeantimeEvent
|
||||
{
|
||||
use InteractsWithEvents;
|
||||
|
||||
public function __construct(public readonly int $thingId) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture class-based listener (resolved through the container, handle() receives the
|
||||
* typed event object).
|
||||
*/
|
||||
class FixtureThingListener
|
||||
{
|
||||
public static array $received = [];
|
||||
|
||||
public function handle(FixtureThingUpdated $event): void
|
||||
{
|
||||
self::$received[] = $event;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture invokable listener (no handle() method) to cover the __invoke fallback for
|
||||
* array-form registrations like [FixtureInvokableListener::class].
|
||||
*/
|
||||
class FixtureInvokableListener
|
||||
{
|
||||
public static ?object $received = null;
|
||||
|
||||
public function __invoke(FixtureThingCreated $event): void
|
||||
{
|
||||
self::$received = $event;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture filter mirroring a migrated domain filter: payload plus typed context.
|
||||
*/
|
||||
class FixtureThingsFilter implements LeantimeFilter
|
||||
{
|
||||
use InteractsWithFilters;
|
||||
|
||||
public function __construct(public array $things, public readonly int $userId) {}
|
||||
|
||||
public function payload(): mixed
|
||||
{
|
||||
return $this->things;
|
||||
}
|
||||
|
||||
public function legacyHooks(): array
|
||||
{
|
||||
return [
|
||||
'leantime.domain.things.services.things.getThings.filterThings',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class ClassEventDispatchTest extends TestCase
|
||||
{
|
||||
private array $staticSnapshot = [];
|
||||
|
||||
private const STATIC_PROPS = [
|
||||
'eventRegistry',
|
||||
'filterRegistry',
|
||||
'available_hooks',
|
||||
'patternMatchCache',
|
||||
'compiledPatternCache',
|
||||
'eventRegistryVersion',
|
||||
'filterRegistryVersion',
|
||||
];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$reflection = new \ReflectionClass(EventDispatcher::class);
|
||||
foreach (self::STATIC_PROPS as $prop) {
|
||||
$property = $reflection->getProperty($prop);
|
||||
$this->staticSnapshot[$prop] = $property->getValue();
|
||||
}
|
||||
|
||||
FixtureThingListener::$received = [];
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$reflection = new \ReflectionClass(EventDispatcher::class);
|
||||
foreach ($this->staticSnapshot as $prop => $value) {
|
||||
$property = $reflection->getProperty($prop);
|
||||
$property->setValue(null, $value);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* A closure listener registered on the FQCN receives the bare typed event object.
|
||||
*/
|
||||
public function test_fqcn_closure_listener_receives_typed_event_object(): void
|
||||
{
|
||||
$received = null;
|
||||
EventDispatcher::add_event_listener(FixtureThingUpdated::class, function ($event) use (&$received) {
|
||||
$received = $event;
|
||||
});
|
||||
|
||||
FixtureThingUpdated::dispatch(thingId: 42);
|
||||
|
||||
$this->assertInstanceOf(FixtureThingUpdated::class, $received);
|
||||
$this->assertSame(42, $received->thingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* A class-string listener registered on the FQCN is container-resolved and its
|
||||
* handle() method receives the typed event object. This is the cacheable
|
||||
* registration style new code should use (no closures).
|
||||
*/
|
||||
public function test_fqcn_class_listener_handle_receives_typed_event_object(): void
|
||||
{
|
||||
EventDispatcher::add_event_listener(FixtureThingUpdated::class, FixtureThingListener::class);
|
||||
|
||||
FixtureThingUpdated::dispatch(thingId: 7);
|
||||
|
||||
$this->assertCount(1, FixtureThingListener::$received);
|
||||
$this->assertSame(7, FixtureThingListener::$received[0]->thingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* An invokable listener registered in array form ([Class::class], no handle()
|
||||
* method) falls back to __invoke() — same as the string registration form.
|
||||
*/
|
||||
public function test_array_form_invokable_listener_falls_back_to_invoke(): void
|
||||
{
|
||||
FixtureInvokableListener::$received = null;
|
||||
EventDispatcher::add_event_listener(FixtureThingCreated::class, [FixtureInvokableListener::class]);
|
||||
|
||||
FixtureThingCreated::dispatch(thingId: 11);
|
||||
|
||||
$this->assertInstanceOf(FixtureThingCreated::class, FixtureInvokableListener::$received);
|
||||
$this->assertSame(11, FixtureInvokableListener::$received->thingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* BACKWARDS COMPATIBILITY: a listener registered on the exact historical string name
|
||||
* fires and receives today's array payload (event properties + current_route +
|
||||
* currentEvent) — NOT the event object. Existing plugins keep working unchanged.
|
||||
*/
|
||||
public function test_legacy_string_listener_receives_legacy_array_payload(): void
|
||||
{
|
||||
$received = null;
|
||||
EventDispatcher::add_event_listener(
|
||||
'leantime.domain.things.services.things.updateThing.thing_updated',
|
||||
function ($params) use (&$received) {
|
||||
$received = $params;
|
||||
}
|
||||
);
|
||||
|
||||
FixtureThingUpdated::dispatch(thingId: 42, legacyHook: 'updateThing');
|
||||
|
||||
$this->assertIsArray($received);
|
||||
$this->assertSame(42, $received['thingId']);
|
||||
$this->assertSame(
|
||||
'leantime.domain.things.services.things.updateThing.thing_updated',
|
||||
$received['currentEvent']
|
||||
);
|
||||
$this->assertArrayHasKey('current_route', $received);
|
||||
}
|
||||
|
||||
/**
|
||||
* BACKWARDS COMPATIBILITY: plugin wildcard subscriptions (leantime.domain.*.services.*)
|
||||
* match the legacy name of a class-based event — exactly ONCE per dispatch, because
|
||||
* each emit site contributes only its own historical name via the legacyHook
|
||||
* discriminator. Both historical names stay reachable from their respective sites.
|
||||
*/
|
||||
public function test_wildcard_listener_fires_once_per_dispatch_for_legacy_hook(): void
|
||||
{
|
||||
$called = 0;
|
||||
EventDispatcher::add_event_listener('leantime.domain.*.services.*', function () use (&$called) {
|
||||
$called++;
|
||||
});
|
||||
|
||||
FixtureThingUpdated::dispatch(thingId: 1, legacyHook: 'updateThing');
|
||||
$this->assertSame(1, $called);
|
||||
|
||||
FixtureThingUpdated::dispatch(thingId: 1, legacyHook: 'patchThing');
|
||||
$this->assertSame(2, $called);
|
||||
}
|
||||
|
||||
/**
|
||||
* BACKWARDS COMPATIBILITY: an exact subscriber to one historical site's name does
|
||||
* NOT fire when a different site emits the same logical event — per-site semantics
|
||||
* are preserved through the migration window.
|
||||
*/
|
||||
public function test_exact_legacy_listener_keeps_per_site_semantics(): void
|
||||
{
|
||||
$called = 0;
|
||||
EventDispatcher::add_event_listener(
|
||||
'leantime.domain.things.services.things.patchThing.thing_updated',
|
||||
function () use (&$called) {
|
||||
$called++;
|
||||
}
|
||||
);
|
||||
|
||||
FixtureThingUpdated::dispatch(thingId: 1, legacyHook: 'updateThing');
|
||||
$this->assertSame(0, $called);
|
||||
|
||||
FixtureThingUpdated::dispatch(thingId: 1, legacyHook: 'patchThing');
|
||||
$this->assertSame(1, $called);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wildcard string listeners do NOT accidentally match the FQCN (backslashes and
|
||||
* case don't fit the dotted lowercase patterns).
|
||||
*/
|
||||
public function test_wildcard_listener_does_not_match_fqcn(): void
|
||||
{
|
||||
$called = 0;
|
||||
EventDispatcher::add_event_listener('leantime.*', function () use (&$called) {
|
||||
$called++;
|
||||
});
|
||||
|
||||
FixtureThingCreated::dispatch(thingId: 1);
|
||||
|
||||
$this->assertSame(0, $called);
|
||||
}
|
||||
|
||||
/**
|
||||
* An event with no legacy hooks only reaches FQCN listeners.
|
||||
*/
|
||||
public function test_event_without_legacy_hooks_fires_fqcn_listener_only(): void
|
||||
{
|
||||
$received = null;
|
||||
EventDispatcher::add_event_listener(FixtureThingCreated::class, function ($event) use (&$received) {
|
||||
$received = $event;
|
||||
});
|
||||
|
||||
FixtureThingCreated::dispatch(thingId: 9);
|
||||
|
||||
$this->assertInstanceOf(FixtureThingCreated::class, $received);
|
||||
$this->assertContains(FixtureThingCreated::class, EventDispatcher::get_available_hooks()['events']);
|
||||
}
|
||||
|
||||
/**
|
||||
* FQCN listeners run in priority order, lower number first.
|
||||
*/
|
||||
public function test_fqcn_listeners_run_in_priority_order(): void
|
||||
{
|
||||
$order = [];
|
||||
EventDispatcher::add_event_listener(FixtureThingCreated::class, function () use (&$order) {
|
||||
$order[] = 30;
|
||||
}, 30);
|
||||
EventDispatcher::add_event_listener(FixtureThingCreated::class, function () use (&$order) {
|
||||
$order[] = 10;
|
||||
}, 10);
|
||||
|
||||
FixtureThingCreated::dispatch(thingId: 1);
|
||||
|
||||
$this->assertSame([10, 30], $order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Class filter: FQCN listeners thread the payload and receive the filter object as
|
||||
* typed context; the final payload is returned.
|
||||
*/
|
||||
public function test_class_filter_threads_payload_through_fqcn_listeners(): void
|
||||
{
|
||||
$receivedFilter = null;
|
||||
EventDispatcher::add_filter_listener(FixtureThingsFilter::class, function ($things, $filter) use (&$receivedFilter) {
|
||||
$receivedFilter = $filter;
|
||||
$things[] = 'added-by-listener';
|
||||
|
||||
return $things;
|
||||
});
|
||||
|
||||
$result = FixtureThingsFilter::dispatch(things: ['original'], userId: 5);
|
||||
|
||||
$this->assertSame(['original', 'added-by-listener'], $result);
|
||||
$this->assertInstanceOf(FixtureThingsFilter::class, $receivedFilter);
|
||||
$this->assertSame(5, $receivedFilter->userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* BACKWARDS COMPATIBILITY: a filter listener on the historical string name receives
|
||||
* today's ($payload, $availableParams) signature — params include the filter's
|
||||
* public properties plus current_route/currentEvent — and its return value threads
|
||||
* into the final result, after FQCN listeners.
|
||||
*/
|
||||
public function test_class_filter_threads_payload_through_legacy_listeners(): void
|
||||
{
|
||||
$receivedParams = null;
|
||||
|
||||
EventDispatcher::add_filter_listener(FixtureThingsFilter::class, function ($things, $filter) {
|
||||
$things[] = 'fqcn';
|
||||
|
||||
return $things;
|
||||
});
|
||||
|
||||
EventDispatcher::add_filter_listener(
|
||||
'leantime.domain.things.services.things.getThings.filterThings',
|
||||
function ($things, $params) use (&$receivedParams) {
|
||||
$receivedParams = $params;
|
||||
$things[] = 'legacy';
|
||||
|
||||
return $things;
|
||||
}
|
||||
);
|
||||
|
||||
$result = FixtureThingsFilter::dispatch(things: ['original'], userId: 5);
|
||||
|
||||
// FQCN group runs first, then the legacy group threads its output.
|
||||
$this->assertSame(['original', 'fqcn', 'legacy'], $result);
|
||||
$this->assertSame(5, $receivedParams['userId']);
|
||||
$this->assertArrayHasKey('current_route', $receivedParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* A filter with no listeners at all returns the payload unchanged.
|
||||
*/
|
||||
public function test_class_filter_without_listeners_returns_payload_unchanged(): void
|
||||
{
|
||||
$result = FixtureThingsFilter::dispatch(things: ['untouched'], userId: 1);
|
||||
|
||||
$this->assertSame(['untouched'], $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* The instance apply() ergonomic returns the filtered payload too.
|
||||
*/
|
||||
public function test_class_filter_apply_instance_method(): void
|
||||
{
|
||||
EventDispatcher::add_filter_listener(FixtureThingsFilter::class, function ($things) {
|
||||
$things[] = 'applied';
|
||||
|
||||
return $things;
|
||||
});
|
||||
|
||||
$filter = new FixtureThingsFilter(things: ['a'], userId: 2);
|
||||
|
||||
$this->assertSame(['a', 'applied'], $filter->apply());
|
||||
}
|
||||
|
||||
/**
|
||||
* Class events route correctly through Laravel's event() helper / the instance
|
||||
* dispatch() of the Dispatcher interface as well.
|
||||
*/
|
||||
public function test_class_event_routes_through_laravel_event_helper(): void
|
||||
{
|
||||
$received = null;
|
||||
EventDispatcher::add_event_listener(FixtureThingCreated::class, function ($event) use (&$received) {
|
||||
$received = $event;
|
||||
});
|
||||
|
||||
event(new FixtureThingCreated(thingId: 3));
|
||||
|
||||
$this->assertInstanceOf(FixtureThingCreated::class, $received);
|
||||
$this->assertSame(3, $received->thingId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Events;
|
||||
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\Events\EventDispatcher;
|
||||
use Leantime\Core\WorkStructure\Events\StructureRegistered;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Fixture emitter that dispatches through the DispatchesEvents trait exactly like a
|
||||
* domain service does, so the auto-generated event names (lowercased FQCN + method +
|
||||
* raw hook) match the real runtime format.
|
||||
*/
|
||||
class CharacterizationEmitter
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
public function updateThing(): void
|
||||
{
|
||||
self::dispatchEvent('thing_updated', ['thingId' => 7]);
|
||||
}
|
||||
|
||||
public function filterThing(int $payload): mixed
|
||||
{
|
||||
return self::dispatchFilter('thing_filter', $payload, ['mode' => 'strict']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Characterization tests locking the CURRENT EventDispatcher behavior before the
|
||||
* class-based event bridge is added. These tests document the string-event contract
|
||||
* that existing plugins rely on; they must keep passing unchanged.
|
||||
*/
|
||||
class EventDispatcherCharacterizationTest extends TestCase
|
||||
{
|
||||
private array $staticSnapshot = [];
|
||||
|
||||
private const STATIC_PROPS = [
|
||||
'eventRegistry',
|
||||
'filterRegistry',
|
||||
'available_hooks',
|
||||
'patternMatchCache',
|
||||
'compiledPatternCache',
|
||||
'eventRegistryVersion',
|
||||
'filterRegistryVersion',
|
||||
];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$reflection = new \ReflectionClass(EventDispatcher::class);
|
||||
foreach (self::STATIC_PROPS as $prop) {
|
||||
$property = $reflection->getProperty($prop);
|
||||
$this->staticSnapshot[$prop] = $property->getValue();
|
||||
}
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$reflection = new \ReflectionClass(EventDispatcher::class);
|
||||
foreach ($this->staticSnapshot as $prop => $value) {
|
||||
$property = $reflection->getProperty($prop);
|
||||
$property->setValue(null, $value);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* The DispatchesEvents trait builds the full event name as
|
||||
* strtolower(FQCN with \ -> .) + '.' + emitting method + '.' + raw hook.
|
||||
* Plugins subscribe to exactly these strings — the format must not drift.
|
||||
*/
|
||||
public function test_trait_builds_full_event_name_from_class_and_method(): void
|
||||
{
|
||||
(new CharacterizationEmitter)->updateThing();
|
||||
|
||||
$this->assertContains(
|
||||
'unit.app.core.events.characterizationemitter.updateThing.thing_updated',
|
||||
EventDispatcher::get_available_hooks()['events']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A listener registered on the full string name receives a SINGLE array argument:
|
||||
* the dispatched payload merged with current_route and currentEvent.
|
||||
*/
|
||||
public function test_string_event_listener_receives_define_params_array(): void
|
||||
{
|
||||
$received = null;
|
||||
EventDispatcher::add_event_listener(
|
||||
'unit.app.core.events.characterizationemitter.updateThing.thing_updated',
|
||||
function ($params) use (&$received) {
|
||||
$received = $params;
|
||||
}
|
||||
);
|
||||
|
||||
(new CharacterizationEmitter)->updateThing();
|
||||
|
||||
$this->assertIsArray($received);
|
||||
$this->assertSame(7, $received['thingId']);
|
||||
$this->assertSame(
|
||||
'unit.app.core.events.characterizationemitter.updateThing.thing_updated',
|
||||
$received['currentEvent']
|
||||
);
|
||||
$this->assertArrayHasKey('current_route', $received);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter listeners receive ($payload, $availableParams) where availableParams is the
|
||||
* emitter-provided context merged with current_route/currentEvent, and the payload is
|
||||
* threaded through listeners in priority order (lower priority number runs first).
|
||||
*/
|
||||
public function test_filter_threads_payload_in_priority_order_and_passes_params(): void
|
||||
{
|
||||
$fullName = 'unit.app.core.events.characterizationemitter.filterThing.thing_filter';
|
||||
$receivedParams = null;
|
||||
|
||||
EventDispatcher::add_filter_listener($fullName, function ($payload, $params) use (&$receivedParams) {
|
||||
$receivedParams = $params;
|
||||
|
||||
return $payload + 1;
|
||||
}, 20);
|
||||
|
||||
EventDispatcher::add_filter_listener($fullName, function ($payload, $params) {
|
||||
return $payload * 2;
|
||||
}, 10);
|
||||
|
||||
$result = (new CharacterizationEmitter)->filterThing(5);
|
||||
|
||||
// priority 10 runs first: 5 * 2 = 10, then priority 20: 10 + 1 = 11
|
||||
$this->assertSame(11, $result);
|
||||
$this->assertSame('strict', $receivedParams['mode']);
|
||||
$this->assertSame($fullName, $receivedParams['currentEvent']);
|
||||
$this->assertArrayHasKey('current_route', $receivedParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugins rely on wildcard subscriptions (e.g. leantime.domain.*.services.*) matching
|
||||
* the auto-generated full names. The * wildcard must keep matching.
|
||||
*/
|
||||
public function test_wildcard_listener_matches_full_event_name(): void
|
||||
{
|
||||
$called = 0;
|
||||
EventDispatcher::add_event_listener('leantime.domain.*.services.*', function () use (&$called) {
|
||||
$called++;
|
||||
});
|
||||
|
||||
EventDispatcher::dispatch_event('leantime.domain.faux.services.faux.doIt.did_it', ['x' => 1], '');
|
||||
|
||||
$this->assertSame(1, $called);
|
||||
}
|
||||
|
||||
/**
|
||||
* Event listeners for one hook run in priority order, lower number first.
|
||||
*/
|
||||
public function test_event_listeners_run_in_priority_order(): void
|
||||
{
|
||||
$order = [];
|
||||
EventDispatcher::add_event_listener('char.priority.event', function () use (&$order) {
|
||||
$order[] = 30;
|
||||
}, 30);
|
||||
EventDispatcher::add_event_listener('char.priority.event', function () use (&$order) {
|
||||
$order[] = 10;
|
||||
}, 10);
|
||||
EventDispatcher::add_event_listener('char.priority.event', function () use (&$order) {
|
||||
$order[] = 20;
|
||||
}, 20);
|
||||
|
||||
EventDispatcher::dispatch_event('char.priority.event', [], '');
|
||||
|
||||
$this->assertSame([10, 20, 30], $order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Current behavior for plain object events (Laravel Dispatchable path, e.g. the
|
||||
* WorkStructure events): the object resolves to its FQCN as the listener name and a
|
||||
* 'leantime' source listener receives the defineParams array with the object at [0].
|
||||
*/
|
||||
public function test_plain_object_event_fires_fqcn_string_listener(): void
|
||||
{
|
||||
$received = null;
|
||||
EventDispatcher::add_event_listener(StructureRegistered::class, function ($params) use (&$received) {
|
||||
$received = $params;
|
||||
});
|
||||
|
||||
StructureRegistered::dispatch(1, 'My Structure', 'system');
|
||||
|
||||
$this->assertIsArray($received);
|
||||
$this->assertInstanceOf(StructureRegistered::class, $received[0]);
|
||||
$this->assertSame(1, $received[0]->structureId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pattern-match cache is invalidated when a listener is added (version counter),
|
||||
* so listeners registered after a first dispatch still fire on later dispatches.
|
||||
*/
|
||||
public function test_pattern_cache_busts_when_listener_added_after_dispatch(): void
|
||||
{
|
||||
$first = 0;
|
||||
$second = 0;
|
||||
|
||||
EventDispatcher::add_event_listener('char.cache.*', function () use (&$first) {
|
||||
$first++;
|
||||
});
|
||||
EventDispatcher::dispatch_event('char.cache.bust', [], '');
|
||||
|
||||
EventDispatcher::add_event_listener('char.cache.*', function () use (&$second) {
|
||||
$second++;
|
||||
});
|
||||
EventDispatcher::dispatch_event('char.cache.bust', [], '');
|
||||
|
||||
$this->assertSame(2, $first);
|
||||
$this->assertSame(1, $second);
|
||||
}
|
||||
}
|
||||
104
tests/Unit/app/Core/Events/EventVocabularyTest.php
Normal file
104
tests/Unit/app/Core/Events/EventVocabularyTest.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Events;
|
||||
|
||||
use Codeception\Test\Unit;
|
||||
use Leantime\Core\Events\Contracts\LeantimeEvent;
|
||||
use Leantime\Core\Events\Contracts\LeantimeFilter;
|
||||
use Leantime\Core\Events\EventVerb;
|
||||
|
||||
/**
|
||||
* Enforces the shared event vocabulary across all domains:
|
||||
*
|
||||
* - event classes are named {Entity}{Verb} with the verb from the central EventVerb
|
||||
* enum (TicketCreated, MilestoneDeleted — never TicketChanged/TicketEdited)
|
||||
* - filter classes are named {Thing}Filter (TodoWidgetTasksFilter)
|
||||
*
|
||||
* Scans every class in app/Domain/* /Events and app/Core/* /Events that implements
|
||||
* LeantimeEvent or LeantimeFilter. Failing this test means a synonym crept in — use an
|
||||
* existing verb or (rarely) add one to EventVerb.
|
||||
*/
|
||||
class EventVocabularyTest extends Unit
|
||||
{
|
||||
public function test_event_class_names_end_with_central_vocabulary_verb(): void
|
||||
{
|
||||
$discovered = $this->discoverEventClasses();
|
||||
|
||||
// Guard against a vacuous pass: if discovery silently finds nothing (e.g. a
|
||||
// broken base path), the loop below would assert nothing. The pilot ships ten
|
||||
// contract classes in Tickets, so discovery must find them.
|
||||
$this->assertContains(
|
||||
\Leantime\Domain\Tickets\Events\TicketUpdated::class,
|
||||
$discovered,
|
||||
'Event class discovery found nothing — the vocabulary check would pass vacuously.'
|
||||
);
|
||||
|
||||
$violations = [];
|
||||
|
||||
foreach ($discovered as $class) {
|
||||
$implements = class_implements($class);
|
||||
$shortName = substr($class, strrpos($class, '\\') + 1);
|
||||
|
||||
if (in_array(LeantimeFilter::class, $implements, true)) {
|
||||
if (! str_ends_with($shortName, 'Filter')) {
|
||||
$violations[] = "$class — filter classes must be named {Thing}Filter";
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array(LeantimeEvent::class, $implements, true)) {
|
||||
$endsWithVerb = false;
|
||||
foreach (EventVerb::cases() as $verb) {
|
||||
if (str_ends_with($shortName, $verb->name)) {
|
||||
$endsWithVerb = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $endsWithVerb) {
|
||||
$violations[] = "$class — event classes must be named {Entity}{Verb} with a verb from EventVerb";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame([], $violations, "Event vocabulary violations:\n".implode("\n", $violations));
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all classes in Domain and Core Events/ folders that implement one of the
|
||||
* class-based hook contracts.
|
||||
*
|
||||
* @return array<int, class-string>
|
||||
*/
|
||||
private function discoverEventClasses(): array
|
||||
{
|
||||
// Anchor on the canonical app-root constant rather than a brittle relative
|
||||
// dirname() hop, so the scan can't silently miss the Events folders.
|
||||
$appRoot = defined('APP_ROOT') ? APP_ROOT : dirname(__DIR__, 5);
|
||||
|
||||
$files = array_merge(
|
||||
glob($appRoot.'/app/Domain/*/Events/*.php') ?: [],
|
||||
glob($appRoot.'/app/Core/*/Events/*.php') ?: [],
|
||||
);
|
||||
|
||||
$classes = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$relative = str_replace([$appRoot.'/app/', '/', '.php'], ['', '\\', ''], $file);
|
||||
$class = 'Leantime\\'.$relative;
|
||||
|
||||
if (! class_exists($class)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$implements = class_implements($class) ?: [];
|
||||
if (in_array(LeantimeEvent::class, $implements, true)
|
||||
|| in_array(LeantimeFilter::class, $implements, true)) {
|
||||
$classes[] = $class;
|
||||
}
|
||||
}
|
||||
|
||||
return $classes;
|
||||
}
|
||||
}
|
||||
69
tests/Unit/app/Core/Events/EventsTest.php
Normal file
69
tests/Unit/app/Core/Events/EventsTest.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Events;
|
||||
|
||||
use Codeception\Test\Unit;
|
||||
use Leantime\Core\Events\EventDispatcher;
|
||||
|
||||
class EventsTest extends Unit
|
||||
{
|
||||
/**
|
||||
* This test will check the dispatch_event method of the EventDispatcher class.
|
||||
* It will dispatch an event and assert if it is added to the available_hooks array.
|
||||
*/
|
||||
public function test_dispatch_event()
|
||||
{
|
||||
$eventName = 'test.event.name';
|
||||
$payload = ['testKey' => 'testValue'];
|
||||
$context = 'testContext';
|
||||
|
||||
// Dispatch event
|
||||
EventDispatcher::dispatch_event($eventName, $payload, $context);
|
||||
|
||||
// Get all available hooks
|
||||
$available_hooks = EventDispatcher::get_available_hooks();
|
||||
|
||||
// Test that the dispatched event has been registered in available_hooks
|
||||
$this->assertContains("$context.$eventName", $available_hooks['events']);
|
||||
}
|
||||
|
||||
/**
|
||||
* This test will check the findEventListeners method of the EventDispatcher class.
|
||||
*/
|
||||
public function test_find_event_listeners()
|
||||
{
|
||||
$eventName = 'test.event.name';
|
||||
$listenerName = 'test.listener';
|
||||
$payload = ['testKey' => 'testValue'];
|
||||
$context = 'testContext';
|
||||
$eventListeners = [$listenerName => [$payload]];
|
||||
|
||||
EventDispatcher::add_event_listener($listenerName, function () {}, 10);
|
||||
// Test that the event listener has been found
|
||||
$this->assertEquals([$payload], EventDispatcher::findEventListeners($listenerName, $eventListeners));
|
||||
}
|
||||
|
||||
/**
|
||||
* This test will check the get_registries method of the EventDispatcher class.
|
||||
* It will add new event listener and a new filter listener and check both listeners
|
||||
* are in the registry arrays.
|
||||
*/
|
||||
public function test_get_registries()
|
||||
{
|
||||
$eventName = 'event.test.name';
|
||||
$filterName = 'filter.test.name';
|
||||
|
||||
// Add an event listener
|
||||
EventDispatcher::add_event_listener($eventName, function () {}, 10);
|
||||
|
||||
// Add a filter listener
|
||||
EventDispatcher::add_filter_listener($filterName, function () {}, 10);
|
||||
|
||||
// Get registries
|
||||
$registries = EventDispatcher::get_registries();
|
||||
|
||||
// Check registries
|
||||
$this->assertContains($eventName, $registries['events']);
|
||||
$this->assertContains($filterName, $registries['filters']);
|
||||
}
|
||||
}
|
||||
22
tests/Unit/app/Core/ExampleTest.php
Normal file
22
tests/Unit/app/Core/ExampleTest.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Test\Unit;
|
||||
|
||||
class ExampleTest extends \Unit\TestCase
|
||||
{
|
||||
public function test_example(): void
|
||||
{
|
||||
// A simple test to demonstrate the testing process
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_string_operations(): void
|
||||
{
|
||||
// A slightly more complex test
|
||||
$string = 'Hello, Leantime!';
|
||||
$this->assertEquals('Hello, Leantime!', $string);
|
||||
$this->assertStringContainsString('Leantime', $string);
|
||||
$this->assertStringStartsWith('Hello', $string);
|
||||
$this->assertStringEndsWith('!', $string);
|
||||
}
|
||||
}
|
||||
109
tests/Unit/app/Core/Exceptions/LeantimeExceptionTest.php
Normal file
109
tests/Unit/app/Core/Exceptions/LeantimeExceptionTest.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Exceptions;
|
||||
|
||||
use Leantime\Core\Exceptions\AuthException;
|
||||
use Leantime\Core\Exceptions\AuthorizationException;
|
||||
use Leantime\Core\Exceptions\Contracts\LeantimeExceptionInterface;
|
||||
use Leantime\Core\Exceptions\EntityExistsException;
|
||||
use Leantime\Core\Exceptions\InvalidArgumentException;
|
||||
use Leantime\Core\Exceptions\MissingParameterException;
|
||||
use Leantime\Core\Exceptions\NotFoundException;
|
||||
use Leantime\Core\Exceptions\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* The typed exception hierarchy: each carries its own HTTP status (honored by the global
|
||||
* ExceptionHandler because it implements HttpExceptionInterface) and JSON-RPC error code
|
||||
* (read by JsonRpcErrorResponse). Auth + validation are modeled as exceptions per the design.
|
||||
*/
|
||||
class LeantimeExceptionTest extends TestCase
|
||||
{
|
||||
public function test_authorization_exception_carries_403_and_rpc_auth_code(): void
|
||||
{
|
||||
$e = new AuthorizationException;
|
||||
|
||||
$this->assertInstanceOf(LeantimeExceptionInterface::class, $e);
|
||||
$this->assertInstanceOf(HttpExceptionInterface::class, $e);
|
||||
$this->assertSame(403, $e->getStatusCode());
|
||||
$this->assertSame(-32001, $e->getRpcCode());
|
||||
$this->assertSame([], $e->getErrorData());
|
||||
$this->assertNotSame('', $e->getClientMessage());
|
||||
}
|
||||
|
||||
public function test_not_found_exception_carries_404(): void
|
||||
{
|
||||
$e = new NotFoundException;
|
||||
|
||||
$this->assertSame(404, $e->getStatusCode());
|
||||
$this->assertSame(-32002, $e->getRpcCode());
|
||||
}
|
||||
|
||||
public function test_validation_exception_carries_422_field_errors_and_invalid_params_code(): void
|
||||
{
|
||||
$errors = ['headline' => ['The headline is required.']];
|
||||
$e = ValidationException::withMessages($errors);
|
||||
|
||||
$this->assertSame(422, $e->getStatusCode());
|
||||
$this->assertSame(-32602, $e->getRpcCode());
|
||||
$this->assertSame($errors, $e->getErrorData());
|
||||
}
|
||||
|
||||
public function test_validate_bridge_returns_validated_data_on_success(): void
|
||||
{
|
||||
$validated = ValidationException::validate(
|
||||
['name' => 'Acme', 'extra' => 'ignored'],
|
||||
['name' => 'required|string'],
|
||||
);
|
||||
|
||||
// validated() returns only the validated keys.
|
||||
$this->assertSame(['name' => 'Acme'], $validated);
|
||||
}
|
||||
|
||||
public function test_validate_bridge_throws_leantime_type_with_field_errors_on_failure(): void
|
||||
{
|
||||
try {
|
||||
ValidationException::validate(['name' => ''], ['name' => 'required']);
|
||||
$this->fail('Expected a ValidationException to be thrown.');
|
||||
} catch (ValidationException $e) {
|
||||
$this->assertArrayHasKey('name', $e->getErrorData());
|
||||
$this->assertSame(-32602, $e->getRpcCode());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_retrofitted_exceptions_expose_http_status_and_rpc_code(): void
|
||||
{
|
||||
$this->assertSame(409, (new EntityExistsException)->getStatusCode());
|
||||
$this->assertSame(-32005, (new EntityExistsException)->getRpcCode());
|
||||
|
||||
$this->assertSame(422, (new InvalidArgumentException)->getStatusCode());
|
||||
$this->assertSame(-32602, (new InvalidArgumentException)->getRpcCode());
|
||||
|
||||
$missing = new MissingParameterException('x missing');
|
||||
$this->assertSame(422, $missing->getStatusCode());
|
||||
$this->assertSame(-32602, $missing->getRpcCode());
|
||||
$this->assertInstanceOf(LeantimeExceptionInterface::class, $missing);
|
||||
}
|
||||
|
||||
public function test_retrofitted_exception_preserves_legacy_get_code(): void
|
||||
{
|
||||
// BC: the HTTP status historically lived in getCode(); keep it readable there too.
|
||||
$this->assertSame(409, (new EntityExistsException('dupe'))->getCode());
|
||||
}
|
||||
|
||||
public function test_auth_exception_is_a_deprecated_authorization_alias(): void
|
||||
{
|
||||
$e = new AuthException('Invalid domain user');
|
||||
|
||||
// It IS an AuthorizationException (a deprecated alias, not a second auth exception),
|
||||
// so it keeps the 403 status + -32001 rpc code while the AdvancedAuth plugin and
|
||||
// external installs that still throw the old class name keep working.
|
||||
$this->assertInstanceOf(AuthorizationException::class, $e);
|
||||
$this->assertSame(403, $e->getStatusCode());
|
||||
$this->assertSame(-32001, $e->getRpcCode());
|
||||
// Legacy ($message, $code) signature preserved, including getCode().
|
||||
$this->assertSame('Invalid domain user', $e->getMessage());
|
||||
$this->assertSame(403, $e->getCode());
|
||||
}
|
||||
}
|
||||
408
tests/Unit/app/Core/Files/FileManagerTest.php
Normal file
408
tests/Unit/app/Core/Files/FileManagerTest.php
Normal file
@@ -0,0 +1,408 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\app\Core\Files;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Filesystem\FilesystemAdapter;
|
||||
use Illuminate\Filesystem\FilesystemManager;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Files\Exceptions\FileValidationException;
|
||||
use Leantime\Core\Files\FileManager;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\Support\CarbonMacros;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Unit\TestCase;
|
||||
|
||||
class FileManagerTest extends TestCase
|
||||
{
|
||||
private $filesystemManager;
|
||||
|
||||
private $config;
|
||||
|
||||
private $fileManager;
|
||||
|
||||
private $storage;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Set up session values needed for DateTimeHelper (used by dtHelper())
|
||||
session(['usersettings.timezone' => 'UTC']);
|
||||
session(['usersettings.language' => 'en-US']);
|
||||
session(['usersettings.date_format' => 'Y-m-d']);
|
||||
session(['usersettings.time_format' => 'H:i']);
|
||||
|
||||
// Mock Environment and bind to container for dtHelper()
|
||||
$envMock = $this->createMock(Environment::class);
|
||||
$envMock->defaultTimezone = 'UTC';
|
||||
$envMock->language = 'en-US';
|
||||
app()->instance(Environment::class, $envMock);
|
||||
|
||||
// Mock Language and bind to container
|
||||
$langMock = $this->createMock(Language::class);
|
||||
$langMock->method('__')->willReturnCallback(function ($index) {
|
||||
$map = [
|
||||
'language.dateformat' => 'Y-m-d',
|
||||
'language.timeformat' => 'H:i',
|
||||
];
|
||||
|
||||
return $map[$index] ?? $index;
|
||||
});
|
||||
app()->instance(Language::class, $langMock);
|
||||
|
||||
// Register CarbonMacros for date parsing
|
||||
CarbonImmutable::mixin(new CarbonMacros('UTC', 'en-US', 'Y-m-d', 'H:i'));
|
||||
|
||||
// Mock the FilesystemManager
|
||||
$this->filesystemManager = $this->createMock(FilesystemManager::class);
|
||||
|
||||
// Mock the Environment
|
||||
$this->config = $this->createMock(Environment::class);
|
||||
|
||||
// Mock the storage disk
|
||||
$this->storage = $this->createMock(FilesystemAdapter::class);
|
||||
|
||||
// Setup the FileManager with mocked dependencies
|
||||
$this->fileManager = new FileManager(
|
||||
$this->filesystemManager,
|
||||
$this->config
|
||||
);
|
||||
|
||||
// Create a test file in userfiles directory
|
||||
$testDir = base_path('userfiles/test');
|
||||
if (! is_dir($testDir)) {
|
||||
mkdir($testDir, 0777, true);
|
||||
}
|
||||
file_put_contents($testDir.'/test.txt', 'test content');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// Clean up test file
|
||||
@unlink(base_path('userfiles/test/test.txt'));
|
||||
@rmdir(base_path('userfiles/test'));
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_upload_file_successfully()
|
||||
{
|
||||
// Mock session data
|
||||
session(['userdata.id' => 123]);
|
||||
|
||||
// Create a mock uploaded file
|
||||
$file = $this->createMock(UploadedFile::class);
|
||||
$file->method('isValid')->willReturn(true);
|
||||
$file->method('getError')->willReturn(0);
|
||||
$file->method('getSize')->willReturn(1000); // 1KB
|
||||
$file->method('getClientOriginalName')->willReturn('test-file.txt');
|
||||
$file->method('getClientOriginalExtension')->willReturn('txt');
|
||||
$file->method('getRealPath')->willReturn(base_path('userfiles/test/test.txt'));
|
||||
|
||||
// Setup filesystem manager to return our mocked storage
|
||||
$this->filesystemManager->method('getDefaultDriver')->willReturn('local');
|
||||
$this->filesystemManager->method('disk')->with('local')->willReturn($this->storage);
|
||||
$this->storage->method('mimeType')->willReturn('text/plain');
|
||||
|
||||
// Setup storage to successfully store the file
|
||||
$this->storage->method('put')->willReturn(true);
|
||||
|
||||
// Mock the PHP stream functions
|
||||
$this->storage->method('put')
|
||||
->with($this->anything(), $this->anything(), $this->anything())
|
||||
->willReturn(true);
|
||||
|
||||
// Execute the method under test
|
||||
$result = $this->fileManager->upload($file);
|
||||
|
||||
// Assert the result is an array with expected keys
|
||||
$this->assertIsArray($result);
|
||||
$this->assertArrayHasKey('fileName', $result);
|
||||
$this->assertArrayHasKey('realName', $result);
|
||||
$this->assertArrayHasKey('extension', $result);
|
||||
$this->assertEquals('test-file.txt', $result['realName']);
|
||||
$this->assertEquals('txt', $result['extension']);
|
||||
}
|
||||
|
||||
public function test_upload_file_with_invalid_file()
|
||||
{
|
||||
// Create a mock uploaded file that is invalid
|
||||
$file = $this->createMock(UploadedFile::class);
|
||||
$file->method('isValid')->willReturn(false);
|
||||
$file->method('getErrorMessage')->willReturn('Test error message');
|
||||
|
||||
// // Mock the Log facade
|
||||
// Log::shouldReceive('error')
|
||||
// ->once()
|
||||
// ->with('File upload failed: Invalid file upload attempt: Test error message', ['exception' => new FileValidationException('test'), 'file'=> '']);
|
||||
|
||||
// Execute the method under test
|
||||
$result = $this->fileManager->upload($file);
|
||||
|
||||
// Assert the result is false
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function test_upload_file_with_file_too_large()
|
||||
{
|
||||
// Create a mock uploaded file that is too large
|
||||
$file = $this->createMock(UploadedFile::class);
|
||||
$file->method('isValid')->willReturn(true);
|
||||
$file->method('getError')->willReturn(0);
|
||||
$file->method('getSize')->willReturn(PHP_INT_MAX); // Very large file
|
||||
|
||||
// // Mock the Log facade
|
||||
// Log::shouldReceive('error')
|
||||
// ->once()
|
||||
// ->with('File upload failed: File size exceeds the maximum allowed size of', ['exception' => new FileValidationException('test'), 'file'=> '']);
|
||||
|
||||
// Execute the method under test
|
||||
$result = $this->fileManager->upload($file);
|
||||
|
||||
// Assert the result is false
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function test_get_file_successfully()
|
||||
{
|
||||
// Setup filesystem manager to return our mocked storage
|
||||
$this->filesystemManager->method('getDefaultDriver')->willReturn('local');
|
||||
$this->filesystemManager->method('disk')->with('local')->willReturn($this->storage);
|
||||
|
||||
// Setup storage to successfully find and read the file
|
||||
$this->storage->expects($this->once())->method('exists')->willReturn(true);
|
||||
$this->storage->method('mimeType')->willReturn('text/plain');
|
||||
$this->storage->method('get')->willReturn('file content');
|
||||
$this->storage->method('size')->willReturn(12);
|
||||
$this->storage->method('lastModified')->willReturn(1700000000);
|
||||
|
||||
// Execute the method under test
|
||||
$result = $this->fileManager->getFile('test.txt', 'original-name.txt');
|
||||
|
||||
// Assert the result is a Response with correct headers
|
||||
$this->assertInstanceOf(Response::class, $result);
|
||||
$this->assertEquals('file content', $result->getContent());
|
||||
$this->assertEquals('text/plain', $result->headers->get('Content-Type'));
|
||||
$this->assertEquals('12', $result->headers->get('Content-Length'));
|
||||
$this->assertStringContainsString('original-name.txt', $result->headers->get('Content-Disposition'));
|
||||
}
|
||||
|
||||
public function test_get_file_not_found()
|
||||
{
|
||||
// Setup filesystem manager to return our mocked storage
|
||||
$this->filesystemManager->method('getDefaultDriver')->willReturn('local');
|
||||
$this->filesystemManager->method('disk')->with('local')->willReturn($this->storage);
|
||||
|
||||
// Setup storage to not find the file
|
||||
$this->storage->method('exists')->willReturn(false);
|
||||
|
||||
// Execute the method under test
|
||||
$result = $this->fileManager->getFile('non-existent-file.txt', 'original-name.txt');
|
||||
|
||||
// Assert the result is false
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function test_get_file_url_local_storage()
|
||||
{
|
||||
// Setup filesystem manager to return our mocked storage
|
||||
$this->filesystemManager->method('getDefaultDriver')->willReturn('local');
|
||||
$this->filesystemManager->method('disk')->with('local')->willReturn($this->storage);
|
||||
$this->storage->method('mimeType')->willReturn('text/plain');
|
||||
|
||||
// Setup storage to successfully find the file and return a URL
|
||||
$this->storage->method('exists')->willReturn(true);
|
||||
$this->storage->method('url')->willReturn('http://example.com/files/test.txt');
|
||||
|
||||
// Configure cache behavior
|
||||
$this->config->method('get')->willReturn(true);
|
||||
Cache::shouldReceive('remember')
|
||||
->once()
|
||||
->andReturn('http://example.com/files/test.txt');
|
||||
|
||||
// Execute the method under test
|
||||
$result = $this->fileManager->getFileUrl('test.txt');
|
||||
|
||||
// Assert the result is the expected URL
|
||||
$this->assertEquals('http://example.com/files/test.txt', $result);
|
||||
}
|
||||
|
||||
public function test_get_file_url_s3_storage()
|
||||
{
|
||||
// Configure environment to use S3
|
||||
$this->config->useS3 = true;
|
||||
$this->config->method('get')->willReturn(60);
|
||||
|
||||
// Setup storage to return mime type
|
||||
$this->storage->method('mimeType')->willReturn('text/plain');
|
||||
// Setup filesystem manager to return our mocked storage
|
||||
$this->filesystemManager->method('disk')->with('s3')->willReturn($this->storage);
|
||||
|
||||
// Setup storage to successfully find the file and return a temporary URL
|
||||
$this->storage->method('exists')->willReturn(true);
|
||||
$this->storage->method('temporaryUrl')->willReturn('https://s3.example.com/files/test.txt?signature=abc123');
|
||||
$this->storage->method('temporaryUrl')->willReturn('https://s3.example.com/files/test.txt?signature=abc123');
|
||||
$this->storage->method('temporaryUrl')->willReturn('https://s3.example.com/files/test.txt?signature=abc123');
|
||||
|
||||
// Execute the method under test
|
||||
$result = $this->fileManager->getFileUrl('test.txt', 's3');
|
||||
|
||||
// Assert the result is the expected URL
|
||||
$this->assertEquals('https://s3.example.com/files/test.txt?signature=abc123', $result);
|
||||
}
|
||||
|
||||
public function test_get_file_url_file_not_found()
|
||||
{
|
||||
// Setup filesystem manager to return our mocked storage
|
||||
$this->filesystemManager->method('getDefaultDriver')->willReturn('local');
|
||||
$this->filesystemManager->method('disk')->with('local')->willReturn($this->storage);
|
||||
|
||||
// Setup storage to not find the file
|
||||
$this->storage->method('exists')->willReturn(false);
|
||||
|
||||
// Execute the method under test
|
||||
$result = $this->fileManager->getFileUrl('non-existent-file.txt');
|
||||
|
||||
// Assert the result is false
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function test_delete_file_successfully()
|
||||
{
|
||||
// Setup filesystem manager to return our mocked storage
|
||||
$this->filesystemManager->method('getDefaultDriver')->willReturn('local');
|
||||
$this->filesystemManager->method('disk')->with('local')->willReturn($this->storage);
|
||||
|
||||
// Setup storage to successfully find and delete the file
|
||||
$this->storage->method('exists')->willReturn(true);
|
||||
$this->storage->method('delete')->willReturn(true);
|
||||
|
||||
// Execute the method under test
|
||||
$result = $this->fileManager->deleteFile('test.txt');
|
||||
|
||||
// Assert the result is true
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
public function test_delete_file_not_found()
|
||||
{
|
||||
// Setup filesystem manager to return our mocked storage
|
||||
$this->filesystemManager->method('getDefaultDriver')->willReturn('local');
|
||||
$this->filesystemManager->method('disk')->with('local')->willReturn($this->storage);
|
||||
|
||||
// Setup storage to not find the file
|
||||
$this->storage->method('exists')->willReturn(false);
|
||||
|
||||
// Mock the Log facade
|
||||
Log::shouldReceive('info')
|
||||
->once()
|
||||
->with('File not found for deletion: test.txt on disk local');
|
||||
|
||||
// Execute the method under test
|
||||
$result = $this->fileManager->deleteFile('test.txt');
|
||||
|
||||
// Assert the result is false
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function test_delete_file_with_empty_filename()
|
||||
{
|
||||
// Mock the Log facade
|
||||
Log::shouldReceive('warning')
|
||||
->once()
|
||||
->with('Attempted to delete a file with empty filename');
|
||||
|
||||
// Execute the method under test
|
||||
$result = $this->fileManager->deleteFile('');
|
||||
|
||||
// Assert the result is false
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function test_get_maximum_file_upload_size()
|
||||
{
|
||||
// Test the static method
|
||||
$result = FileManager::getMaximumFileUploadSize();
|
||||
|
||||
// Assert the result is an integer
|
||||
$this->assertIsInt($result);
|
||||
|
||||
// The result should be the minimum of post_max_size and upload_max_filesize
|
||||
$expected = min(
|
||||
$this->convertPHPSizeToBytes(ini_get('post_max_size')),
|
||||
$this->convertPHPSizeToBytes(ini_get('upload_max_filesize'))
|
||||
);
|
||||
$this->assertEquals($expected, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to convert PHP size strings to bytes
|
||||
*/
|
||||
private function convertPHPSizeToBytes(string $sSize): int
|
||||
{
|
||||
$sSuffix = strtoupper(substr($sSize, -1));
|
||||
if (! in_array($sSuffix, ['P', 'T', 'G', 'M', 'K'])) {
|
||||
return (int) $sSize;
|
||||
}
|
||||
$iValue = substr($sSize, 0, -1);
|
||||
switch ($sSuffix) {
|
||||
case 'P':
|
||||
$iValue *= 1024;
|
||||
// Fallthrough intended
|
||||
case 'T':
|
||||
$iValue *= 1024;
|
||||
// Fallthrough intended
|
||||
case 'G':
|
||||
$iValue *= 1024;
|
||||
// Fallthrough intended
|
||||
case 'M':
|
||||
$iValue *= 1024;
|
||||
// Fallthrough intended
|
||||
case 'K':
|
||||
$iValue *= 1024;
|
||||
break;
|
||||
}
|
||||
|
||||
return (int) $iValue;
|
||||
}
|
||||
|
||||
public function test_sanitize_filename()
|
||||
{
|
||||
// Use reflection to test private method
|
||||
$reflection = new \ReflectionClass(FileManager::class);
|
||||
$method = $reflection->getMethod('sanitizeFilename');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Test with a normal filename
|
||||
$result = $method->invoke($this->fileManager, 'test.txt');
|
||||
$this->assertEquals('test.txt', $result);
|
||||
|
||||
// Test with a path
|
||||
$result = $method->invoke($this->fileManager, '/path/to/test.txt');
|
||||
$this->assertEquals('test.txt', $result);
|
||||
|
||||
// Test with special characters
|
||||
$result = $method->invoke($this->fileManager, 'test@file#$.txt');
|
||||
$this->assertEquals('test-file--.txt', $result);
|
||||
|
||||
// Allow chinese characters
|
||||
$result = $method->invoke($this->fileManager, '测试文件.txt');
|
||||
$this->assertEquals('测试文件.txt', $result);
|
||||
}
|
||||
|
||||
public function test_get_avatar_with_cache_hit()
|
||||
{
|
||||
// We already have a test file at userfiles/test/test.txt
|
||||
$testFile = base_path('userfiles/test/test.txt');
|
||||
|
||||
// Verify the test file exists
|
||||
$this->assertFileExists($testFile);
|
||||
|
||||
// Verify content
|
||||
$this->assertEquals('test content', file_get_contents($testFile));
|
||||
}
|
||||
}
|
||||
45
tests/Unit/app/Core/Http/Responses/ImageResponseTest.php
Normal file
45
tests/Unit/app/Core/Http/Responses/ImageResponseTest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Http\Responses;
|
||||
|
||||
use Leantime\Core\Http\Responses\Contracts\LeantimeResponseInterface;
|
||||
use Leantime\Core\Http\Responses\ImageResponse;
|
||||
use SVG\SVG;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the ImageResponse response type used by the domain image controllers
|
||||
* (Users\Controllers\ProfileImage, Projects\Controllers\ProjectImage). It is returned
|
||||
* directly from controllers and converted by Laravel's router via the Responsable contract.
|
||||
*/
|
||||
class ImageResponseTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
public function test_it_is_a_leantime_response(): void
|
||||
{
|
||||
$this->assertInstanceOf(LeantimeResponseInterface::class, new ImageResponse('/tmp/x'));
|
||||
}
|
||||
|
||||
public function test_to_response_renders_svg_with_cache_headers(): void
|
||||
{
|
||||
$svg = $this->make(SVG::class, [
|
||||
'toXMLString' => fn () => '<svg></svg>',
|
||||
]);
|
||||
|
||||
$response = (new ImageResponse($svg))->toResponse(null);
|
||||
|
||||
$this->assertSame('<svg></svg>', $response->getContent());
|
||||
$this->assertSame('image/svg+xml', $response->headers->get('Content-type'));
|
||||
$this->assertStringContainsString('max-age=86400', $response->headers->get('Cache-Control'));
|
||||
}
|
||||
|
||||
public function test_to_response_passes_through_an_existing_response(): void
|
||||
{
|
||||
$existing = new Response('already built');
|
||||
|
||||
// An uploaded file is already a built Response; it must be returned untouched.
|
||||
$this->assertSame($existing, (new ImageResponse($existing))->toResponse(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Http\Responses;
|
||||
|
||||
use Leantime\Core\Exceptions\AuthorizationException;
|
||||
use Leantime\Core\Exceptions\ValidationException;
|
||||
use Leantime\Core\Http\Responses\Contracts\LeantimeResponseInterface;
|
||||
use Leantime\Core\Http\Responses\JsonRpcErrorResponse;
|
||||
use RuntimeException;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* The JSON-RPC 2.0 error envelope response type and its fromException() bridge — the single
|
||||
* place a thrown exception becomes a client-facing error. Typed Leantime exceptions expose
|
||||
* their own code/message/data; any other throwable is collapsed to a generic server error so
|
||||
* internal detail is never leaked.
|
||||
*/
|
||||
class JsonRpcErrorResponseTest extends TestCase
|
||||
{
|
||||
public function test_it_is_a_leantime_response(): void
|
||||
{
|
||||
$this->assertInstanceOf(LeantimeResponseInterface::class, new JsonRpcErrorResponse(-32000, 'x'));
|
||||
}
|
||||
|
||||
public function test_error_envelope(): void
|
||||
{
|
||||
$response = (new JsonRpcErrorResponse(-32602, 'Invalid params', ['field' => ['bad']], 3))->toResponse(null);
|
||||
$body = json_decode($response->getContent(), true);
|
||||
|
||||
$this->assertSame('2.0', $body['jsonrpc']);
|
||||
$this->assertSame(-32602, $body['error']['code']);
|
||||
$this->assertSame('Invalid params', $body['error']['message']);
|
||||
$this->assertSame(['field' => ['bad']], $body['error']['data']);
|
||||
$this->assertSame(3, $body['id']);
|
||||
}
|
||||
|
||||
public function test_from_validation_exception_maps_code_and_field_errors(): void
|
||||
{
|
||||
$errors = ['name' => ['Name is required.']];
|
||||
|
||||
$response = JsonRpcErrorResponse::fromException(ValidationException::withMessages($errors), 5)->toResponse(null);
|
||||
$body = json_decode($response->getContent(), true);
|
||||
|
||||
$this->assertSame(-32602, $body['error']['code']);
|
||||
$this->assertSame($errors, $body['error']['data']);
|
||||
$this->assertSame(5, $body['id']);
|
||||
}
|
||||
|
||||
public function test_from_authorization_exception_uses_auth_code(): void
|
||||
{
|
||||
$response = JsonRpcErrorResponse::fromException(new AuthorizationException, 1)->toResponse(null);
|
||||
$body = json_decode($response->getContent(), true);
|
||||
|
||||
$this->assertSame(-32001, $body['error']['code']);
|
||||
}
|
||||
|
||||
public function test_unknown_throwable_is_generic_and_does_not_leak(): void
|
||||
{
|
||||
$secret = 'internal-db-dsn-with-password';
|
||||
|
||||
$response = JsonRpcErrorResponse::fromException(new RuntimeException($secret), 9)->toResponse(null);
|
||||
$body = json_decode($response->getContent(), true);
|
||||
|
||||
$this->assertSame(-32000, $body['error']['code']);
|
||||
$this->assertSame('Server error', $body['error']['message']);
|
||||
$this->assertNull($body['error']['data']);
|
||||
$this->assertStringNotContainsString($secret, $response->getContent());
|
||||
}
|
||||
}
|
||||
47
tests/Unit/app/Core/Http/Responses/JsonRpcResponseTest.php
Normal file
47
tests/Unit/app/Core/Http/Responses/JsonRpcResponseTest.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Http\Responses;
|
||||
|
||||
use Leantime\Core\Http\Responses\Contracts\LeantimeResponseInterface;
|
||||
use Leantime\Core\Http\Responses\JsonRpcResponse;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* The JSON-RPC 2.0 success envelope response type. Centralizes the `{jsonrpc, result, id}`
|
||||
* wire format previously inlined in the Jsonrpc controller.
|
||||
*/
|
||||
class JsonRpcResponseTest extends TestCase
|
||||
{
|
||||
public function test_it_is_a_leantime_response(): void
|
||||
{
|
||||
$this->assertInstanceOf(LeantimeResponseInterface::class, new JsonRpcResponse('x', 1));
|
||||
}
|
||||
|
||||
public function test_success_envelope(): void
|
||||
{
|
||||
$response = (new JsonRpcResponse(['ok' => true], 7))->toResponse(null);
|
||||
$body = json_decode($response->getContent(), true);
|
||||
|
||||
$this->assertSame('2.0', $body['jsonrpc']);
|
||||
$this->assertSame(['ok' => true], $body['result']);
|
||||
$this->assertSame(7, $body['id']);
|
||||
}
|
||||
|
||||
public function test_scalar_result_and_string_id_pass_through(): void
|
||||
{
|
||||
$response = (new JsonRpcResponse(42, 'abc'))->toResponse(null);
|
||||
$body = json_decode($response->getContent(), true);
|
||||
|
||||
$this->assertSame(42, $body['result']);
|
||||
$this->assertSame('abc', $body['id']);
|
||||
}
|
||||
|
||||
public function test_notification_without_id_returns_empty_200(): void
|
||||
{
|
||||
// A JSON-RPC notification (no id) MUST NOT be responded to.
|
||||
$response = (new JsonRpcResponse('ignored', null))->toResponse(null);
|
||||
|
||||
$this->assertSame(200, $response->getStatusCode());
|
||||
$this->assertSame('', $response->getContent());
|
||||
}
|
||||
}
|
||||
118
tests/Unit/app/Core/Middleware/AuthCheckTest.php
Normal file
118
tests/Unit/app/Core/Middleware/AuthCheckTest.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Middleware;
|
||||
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Leantime\Core\Middleware\AuthCheck;
|
||||
use Leantime\Domain\Api\Services\Api;
|
||||
use Leantime\Domain\Users\Services\Users;
|
||||
|
||||
/**
|
||||
* Guards the Bearer-auth regression (3.9.0): the permission engine reads the user's id + role from
|
||||
* session('userdata'), which the x-api-key guard establishes as a side effect of getAPIKeyUser()
|
||||
* but the Sanctum (Bearer) guard never did — so every gated @api method denied Bearer requests.
|
||||
* establishApiUserSession() makes the API auth path uniform: any guard that resolves a user has the
|
||||
* same userdata built from the canonical user row, through the same setApiUserSession() builder.
|
||||
*
|
||||
* This tests the middleware's responsibility — resolve the user id, fetch the canonical row, and
|
||||
* hand it to the session builder, idempotently. The builder itself is covered by ApiServiceTest.
|
||||
*/
|
||||
class AuthCheckTest extends \Unit\TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/**
|
||||
* A request whose user() resolver returns an object with the given id — i.e. a guard (Sanctum
|
||||
* or x-api-key) has authenticated, but userdata has not been established yet.
|
||||
*/
|
||||
private function apiRequestForUser(int $userId): IncomingRequest
|
||||
{
|
||||
$request = IncomingRequest::create('/api/jsonrpc', 'POST');
|
||||
$request->setUserResolver(fn () => (object) ['id' => $userId]);
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
/** Invoke the protected establishApiUserSession() on a constructor-less AuthCheck. */
|
||||
private function establish(IncomingRequest $request): void
|
||||
{
|
||||
$authCheck = $this->make(AuthCheck::class);
|
||||
(fn () => $this->establishApiUserSession($request))->call($authCheck);
|
||||
}
|
||||
|
||||
public function test_establishes_userdata_from_the_canonical_row_when_missing(): void
|
||||
{
|
||||
session()->forget('userdata');
|
||||
|
||||
$row = ['id' => 42, 'firstname' => 'Gloria', 'role' => 20];
|
||||
|
||||
app()->instance(Users::class, $this->make(Users::class, [
|
||||
'getUser' => fn ($id = null) => (int) $id === 42 ? $row : false,
|
||||
]));
|
||||
|
||||
$captured = null;
|
||||
app()->instance(Api::class, $this->make(Api::class, [
|
||||
'setApiUserSession' => function (array $user, bool $isExternalAuth = false) use (&$captured) {
|
||||
$captured = ['user' => $user, 'external' => $isExternalAuth];
|
||||
},
|
||||
]));
|
||||
|
||||
$this->establish($this->apiRequestForUser(42));
|
||||
|
||||
$this->assertSame($row, $captured['user'] ?? null, 'the canonical row must be handed to the session builder');
|
||||
$this->assertTrue($captured['external'] ?? false, 'API sessions are external auth');
|
||||
}
|
||||
|
||||
public function test_is_idempotent_when_userdata_already_exists(): void
|
||||
{
|
||||
// x-api-key (and stateful web) already populated userdata before this runs — leave it,
|
||||
// and never re-resolve the user.
|
||||
session(['userdata' => ['id' => 7, 'role' => 'admin']]);
|
||||
|
||||
app()->instance(Users::class, $this->make(Users::class, [
|
||||
'getUser' => function ($id = null) {
|
||||
$this->fail('must not re-resolve the user when userdata already exists');
|
||||
},
|
||||
]));
|
||||
|
||||
$called = false;
|
||||
app()->instance(Api::class, $this->make(Api::class, [
|
||||
'setApiUserSession' => function (array $user, bool $isExternalAuth = false) use (&$called) {
|
||||
$called = true;
|
||||
},
|
||||
]));
|
||||
|
||||
$this->establish($this->apiRequestForUser(42));
|
||||
|
||||
$this->assertFalse($called, 'must not rebuild an already-established session');
|
||||
$this->assertSame(7, session('userdata.id'), 'existing userdata must be left untouched');
|
||||
}
|
||||
|
||||
/**
|
||||
* The mobile SSO exchange (/oidc/mobile/exchange) arrives with no session
|
||||
* cookie — the validated one-time code + PKCE verifier are the authorization —
|
||||
* so it must be allow-listed as public. Guards that allow-list from regressing.
|
||||
*/
|
||||
public function test_oidc_mobile_exchange_is_a_public_route(): void
|
||||
{
|
||||
$authCheck = $this->make(AuthCheck::class);
|
||||
|
||||
$this->assertTrue(
|
||||
$authCheck->isPublicController('oidc.mobile.exchange'),
|
||||
'the mobile exchange endpoint must be public (no session at exchange time)'
|
||||
);
|
||||
|
||||
// Negative control: an oidc sub-route that is NOT allow-listed stays private.
|
||||
$this->assertFalse($authCheck->isPublicController('oidc.settings.save'));
|
||||
}
|
||||
|
||||
public function test_status_discovery_is_a_public_route(): void
|
||||
{
|
||||
$authCheck = $this->make(AuthCheck::class);
|
||||
|
||||
// The mobile app hits /status unauthenticated at connect time to discover
|
||||
// login methods, so the route must be public.
|
||||
$this->assertTrue($authCheck->isPublicController('status.index'));
|
||||
$this->assertTrue($authCheck->isPublicController('status'));
|
||||
}
|
||||
}
|
||||
137
tests/Unit/app/Core/Middleware/SessionMergeTest.php
Normal file
137
tests/Unit/app/Core/Middleware/SessionMergeTest.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Middleware;
|
||||
|
||||
use Illuminate\Session\ArraySessionHandler;
|
||||
use Illuminate\Session\Store;
|
||||
use Leantime\Core\Middleware\StartSession;
|
||||
use ReflectionMethod;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Regression coverage for the optimistic session-concurrency strategy in
|
||||
* StartSession. The original blanket-locking existed because a no-lock version
|
||||
* lost session writes: two concurrent requests would each overwrite the whole
|
||||
* session blob, clobbering each other (e.g. a project switch reverted by a
|
||||
* background widget). The merge-on-write strategy must persist ONLY the keys a
|
||||
* request actually changed, re-reading the freshest state first, so a concurrent
|
||||
* writer's keys survive.
|
||||
*/
|
||||
class SessionMergeTest extends TestCase
|
||||
{
|
||||
private function middleware(): StartSession
|
||||
{
|
||||
return new StartSession(app('session'));
|
||||
}
|
||||
|
||||
private function invokeDiff(array $initial, array $current): array
|
||||
{
|
||||
$method = new ReflectionMethod(StartSession::class, 'diffSession');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return $method->invoke($this->middleware(), $initial, $current);
|
||||
}
|
||||
|
||||
private function invokeMerge(Store $session, array $changed, array $removed): void
|
||||
{
|
||||
$method = new ReflectionMethod(StartSession::class, 'mergeSessionChanges');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($this->middleware(), $session, $changed, $removed);
|
||||
}
|
||||
|
||||
public function test_diff_detects_added_changed_and_removed_keys(): void
|
||||
{
|
||||
[$changed, $removed] = $this->invokeDiff(
|
||||
['currentProject' => 1, 'keep' => 'same', 'goingAway' => 'x'],
|
||||
['currentProject' => 2, 'keep' => 'same', 'brandNew' => 'y'],
|
||||
);
|
||||
|
||||
$this->assertSame(['currentProject' => 2, 'brandNew' => 'y'], $changed);
|
||||
$this->assertSame(['goingAway'], $removed);
|
||||
}
|
||||
|
||||
public function test_pure_read_produces_no_diff(): void
|
||||
{
|
||||
[$changed, $removed] = $this->invokeDiff(
|
||||
['currentProject' => 1, 'nested' => ['a' => 1]],
|
||||
['currentProject' => 1, 'nested' => ['a' => 1]],
|
||||
);
|
||||
|
||||
$this->assertSame([], $changed);
|
||||
$this->assertSame([], $removed);
|
||||
}
|
||||
|
||||
/**
|
||||
* The core race: request B loads the session, request A switches the project
|
||||
* and commits first, then B persists. B only changed `lastPage`, so the merge
|
||||
* must keep A's `currentProject = 2` rather than reverting it to the value B
|
||||
* originally loaded.
|
||||
*/
|
||||
public function test_merge_preserves_a_concurrent_writers_key(): void
|
||||
{
|
||||
$handler = new ArraySessionHandler(120);
|
||||
$name = 'leantime_session';
|
||||
// Store::setId() rejects ids that aren't 40-char alphanumeric and
|
||||
// generates a random one instead, so the id must be a valid session id
|
||||
// for the three stores to share state through the handler.
|
||||
$id = str_repeat('a', 40);
|
||||
|
||||
// Seed the persisted session.
|
||||
$seed = new Store($name, $handler, $id);
|
||||
$seed->start();
|
||||
$seed->put('currentProject', 1);
|
||||
$seed->put('userdata.id', 99);
|
||||
$seed->save();
|
||||
|
||||
// Request B starts and loads the current state.
|
||||
$requestB = new Store($name, $handler, $id);
|
||||
$requestB->start();
|
||||
$bInitial = $requestB->all();
|
||||
$requestB->put('lastPage', '/dashboard/home'); // B's only change
|
||||
|
||||
// Request A switches the project and commits BEFORE B persists.
|
||||
$requestA = new Store($name, $handler, $id);
|
||||
$requestA->start();
|
||||
$requestA->put('currentProject', 2);
|
||||
$requestA->save();
|
||||
|
||||
// B persists via the merge strategy (diff of B's change against B's snapshot).
|
||||
[$changed, $removed] = $this->invokeDiff($bInitial, $requestB->all());
|
||||
$this->invokeMerge($requestB, $changed, $removed);
|
||||
|
||||
// Read the final persisted state.
|
||||
$verify = new Store($name, $handler, $id);
|
||||
$verify->start();
|
||||
|
||||
$this->assertSame(2, $verify->get('currentProject'), 'concurrent project switch was clobbered');
|
||||
$this->assertSame('/dashboard/home', $verify->get('lastPage'), 'B\'s own write was lost');
|
||||
$this->assertSame(99, $verify->get('userdata.id'), 'untouched key was dropped');
|
||||
}
|
||||
|
||||
public function test_merge_applies_removed_keys(): void
|
||||
{
|
||||
$handler = new ArraySessionHandler(120);
|
||||
$name = 'leantime_session';
|
||||
$id = str_repeat('b', 40);
|
||||
|
||||
$seed = new Store($name, $handler, $id);
|
||||
$seed->start();
|
||||
$seed->put('currentIdeaCanvas', 5);
|
||||
$seed->put('currentProject', 3);
|
||||
$seed->save();
|
||||
|
||||
$request = new Store($name, $handler, $id);
|
||||
$request->start();
|
||||
$initial = $request->all();
|
||||
$request->forget('currentIdeaCanvas');
|
||||
|
||||
[$changed, $removed] = $this->invokeDiff($initial, $request->all());
|
||||
$this->invokeMerge($request, $changed, $removed);
|
||||
|
||||
$verify = new Store($name, $handler, $id);
|
||||
$verify->start();
|
||||
|
||||
$this->assertFalse($verify->has('currentIdeaCanvas'), 'removed key should not be persisted');
|
||||
$this->assertSame(3, $verify->get('currentProject'));
|
||||
}
|
||||
}
|
||||
117
tests/Unit/app/Core/Middleware/UpdatedTest.php
Normal file
117
tests/Unit/app/Core/Middleware/UpdatedTest.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Middleware;
|
||||
|
||||
use Leantime\Core\Configuration\AppSettings;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Leantime\Core\Middleware\Updated;
|
||||
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
|
||||
|
||||
/**
|
||||
* Guards the stale-session redirect loop: the Updated middleware caches the
|
||||
* db-version in the session and (before the fix) never re-read the database
|
||||
* once a value was cached. After an admin ran an update in THEIR session,
|
||||
* every other live session kept the old cached version, concluded "not
|
||||
* updated", and bounced between every page and /install/update until the
|
||||
* user's cookies were cleared.
|
||||
*
|
||||
* The fix self-heals: when a CACHED value would trigger the redirect, the
|
||||
* middleware re-reads the real version from the database first — one extra
|
||||
* query, only on the would-redirect path.
|
||||
*/
|
||||
class UpdatedTest extends \Unit\TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
private function appSettings(string $codeVersion): void
|
||||
{
|
||||
$settings = new AppSettings;
|
||||
$settings->dbVersion = $codeVersion;
|
||||
app()->instance(AppSettings::class, $settings);
|
||||
}
|
||||
|
||||
/** @param array<int, string|false> $dbVersions consecutive getSetting('db-version') results */
|
||||
private function settingRepo(array $dbVersions, ?int &$reads = null): void
|
||||
{
|
||||
$reads = 0;
|
||||
app()->instance(SettingRepository::class, $this->make(SettingRepository::class, [
|
||||
// Mirrors the real signature so forwarded arguments can't ever
|
||||
// make the stub brittle.
|
||||
'getSetting' => function (string $type = 'db-version') use (&$reads, $dbVersions) {
|
||||
$value = $dbVersions[min($reads, count($dbVersions) - 1)];
|
||||
$reads++;
|
||||
|
||||
return $value;
|
||||
},
|
||||
]));
|
||||
}
|
||||
|
||||
/** Run the middleware; returns [response, nextWasCalled]. */
|
||||
private function handleRequest(): array
|
||||
{
|
||||
// The redirect path resolves Frontcontroller from the container; its
|
||||
// real constructor needs the full HTTP stack, so bind a bare instance
|
||||
// (its redirect()/getCurrentRoute() members are static and work as-is).
|
||||
app()->instance(
|
||||
\Leantime\Core\Controller\Frontcontroller::class,
|
||||
$this->make(\Leantime\Core\Controller\Frontcontroller::class)
|
||||
);
|
||||
|
||||
$called = false;
|
||||
$response = (new Updated)->handle(
|
||||
IncomingRequest::create('/dashboard/home', 'GET'),
|
||||
function () use (&$called) {
|
||||
$called = true;
|
||||
|
||||
return new \Symfony\Component\HttpFoundation\Response('ok');
|
||||
}
|
||||
);
|
||||
|
||||
return [$response, $called];
|
||||
}
|
||||
|
||||
public function test_stale_session_cache_self_heals_after_an_update_ran_elsewhere(): void
|
||||
{
|
||||
// Session still remembers 3.5.25 from before the admin upgraded; the
|
||||
// DATABASE already says 3.5.26 (matching the code). The middleware
|
||||
// must re-read and pass through — not redirect-loop the user.
|
||||
session(['dbVersion' => '3.5.25', 'isUpdated' => false]);
|
||||
$this->appSettings('3.5.26');
|
||||
$this->settingRepo(['3.5.26'], $reads);
|
||||
|
||||
[, $nextCalled] = $this->handleRequest();
|
||||
|
||||
$this->assertTrue($nextCalled, 'a session whose cache is stale but whose DB is current must pass through');
|
||||
$this->assertSame(1, $reads, 'the DB is consulted exactly once to heal the cache');
|
||||
$this->assertSame('3.5.26', session('dbVersion'), 'the healed version is re-cached');
|
||||
$this->assertTrue(session('isUpdated'));
|
||||
}
|
||||
|
||||
public function test_current_session_cache_passes_through_without_touching_the_db(): void
|
||||
{
|
||||
session(['dbVersion' => '3.5.26', 'isUpdated' => true]);
|
||||
$this->appSettings('3.5.26');
|
||||
$this->settingRepo(['3.5.26'], $reads);
|
||||
|
||||
[, $nextCalled] = $this->handleRequest();
|
||||
|
||||
$this->assertTrue($nextCalled);
|
||||
$this->assertSame(0, $reads, 'an up-to-date cached version costs zero settings reads');
|
||||
}
|
||||
|
||||
public function test_genuinely_outdated_install_still_redirects_to_update(): void
|
||||
{
|
||||
// Both the cache AND the database are behind the code: the redirect is
|
||||
// correct. The self-heal costs one confirming read, then redirects.
|
||||
session(['dbVersion' => '3.5.25', 'isUpdated' => false]);
|
||||
$this->appSettings('3.5.26');
|
||||
$this->settingRepo(['3.5.25'], $reads);
|
||||
|
||||
[$response, $nextCalled] = $this->handleRequest();
|
||||
|
||||
$this->assertFalse($nextCalled, 'a genuinely outdated install must not pass through');
|
||||
$this->assertSame(1, $reads);
|
||||
$this->assertStringContainsString('/install/update', $response->headers->get('Location') ?? '', 'the redirect still points at the updater');
|
||||
$this->assertFalse(session('isUpdated'));
|
||||
}
|
||||
}
|
||||
164
tests/Unit/app/Core/Resources/Models/ResourceSummaryTest.php
Normal file
164
tests/Unit/app/Core/Resources/Models/ResourceSummaryTest.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unit\app\Core\Resources\Models;
|
||||
|
||||
use Leantime\Core\Resources\Models\BudgetLine;
|
||||
use Leantime\Core\Resources\Models\Dependency;
|
||||
use Leantime\Core\Resources\Models\PersonAllocation;
|
||||
use Leantime\Core\Resources\Models\ResourceSummary;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* ResourceSummary value-object arithmetic tests. Utilization math is
|
||||
* consumed directly by the report tiles, so its edge cases (zero-divide,
|
||||
* empty aggregation) need to be locked in.
|
||||
*/
|
||||
class ResourceSummaryTest extends TestCase
|
||||
{
|
||||
public function test_empty_returns_a_summary_with_zero_totals_and_marks_is_empty(): void
|
||||
{
|
||||
$summary = ResourceSummary::empty([1, 2, 3]);
|
||||
|
||||
$this->assertSame([1, 2, 3], $summary->projectIds);
|
||||
$this->assertSame([], $summary->people);
|
||||
$this->assertSame([], $summary->budget);
|
||||
$this->assertSame([], $summary->dependencies);
|
||||
$this->assertSame(0.0, $summary->totalCapacity);
|
||||
$this->assertSame(0.0, $summary->totalAllocated);
|
||||
$this->assertTrue($summary->isEmpty());
|
||||
}
|
||||
|
||||
public function test_capacity_utilization_is_zero_when_no_capacity_declared(): void
|
||||
{
|
||||
$summary = ResourceSummary::empty([1]);
|
||||
|
||||
// Divide-by-zero must not occur — the report tile calls this
|
||||
// unconditionally.
|
||||
$this->assertSame(0.0, $summary->capacityUtilization());
|
||||
}
|
||||
|
||||
public function test_capacity_utilization_returns_percent_when_capacity_declared(): void
|
||||
{
|
||||
$summary = new ResourceSummary(
|
||||
projectIds: [1],
|
||||
people: [$this->makePerson(40, [1 => 30])],
|
||||
budget: [],
|
||||
dependencies: [],
|
||||
totalCapacity: 40.0,
|
||||
totalAllocated: 30.0,
|
||||
totalBudgeted: 0.0,
|
||||
totalSpent: 0.0,
|
||||
);
|
||||
|
||||
$this->assertSame(75.0, $summary->capacityUtilization());
|
||||
}
|
||||
|
||||
public function test_budget_utilization_edge_cases(): void
|
||||
{
|
||||
$noBudget = ResourceSummary::empty([1]);
|
||||
$this->assertSame(0.0, $noBudget->budgetUtilization());
|
||||
|
||||
$withBudget = new ResourceSummary(
|
||||
projectIds: [1],
|
||||
people: [],
|
||||
budget: [$this->makeBudget(1000.0, 250.0)],
|
||||
dependencies: [],
|
||||
totalCapacity: 0.0,
|
||||
totalAllocated: 0.0,
|
||||
totalBudgeted: 1000.0,
|
||||
totalSpent: 250.0,
|
||||
);
|
||||
$this->assertSame(25.0, $withBudget->budgetUtilization());
|
||||
}
|
||||
|
||||
public function test_person_allocation_totals_and_availability(): void
|
||||
{
|
||||
$person = $this->makePerson(40, [1 => 20, 2 => 15]);
|
||||
|
||||
$this->assertSame(35.0, $person->totalAllocated());
|
||||
$this->assertSame(5.0, $person->available());
|
||||
}
|
||||
|
||||
public function test_person_over_allocation_reports_zero_available(): void
|
||||
{
|
||||
// available() clamps at 0; over-allocation is a real product state
|
||||
// callers detect by comparing totalAllocated() > capacity directly.
|
||||
$person = $this->makePerson(40, [1 => 45]);
|
||||
|
||||
$this->assertSame(45.0, $person->totalAllocated());
|
||||
$this->assertSame(0.0, $person->available());
|
||||
$this->assertGreaterThan($person->capacity, $person->totalAllocated());
|
||||
}
|
||||
|
||||
public function test_is_empty_true_only_when_all_three_sections_empty(): void
|
||||
{
|
||||
$onlyPeople = new ResourceSummary(
|
||||
projectIds: [1],
|
||||
people: [$this->makePerson(40, [])],
|
||||
budget: [],
|
||||
dependencies: [],
|
||||
totalCapacity: 40.0,
|
||||
totalAllocated: 0.0,
|
||||
totalBudgeted: 0.0,
|
||||
totalSpent: 0.0,
|
||||
);
|
||||
|
||||
$this->assertFalse($onlyPeople->isEmpty());
|
||||
}
|
||||
|
||||
public function test_is_empty_false_when_only_dependencies_present(): void
|
||||
{
|
||||
// A partnership-heavy program can have zero people and zero budget
|
||||
// authored but still be non-empty; the section must render.
|
||||
$onlyDeps = new ResourceSummary(
|
||||
projectIds: [1],
|
||||
people: [],
|
||||
budget: [],
|
||||
dependencies: [$this->makeDependency()],
|
||||
totalCapacity: 0.0,
|
||||
totalAllocated: 0.0,
|
||||
totalBudgeted: 0.0,
|
||||
totalSpent: 0.0,
|
||||
);
|
||||
|
||||
$this->assertFalse($onlyDeps->isEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, float> $allocations
|
||||
*/
|
||||
private function makePerson(float $capacity, array $allocations): PersonAllocation
|
||||
{
|
||||
return new PersonAllocation(
|
||||
itemId: 1,
|
||||
userId: null,
|
||||
displayName: 'Test',
|
||||
capacity: $capacity,
|
||||
allocations: $allocations,
|
||||
);
|
||||
}
|
||||
|
||||
private function makeBudget(float $budgeted, float $spent): BudgetLine
|
||||
{
|
||||
return new BudgetLine(
|
||||
itemId: 1,
|
||||
projectId: 1,
|
||||
label: 'Test',
|
||||
budgeted: $budgeted,
|
||||
spent: $spent,
|
||||
color: null,
|
||||
);
|
||||
}
|
||||
|
||||
private function makeDependency(): Dependency
|
||||
{
|
||||
return new Dependency(
|
||||
itemId: 1,
|
||||
partnerName: 'Test',
|
||||
type: 'partner',
|
||||
confirmed: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
117
tests/Unit/app/Core/Resources/Services/ResourcesRegistryTest.php
Normal file
117
tests/Unit/app/Core/Resources/Services/ResourcesRegistryTest.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unit\app\Core\Resources\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Leantime\Core\Resources\Contracts\ResourcesGateway;
|
||||
use Leantime\Core\Resources\Models\ResourceSummary;
|
||||
use Leantime\Core\Resources\Services\ResourcesRegistry;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for ResourcesRegistry — the one-plugin-owns-it contract.
|
||||
*
|
||||
* Behaviors under test:
|
||||
* - null on read when no provider is registered (honest "not installed")
|
||||
* - a registered provider is returned by resolve()
|
||||
* - re-registering the SAME provider class REPLACES the stored instance
|
||||
* (last write wins; safe because two instances of the same class are
|
||||
* functionally interchangeable — deliberately NOT idempotent)
|
||||
* - a DIFFERENT provider class trying to register is refused (first wins)
|
||||
*/
|
||||
class ResourcesRegistryTest extends TestCase
|
||||
{
|
||||
public function test_resolve_returns_null_when_no_provider_registered(): void
|
||||
{
|
||||
$registry = new ResourcesRegistry;
|
||||
|
||||
$this->assertNull($registry->resolve());
|
||||
$this->assertFalse($registry->hasProvider());
|
||||
}
|
||||
|
||||
public function test_resolve_returns_registered_gateway(): void
|
||||
{
|
||||
$registry = new ResourcesRegistry;
|
||||
$gateway = $this->makeGateway();
|
||||
|
||||
$registry->register($gateway);
|
||||
|
||||
$this->assertSame($gateway, $registry->resolve());
|
||||
$this->assertTrue($registry->hasProvider());
|
||||
}
|
||||
|
||||
public function test_reregistering_same_provider_class_replaces_instance(): void
|
||||
{
|
||||
$registry = new ResourcesRegistry;
|
||||
$first = $this->makeGateway();
|
||||
$second = $this->makeGateway(); // same anonymous class
|
||||
|
||||
$registry->register($first);
|
||||
$registry->register($second);
|
||||
|
||||
// Second registration replaces first because it's the same class.
|
||||
// (No warning is emitted — this is the "plugin re-registered on
|
||||
// hot-reload" case, not a conflict.) NOT idempotent in the strict
|
||||
// sense: state changes to point at the newer instance.
|
||||
$this->assertSame($second, $registry->resolve());
|
||||
}
|
||||
|
||||
public function test_different_provider_class_registration_is_refused(): void
|
||||
{
|
||||
Log::spy();
|
||||
|
||||
$registry = new ResourcesRegistry;
|
||||
$first = $this->makeGateway();
|
||||
$second = $this->makeOtherGateway();
|
||||
|
||||
$registry->register($first);
|
||||
$registry->register($second);
|
||||
|
||||
$this->assertSame(
|
||||
$first,
|
||||
$registry->resolve(),
|
||||
'First registration must win when a different class tries to register',
|
||||
);
|
||||
|
||||
// Logging the refused registration is part of the contract — a silent
|
||||
// refusal would let double-installs go unnoticed.
|
||||
Log::shouldHaveReceived('warning')->once()->withArgs(
|
||||
fn (string $message): bool => str_contains($message, 'ResourcesRegistry')
|
||||
&& str_contains($message, 'already registered')
|
||||
);
|
||||
}
|
||||
|
||||
private function makeGateway(): ResourcesGateway
|
||||
{
|
||||
return new class implements ResourcesGateway
|
||||
{
|
||||
public function getForProjects(array $projectIds, ?string $actualsFrom = null, ?string $actualsTo = null): ResourceSummary
|
||||
{
|
||||
return ResourceSummary::empty($projectIds);
|
||||
}
|
||||
|
||||
public function getForProgram(int $programId): ResourceSummary
|
||||
{
|
||||
return ResourceSummary::empty([$programId]);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function makeOtherGateway(): ResourcesGateway
|
||||
{
|
||||
return new class implements ResourcesGateway
|
||||
{
|
||||
public function getForProjects(array $projectIds, ?string $actualsFrom = null, ?string $actualsTo = null): ResourceSummary
|
||||
{
|
||||
return ResourceSummary::empty($projectIds);
|
||||
}
|
||||
|
||||
public function getForProgram(int $programId): ResourceSummary
|
||||
{
|
||||
return ResourceSummary::empty([$programId]);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
147
tests/Unit/app/Core/Support/AvatarcreatorTest.php
Normal file
147
tests/Unit/app/Core/Support/AvatarcreatorTest.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\app\Core\Support;
|
||||
|
||||
use LasseRafn\InitialAvatarGenerator\InitialAvatar;
|
||||
use LasseRafn\Initials\Initials;
|
||||
use Leantime\Core\Support\Avatarcreator;
|
||||
use Leantime\Core\UI\Theme;
|
||||
use SVG\SVG;
|
||||
use Unit\TestCase;
|
||||
|
||||
class AvatarcreatorTest extends TestCase
|
||||
{
|
||||
private $avatarGenerator;
|
||||
|
||||
private $initials;
|
||||
|
||||
private $theme;
|
||||
|
||||
private $avatarCreator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->avatarGenerator = $this->createMock(InitialAvatar::class);
|
||||
$this->avatarGenerator->method('background')->willReturn($this->avatarGenerator);
|
||||
$this->avatarGenerator->method('font')->willReturn($this->avatarGenerator);
|
||||
$this->avatarGenerator->method('color')->willReturn($this->avatarGenerator);
|
||||
$this->avatarGenerator->method('generateSvg')->willReturn(SVG::fromString('<svg></svg>'));
|
||||
|
||||
$this->initials = $this->createMock(Initials::class);
|
||||
$this->theme = $this->createMock(Theme::class);
|
||||
|
||||
$this->avatarCreator = new Avatarcreator(
|
||||
$this->avatarGenerator,
|
||||
$this->initials,
|
||||
$this->theme
|
||||
);
|
||||
}
|
||||
|
||||
public function test_set_background_color()
|
||||
{
|
||||
$this->avatarGenerator->expects($this->once())
|
||||
->method('background')
|
||||
->with('#ffffff');
|
||||
|
||||
$this->avatarCreator->setBackground('#ffffff');
|
||||
}
|
||||
|
||||
public function test_set_file_prefix()
|
||||
{
|
||||
$this->avatarCreator->setFilePrefix('test-prefix');
|
||||
$this->assertEquals('test-prefix', $this->avatarCreator->getFilePrefix());
|
||||
}
|
||||
|
||||
public function test_set_initials_with_valid_name()
|
||||
{
|
||||
$this->initials->expects($this->once())
|
||||
->method('name')
|
||||
->with('john-doe');
|
||||
|
||||
$this->avatarGenerator->expects($this->once())
|
||||
->method('name')
|
||||
->with('john-doe');
|
||||
|
||||
$this->avatarCreator->setInitials('John Doe');
|
||||
}
|
||||
|
||||
public function test_set_initials_with_empty_name()
|
||||
{
|
||||
$this->initials->expects($this->once())
|
||||
->method('name')
|
||||
->with('👻');
|
||||
|
||||
$this->avatarCreator->setInitials('');
|
||||
}
|
||||
|
||||
public function test_get_initials()
|
||||
{
|
||||
$this->initials->expects($this->once())
|
||||
->method('getInitials')
|
||||
->willReturn('JD');
|
||||
|
||||
$this->assertEquals('JD', $this->avatarCreator->getInitials());
|
||||
}
|
||||
|
||||
public function test_get_avatar_with_cache_hit()
|
||||
{
|
||||
$this->initials->method('getInitials')->willReturn('JD');
|
||||
|
||||
// Create test file
|
||||
$cacheDir = storage_path('framework/cache/avatars');
|
||||
if (! is_dir($cacheDir)) {
|
||||
mkdir($cacheDir, 0777, true);
|
||||
}
|
||||
$testFile = $cacheDir.'/user-jd.svg';
|
||||
file_put_contents($testFile, '<svg>test</svg>');
|
||||
|
||||
$result = $this->avatarCreator->getAvatar('John Doe');
|
||||
|
||||
$this->assertEquals(SVG::fromString('<svg>test</svg>'), $result);
|
||||
unlink($testFile);
|
||||
}
|
||||
|
||||
public function test_get_avatar_with_cache_miss()
|
||||
{
|
||||
$this->initials->method('getInitials')->willReturn('JD');
|
||||
$this->avatarGenerator->method('generateSvg')
|
||||
->willReturn(SVG::fromString('<svg></svg>'));
|
||||
|
||||
$result = $this->avatarCreator->getAvatar('John Doe');
|
||||
|
||||
$cacheDir = storage_path('framework/cache/avatars');
|
||||
$testFile = $cacheDir.'/user-jd.svg';
|
||||
|
||||
$this->assertFileExists($testFile);
|
||||
}
|
||||
|
||||
public function test_get_avatar_with_special_characters()
|
||||
{
|
||||
$this->initials->method('getInitials')->willReturn('JD');
|
||||
$this->avatarGenerator->method('generateSvg')
|
||||
->willReturn(SVG::fromString('<svg></svg>'));
|
||||
|
||||
$result = $this->avatarCreator->getAvatar('John@Doe#$%');
|
||||
|
||||
$cacheDir = storage_path('framework/cache/avatars');
|
||||
$testFile = $cacheDir.'/user-jd.svg';
|
||||
|
||||
$this->assertFileExists($testFile);
|
||||
}
|
||||
|
||||
public function test_get_avatar_with_non_latin_characters()
|
||||
{
|
||||
$this->initials->method('getInitials')->willReturn('李王');
|
||||
$this->avatarGenerator->method('generateSvg')
|
||||
->willReturn(SVG::fromString('<svg></svg>'));
|
||||
|
||||
$result = $this->avatarCreator->getAvatar('李王');
|
||||
|
||||
$cacheDir = storage_path('framework/cache/avatars');
|
||||
$testFile = $cacheDir.'/user-李王.svg';
|
||||
|
||||
$this->assertFileExists($testFile);
|
||||
}
|
||||
}
|
||||
122
tests/Unit/app/Core/Support/CarbonMacrosTest.php
Normal file
122
tests/Unit/app/Core/Support/CarbonMacrosTest.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\App\Core\Support;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\Support\CarbonMacros;
|
||||
use Unit\TestCase;
|
||||
|
||||
class CarbonMacrosTest extends TestCase
|
||||
{
|
||||
private CarbonMacros $carbonMacros;
|
||||
|
||||
private Language $languageMock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->languageMock = $this->createMock(Language::class);
|
||||
$this->languageMock->method('__')
|
||||
->willReturnCallback(function ($key) {
|
||||
return match ($key) {
|
||||
'language.dateformat' => 'm/d/Y',
|
||||
'language.timeformat' => 'h:i A',
|
||||
'language.dayNamesShort' => 'zo,ma,di,wo,do,vr,za',
|
||||
'language.dayNamesMin' => 'zo,ma,di,wo,do,vr,za',
|
||||
'language.monthNamesShort' => 'jan,feb,mrt,apr,mei,jun,jul,aug,sep,okt,nov,dec',
|
||||
default => $key
|
||||
};
|
||||
});
|
||||
|
||||
app()->instance(Language::class, $this->languageMock);
|
||||
|
||||
// Initialize with test values
|
||||
$this->carbonMacros = new CarbonMacros(
|
||||
userTimezone: 'America/Los_Angeles',
|
||||
userLanguage: 'en_US',
|
||||
userDateFormat: 'm/d/Y',
|
||||
userTimeFormat: 'h:i A',
|
||||
dbFormat: 'Y-m-d H:i:s',
|
||||
dbTimezone: 'UTC'
|
||||
);
|
||||
|
||||
// Mix in the macros to CarbonImmutable
|
||||
CarbonImmutable::mixin($this->carbonMacros);
|
||||
}
|
||||
|
||||
public function test_format_date_for_user(): void
|
||||
{
|
||||
$date = CarbonImmutable::create(2023, 12, 25, 14, 30, 0, 'UTC');
|
||||
$formatted = $date->formatDateForUser();
|
||||
|
||||
// Should be formatted according to user's timezone (PST) and format (m/d/Y)
|
||||
$this->assertEquals('12/25/2023', $formatted);
|
||||
}
|
||||
|
||||
public function test_format_time_for_user(): void
|
||||
{
|
||||
$date = CarbonImmutable::create(2023, 12, 25, 14, 30, 0, 'UTC');
|
||||
$formatted = $date->formatTimeForUser();
|
||||
|
||||
// UTC 14:30 is 06:30 AM in PST
|
||||
$this->assertEquals('06:30 AM', $formatted);
|
||||
}
|
||||
|
||||
public function test_format_24h_time_for_user(): void
|
||||
{
|
||||
$date = CarbonImmutable::create(2023, 12, 25, 14, 30, 0, 'UTC');
|
||||
$formatted = $date->format24HTimeForUser();
|
||||
|
||||
// UTC 14:30 is 06:30 in PST
|
||||
$this->assertEquals('06:30', $formatted);
|
||||
}
|
||||
|
||||
public function test_format_date_time_for_db(): void
|
||||
{
|
||||
// Create a date in user's timezone
|
||||
$date = CarbonImmutable::create(2023, 12, 25, 6, 30, 0, 'America/Los_Angeles');
|
||||
$formatted = $date->formatDateTimeForDb();
|
||||
|
||||
// Should be converted to UTC and formatted as Y-m-d H:i:s
|
||||
$this->assertEquals('2023-12-25 14:30:00', $formatted);
|
||||
}
|
||||
|
||||
public function test_set_to_user_timezone(): void
|
||||
{
|
||||
$date = CarbonImmutable::create(2023, 12, 25, 14, 30, 0, 'UTC');
|
||||
$converted = $date->setToUserTimezone();
|
||||
|
||||
$this->assertEquals('America/Los_Angeles', $converted->timezone->getName());
|
||||
$this->assertEquals('06:30', $converted->format('H:i'));
|
||||
}
|
||||
|
||||
public function test_set_to_db_timezone(): void
|
||||
{
|
||||
$date = CarbonImmutable::create(2023, 12, 25, 6, 30, 0, 'America/Los_Angeles');
|
||||
$converted = $date->setToDbTimezone();
|
||||
|
||||
$this->assertEquals('UTC', $converted->timezone->getName());
|
||||
$this->assertEquals('14:30', $converted->format('H:i'));
|
||||
}
|
||||
|
||||
public function test_dutch_language_support(): void
|
||||
{
|
||||
$macros = new CarbonMacros(
|
||||
userTimezone: 'Europe/Amsterdam',
|
||||
userLanguage: 'nl_NL',
|
||||
userDateFormat: 'd-m-Y',
|
||||
userTimeFormat: 'H:i',
|
||||
dbFormat: 'Y-m-d H:i:s',
|
||||
dbTimezone: 'UTC'
|
||||
);
|
||||
|
||||
CarbonImmutable::mixin($macros);
|
||||
|
||||
$date = CarbonImmutable::create(2023, 12, 25, 14, 30, 0, 'UTC');
|
||||
$formatted = $date->formatDateForUser();
|
||||
|
||||
$this->assertEquals('25-12-2023', $formatted);
|
||||
}
|
||||
}
|
||||
290
tests/Unit/app/Core/Support/DateTimeHelperTest.php
Normal file
290
tests/Unit/app/Core/Support/DateTimeHelperTest.php
Normal file
@@ -0,0 +1,290 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\App\Core\Support;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Carbon\Exceptions\InvalidDateException;
|
||||
use Carbon\Exceptions\InvalidFormatException;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\Support\CarbonMacros;
|
||||
use Leantime\Core\Support\DateTimeHelper;
|
||||
use Unit\TestCase;
|
||||
|
||||
class DateTimeHelperTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
private DateTimeHelper $dateTimeHelper;
|
||||
|
||||
private Environment $environmentMock;
|
||||
|
||||
private Language $languageMock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Mock the Environment class
|
||||
$this->environmentMock = $this->make(Environment::class, [
|
||||
'defaultTimezone' => 'UTC',
|
||||
'language' => 'en-US',
|
||||
]);
|
||||
app()->instance(Environment::class, $this->environmentMock);
|
||||
|
||||
$this->languageMock = $this->createMock(Language::class);
|
||||
$this->languageMock->method('__')->willReturnCallback(function ($index) {
|
||||
$map = [
|
||||
'language.dateformat' => 'm/d/Y',
|
||||
'language.timeformat' => 'h:i A',
|
||||
];
|
||||
|
||||
return $map[$index] ?? null;
|
||||
});
|
||||
app()->instance(\Leantime\Core\Language::class, $this->languageMock);
|
||||
|
||||
// Register mocks with the application container
|
||||
//
|
||||
// app()->instance(Language::class, $this->languageMock);
|
||||
|
||||
// America Los_Angeles is UTC - 8 so all db times need to come back from UTC - 8 hours
|
||||
CarbonImmutable::mixin(new CarbonMacros(
|
||||
'America/Los_Angeles',
|
||||
'en-US',
|
||||
'm/d/Y',
|
||||
'h:i A'
|
||||
));
|
||||
|
||||
// Create the DateTimeHelper instance
|
||||
$this->dateTimeHelper = new DateTimeHelper;
|
||||
}
|
||||
|
||||
public function test_parse_iso8601_with_timezone_offset_midnight(): void
|
||||
{
|
||||
// Test ISO 8601 with timezone offset (2025-04-16T00:00:00-04:00)
|
||||
$dateString = '2025-04-16T00:00:00-04:00';
|
||||
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString);
|
||||
|
||||
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
|
||||
$this->assertEquals('2025', $parsedDate->format('Y'));
|
||||
$this->assertEquals('04', $parsedDate->format('m'));
|
||||
$this->assertEquals('16', $parsedDate->format('d'));
|
||||
$this->assertEquals('00', $parsedDate->format('H'));
|
||||
$this->assertEquals('00', $parsedDate->format('i'));
|
||||
$this->assertEquals('00', $parsedDate->format('s'));
|
||||
$this->assertEquals('-04:00', $parsedDate->format('P'));
|
||||
}
|
||||
|
||||
public function test_parse_iso8601_with_timezone_offset(): void
|
||||
{
|
||||
// Test ISO 8601 with timezone offset (2025-04-16T23:59:59-04:00)
|
||||
$dateString = '2025-04-16T23:59:59-04:00';
|
||||
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString);
|
||||
|
||||
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
|
||||
$this->assertEquals('2025', $parsedDate->format('Y'));
|
||||
$this->assertEquals('04', $parsedDate->format('m'));
|
||||
$this->assertEquals('16', $parsedDate->format('d'));
|
||||
$this->assertEquals('23', $parsedDate->format('H'));
|
||||
$this->assertEquals('59', $parsedDate->format('i'));
|
||||
$this->assertEquals('59', $parsedDate->format('s'));
|
||||
$this->assertEquals('-04:00', $parsedDate->format('P'));
|
||||
}
|
||||
|
||||
public function test_parse_iso8601_with_timezone_offset_hhmm(): void
|
||||
{
|
||||
// Test ISO 8601 with timezone offset (2025-04-16T23:59:59-0400)
|
||||
$dateString = '2025-04-16T23:59:59-0400';
|
||||
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString);
|
||||
|
||||
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
|
||||
$this->assertEquals('2025', $parsedDate->format('Y'));
|
||||
$this->assertEquals('04', $parsedDate->format('m'));
|
||||
$this->assertEquals('16', $parsedDate->format('d'));
|
||||
$this->assertEquals('23', $parsedDate->format('H'));
|
||||
$this->assertEquals('59', $parsedDate->format('i'));
|
||||
$this->assertEquals('59', $parsedDate->format('s'));
|
||||
$this->assertEquals('-04:00', $parsedDate->format('P'));
|
||||
}
|
||||
|
||||
public function test_parse_iso8601_with_timezone_offset_hh(): void
|
||||
{
|
||||
// Test ISO 8601 with timezone offset (2025-04-16T23:59:59-04)
|
||||
$dateString = '2025-04-16T23:59:59-04';
|
||||
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString);
|
||||
|
||||
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
|
||||
$this->assertEquals('2025', $parsedDate->format('Y'));
|
||||
$this->assertEquals('04', $parsedDate->format('m'));
|
||||
$this->assertEquals('16', $parsedDate->format('d'));
|
||||
$this->assertEquals('23', $parsedDate->format('H'));
|
||||
$this->assertEquals('59', $parsedDate->format('i'));
|
||||
$this->assertEquals('59', $parsedDate->format('s'));
|
||||
$this->assertEquals('-04:00', $parsedDate->format('P'));
|
||||
}
|
||||
|
||||
public function test_parse_iso8601_with_zulu_time(): void
|
||||
{
|
||||
// Test ISO 8601 with Z/Zulu time (2025-04-16T23:59:59Z)
|
||||
$dateString = '2025-04-16T23:59:59Z';
|
||||
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString);
|
||||
|
||||
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
|
||||
$this->assertEquals('2025', $parsedDate->format('Y'));
|
||||
$this->assertEquals('04', $parsedDate->format('m'));
|
||||
$this->assertEquals('16', $parsedDate->format('d'));
|
||||
$this->assertEquals('23', $parsedDate->format('H'));
|
||||
$this->assertEquals('59', $parsedDate->format('i'));
|
||||
$this->assertEquals('59', $parsedDate->format('s'));
|
||||
// Z time should be parsed as UTC
|
||||
$this->assertEquals('+00:00', $parsedDate->format('P'));
|
||||
}
|
||||
|
||||
public function test_parse_iso8601_without_timezone(): void
|
||||
{
|
||||
// Test ISO 8601 without timezone (2025-04-16T23:59:59)
|
||||
$dateString = '2025-04-16T23:59:59';
|
||||
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString);
|
||||
|
||||
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
|
||||
$this->assertEquals('2025', $parsedDate->format('Y'));
|
||||
$this->assertEquals('04', $parsedDate->format('m'));
|
||||
$this->assertEquals('16', $parsedDate->format('d'));
|
||||
$this->assertEquals('23', $parsedDate->format('H'));
|
||||
$this->assertEquals('59', $parsedDate->format('i'));
|
||||
$this->assertEquals('59', $parsedDate->format('s'));
|
||||
}
|
||||
|
||||
public function test_parse_user_date_format(): void
|
||||
{
|
||||
// Test parsing date in user format (m/d/Y)
|
||||
$dateString = '04/16/2025';
|
||||
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString);
|
||||
|
||||
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
|
||||
$this->assertEquals('2025', $parsedDate->format('Y'));
|
||||
$this->assertEquals('04', $parsedDate->format('m'));
|
||||
$this->assertEquals('16', $parsedDate->format('d'));
|
||||
}
|
||||
|
||||
public function test_parse_user_date_and_time_format(): void
|
||||
{
|
||||
// Test parsing date and time in user format (m/d/Y h:i A)
|
||||
$dateString = '04/16/2025';
|
||||
$timeString = '11:59 PM';
|
||||
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString, $timeString);
|
||||
|
||||
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
|
||||
$this->assertEquals('2025', $parsedDate->format('Y'));
|
||||
$this->assertEquals('04', $parsedDate->format('m'));
|
||||
$this->assertEquals('16', $parsedDate->format('d'));
|
||||
$this->assertEquals('23', $parsedDate->format('H'));
|
||||
$this->assertEquals('59', $parsedDate->format('i'));
|
||||
}
|
||||
|
||||
public function test_parse_user_date_with_start_of_day(): void
|
||||
{
|
||||
// Test parsing date with start of day
|
||||
$dateString = '04/16/2025';
|
||||
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString, 'start');
|
||||
|
||||
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
|
||||
$this->assertEquals('2025', $parsedDate->format('Y'));
|
||||
$this->assertEquals('04', $parsedDate->format('m'));
|
||||
$this->assertEquals('16', $parsedDate->format('d'));
|
||||
$this->assertEquals('00', $parsedDate->format('H'));
|
||||
$this->assertEquals('00', $parsedDate->format('i'));
|
||||
$this->assertEquals('00', $parsedDate->format('s'));
|
||||
}
|
||||
|
||||
public function test_parse_user_date_with_end_of_day(): void
|
||||
{
|
||||
// Test parsing date with end of day
|
||||
$dateString = '04/16/2025';
|
||||
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString, 'end');
|
||||
|
||||
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
|
||||
$this->assertEquals('2025', $parsedDate->format('Y'));
|
||||
$this->assertEquals('04', $parsedDate->format('m'));
|
||||
$this->assertEquals('16', $parsedDate->format('d'));
|
||||
$this->assertEquals('23', $parsedDate->format('H'));
|
||||
$this->assertEquals('59', $parsedDate->format('i'));
|
||||
$this->assertEquals('59', $parsedDate->format('s'));
|
||||
}
|
||||
|
||||
public function test_invalid_date_string(): void
|
||||
{
|
||||
// Test with invalid date string
|
||||
$this->expectException(InvalidFormatException::class);
|
||||
$this->dateTimeHelper->parseUserDateTime('not-a-date');
|
||||
}
|
||||
|
||||
public function test_empty_date_string(): void
|
||||
{
|
||||
// Test with empty date string
|
||||
$this->expectException(InvalidDateException::class);
|
||||
$this->dateTimeHelper->parseUserDateTime('');
|
||||
}
|
||||
|
||||
public function test_parse_db_date_time(): void
|
||||
{
|
||||
// Test parsing DB date time
|
||||
$dbDate = '2025-04-16 23:59:59';
|
||||
$parsedDate = $this->dateTimeHelper->parseDbDateTime($dbDate);
|
||||
|
||||
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
|
||||
$this->assertEquals('2025', $parsedDate->format('Y'));
|
||||
$this->assertEquals('04', $parsedDate->format('m'));
|
||||
$this->assertEquals('16', $parsedDate->format('d'));
|
||||
$this->assertEquals('23', $parsedDate->format('H'));
|
||||
$this->assertEquals('59', $parsedDate->format('i'));
|
||||
$this->assertEquals('59', $parsedDate->format('s'));
|
||||
}
|
||||
|
||||
public function test_parse_user_24h_time(): void
|
||||
{
|
||||
// Test parsing 24h time
|
||||
$timeString = '23:59';
|
||||
$parsedTime = $this->dateTimeHelper->parseUser24hTime($timeString);
|
||||
|
||||
$this->assertInstanceOf(CarbonImmutable::class, $parsedTime);
|
||||
$this->assertEquals('23', $parsedTime->format('H'));
|
||||
$this->assertEquals('59', $parsedTime->format('i'));
|
||||
}
|
||||
|
||||
public function test_user_now(): void
|
||||
{
|
||||
// Test user now returns current time
|
||||
$now = $this->dateTimeHelper->userNow();
|
||||
$this->assertInstanceOf(CarbonImmutable::class, $now);
|
||||
|
||||
// Should be within a few seconds of now
|
||||
$this->assertLessThan(5, abs(time() - $now->timestamp));
|
||||
}
|
||||
|
||||
public function test_db_now(): void
|
||||
{
|
||||
// Test db now returns current time in UTC
|
||||
$now = $this->dateTimeHelper->dbNow();
|
||||
$this->assertInstanceOf(CarbonImmutable::class, $now);
|
||||
|
||||
// Should be within a few seconds of now
|
||||
$this->assertLessThan(5, abs(time() - $now->timestamp));
|
||||
|
||||
// Should be in UTC timezone
|
||||
$this->assertEquals('UTC', $now->timezone->getName());
|
||||
}
|
||||
|
||||
public function test_is_valid_date_string(): void
|
||||
{
|
||||
// Test valid date strings
|
||||
$this->assertTrue($this->dateTimeHelper->isValidDateString('2025-04-16 23:59:59'));
|
||||
$this->assertTrue($this->dateTimeHelper->isValidDateString('2025-04-16T23:59:59-04:00'));
|
||||
|
||||
// Test invalid date strings
|
||||
$this->assertFalse($this->dateTimeHelper->isValidDateString(''));
|
||||
$this->assertFalse($this->dateTimeHelper->isValidDateString(null));
|
||||
$this->assertFalse($this->dateTimeHelper->isValidDateString('1969-12-31 00:00:00'));
|
||||
$this->assertFalse($this->dateTimeHelper->isValidDateString('0000-00-00 00:00:00'));
|
||||
}
|
||||
}
|
||||
73
tests/Unit/app/Core/Support/FormatTest.php
Normal file
73
tests/Unit/app/Core/Support/FormatTest.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Support;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Leantime\Core\Support\CarbonMacros;
|
||||
use Leantime\Core\Support\Format;
|
||||
use Tests\DateTimeHelper;
|
||||
use Tests\Language;
|
||||
use Tests\MockObject;
|
||||
use Unit\TestCase;
|
||||
|
||||
class FormatTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @var DateTimeHelper|MockObject
|
||||
*/
|
||||
private $carbonMacrosMock;
|
||||
|
||||
/**
|
||||
* @var Language|MockObject
|
||||
*/
|
||||
private $languageMock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
|
||||
parent::setUp();
|
||||
|
||||
$this->languageMock = $this->createMock(\Leantime\Core\Language::class);
|
||||
app()->instance(\Leantime\Core\Support\CarbonMacros::class, $this->carbonMacrosMock);
|
||||
app()->instance(\Leantime\Core\Language::class, $this->languageMock);
|
||||
|
||||
// America Los_Angeles is UTC - 8 so all db times need to come back from UTC - 8 hours
|
||||
CarbonImmutable::mixin(new CarbonMacros(
|
||||
'America/Los_Angeles',
|
||||
'en-US',
|
||||
'm/d/Y',
|
||||
'h:i A'
|
||||
));
|
||||
|
||||
}
|
||||
|
||||
public function test_date(): void
|
||||
{
|
||||
$formattedDateString = '12/31/2021';
|
||||
$dbDate = '2022-01-01 00:00:00';
|
||||
$format = new Format($dbDate, '');
|
||||
|
||||
$this->assertSame($formattedDateString, $format->date());
|
||||
}
|
||||
|
||||
public function test_time(): void
|
||||
{
|
||||
$formattedTimeString = '04:00 PM';
|
||||
$dbDate = '2022-01-01 00:00:00';
|
||||
$format = new Format($dbDate, '');
|
||||
|
||||
$this->assertSame($formattedTimeString, $format->time());
|
||||
}
|
||||
|
||||
public function test_time24(): void
|
||||
{
|
||||
$formattedTimeString = '16:00';
|
||||
$dbDate = '2022-01-01 00:00:00';
|
||||
$format = new Format($dbDate, '');
|
||||
|
||||
$this->assertSame($formattedTimeString, $format->time24());
|
||||
|
||||
}
|
||||
|
||||
// Similarly you can add tests for other 'Format' class methods.
|
||||
}
|
||||
61
tests/Unit/app/Core/Support/NameSanitizerTest.php
Normal file
61
tests/Unit/app/Core/Support/NameSanitizerTest.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Support;
|
||||
|
||||
use Leantime\Core\Support\NameSanitizer;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Regression tests for the invite-spam abuse fix: person names were stored and
|
||||
* emailed raw, letting spammers use the firstname field as an email payload.
|
||||
* The sanitizer must strip abuse vectors (contact numbers, URLs, emails, bidi
|
||||
* tricks) while letting legitimate names in any script through unchanged.
|
||||
*/
|
||||
class NameSanitizerTest extends TestCase
|
||||
{
|
||||
public function test_legitimate_names_pass_unchanged(): void
|
||||
{
|
||||
$this->assertSame('Marcel', NameSanitizer::clean('Marcel'));
|
||||
$this->assertSame('María José', NameSanitizer::clean('María José'));
|
||||
$this->assertSame('汪小明', NameSanitizer::clean('汪小明'));
|
||||
$this->assertSame('محمد علي', NameSanitizer::clean('محمد علي'));
|
||||
$this->assertSame("O'Connor-Smith", NameSanitizer::clean("O'Connor-Smith"));
|
||||
}
|
||||
|
||||
public function test_strips_contact_number_from_spam_payload(): void
|
||||
{
|
||||
// The actual payload from the 2026-07 abuse reports
|
||||
$this->assertStringNotContainsString('992600898', NameSanitizer::clean('+汪汪992600898-ن颂58嗏،Virtual'));
|
||||
}
|
||||
|
||||
public function test_strips_html(): void
|
||||
{
|
||||
$this->assertSame('alert(1)', NameSanitizer::clean('<script>alert(1)</script>'));
|
||||
}
|
||||
|
||||
public function test_strips_urls_and_emails(): void
|
||||
{
|
||||
$this->assertSame('Buy cheap', NameSanitizer::clean('Buy http://spam.example.com cheap'));
|
||||
$this->assertSame('Visit now', NameSanitizer::clean('Visit www.spam.example now'));
|
||||
$this->assertSame('mail me', NameSanitizer::clean('mail spam@evil.example me'));
|
||||
}
|
||||
|
||||
public function test_strips_control_and_bidi_characters(): void
|
||||
{
|
||||
$this->assertSame('JohnSmith', NameSanitizer::clean("John\u{202E}Smith"));
|
||||
$this->assertSame('AB', NameSanitizer::clean("A\u{200B}\u{0000}B"));
|
||||
}
|
||||
|
||||
public function test_caps_length_and_handles_non_strings(): void
|
||||
{
|
||||
$this->assertSame(50, mb_strlen(NameSanitizer::clean(str_repeat('A', 200))));
|
||||
$this->assertSame('', NameSanitizer::clean(null));
|
||||
$this->assertSame('', NameSanitizer::clean(12345));
|
||||
$this->assertSame('', NameSanitizer::clean(['array']));
|
||||
}
|
||||
|
||||
public function test_collapses_whitespace(): void
|
||||
{
|
||||
$this->assertSame('John Smith', NameSanitizer::clean(" John Smith \n"));
|
||||
}
|
||||
}
|
||||
84
tests/Unit/app/Core/Support/OutboundUrlGuardTest.php
Normal file
84
tests/Unit/app/Core/Support/OutboundUrlGuardTest.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Support;
|
||||
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use GuzzleHttp\Psr7\Uri;
|
||||
use Leantime\Core\Support\OutboundUrlGuard;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Covers the SSRF guard's address classification and redirect re-validation using IP literals and
|
||||
* direct calls, so nothing here depends on live DNS or the network.
|
||||
*/
|
||||
class OutboundUrlGuardTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @dataProvider ipProvider
|
||||
*/
|
||||
public function test_is_ip_allowed(string $ip, bool $expected): void
|
||||
{
|
||||
$this->assertSame($expected, OutboundUrlGuard::isIpAllowed($ip));
|
||||
}
|
||||
|
||||
public static function ipProvider(): array
|
||||
{
|
||||
return [
|
||||
'loopback v4' => ['127.0.0.1', false],
|
||||
'private 10/8' => ['10.1.2.3', false],
|
||||
'private 172.16/12' => ['172.16.5.5', false],
|
||||
'private 192.168/16' => ['192.168.1.1', false],
|
||||
'cgnat 100.64/10' => ['100.64.0.1', false],
|
||||
'link-local metadata' => ['169.254.169.254', false],
|
||||
'reserved 0.0.0.0/8' => ['0.0.0.0', false],
|
||||
'public v4 (google dns)' => ['8.8.8.8', true],
|
||||
'public v4 (cloudflare)' => ['1.1.1.1', true],
|
||||
'loopback v6' => ['::1', false],
|
||||
'public v6 (cloudflare)' => ['2606:4700:4700::1111', true],
|
||||
'ipv4-mapped loopback' => ['::ffff:127.0.0.1', false],
|
||||
'ipv4-mapped cgnat' => ['::ffff:100.64.0.1', false],
|
||||
'ipv4-mapped public' => ['::ffff:8.8.8.8', true],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider urlProvider
|
||||
*/
|
||||
public function test_is_allowed_url(string $url, bool $expected): void
|
||||
{
|
||||
$this->assertSame($expected, OutboundUrlGuard::isAllowedUrl($url));
|
||||
}
|
||||
|
||||
public static function urlProvider(): array
|
||||
{
|
||||
return [
|
||||
'loopback literal' => ['http://127.0.0.1/feed.ics', false],
|
||||
'cgnat literal' => ['http://100.64.0.1/', false],
|
||||
'metadata literal' => ['http://169.254.169.254/latest/meta-data/', false],
|
||||
'public literal' => ['https://8.8.8.8/', true],
|
||||
'non-http scheme' => ['ftp://8.8.8.8/', false],
|
||||
'file scheme' => ['file:///etc/passwd', false],
|
||||
'garbage' => ['not-a-url', false],
|
||||
];
|
||||
}
|
||||
|
||||
public function test_redirect_options_block_disallowed_hop(): void
|
||||
{
|
||||
$onRedirect = OutboundUrlGuard::redirectOptions()['on_redirect'];
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
$onRedirect(new Request('GET', 'https://8.8.8.8/'), new Response(302), new Uri('http://169.254.169.254/'));
|
||||
}
|
||||
|
||||
public function test_redirect_options_allow_public_hop(): void
|
||||
{
|
||||
$onRedirect = OutboundUrlGuard::redirectOptions()['on_redirect'];
|
||||
|
||||
// A public → public redirect must not throw.
|
||||
$onRedirect(new Request('GET', 'https://8.8.8.8/'), new Response(302), new Uri('https://1.1.1.1/'));
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
61
tests/Unit/app/Core/UI/TemplateEscapeTest.php
Normal file
61
tests/Unit/app/Core/UI/TemplateEscapeTest.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\UI;
|
||||
|
||||
use Leantime\Core\UI\Template;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Regression tests for Template::escape() (#3636).
|
||||
*
|
||||
* escape() used htmlentities(), which converts non-ASCII to named entities on top of the
|
||||
* XSS-relevant characters. Nearly every call site renders through {{ }}, which escapes the
|
||||
* resulting ampersand a second time, so users with non-English data saw a literal
|
||||
* "Müller" in dropdowns, filters and Ideas.
|
||||
*
|
||||
* These pin both halves of the contract: non-ASCII survives, and the escaping is still as
|
||||
* strong as it was for the call sites that render through {!! !!}.
|
||||
*/
|
||||
class TemplateEscapeTest extends TestCase
|
||||
{
|
||||
private function escape(?string $value): string
|
||||
{
|
||||
// Built without the constructor: escape() only reaches convertRelativePaths(), which
|
||||
// depends on the BASE_URL constant rather than any instance state, so none of
|
||||
// Template's collaborators (session, db, theme) need to exist here.
|
||||
$template = (new \ReflectionClass(Template::class))->newInstanceWithoutConstructor();
|
||||
|
||||
return $template->escape($value);
|
||||
}
|
||||
|
||||
public function test_non_ascii_is_left_alone(): void
|
||||
{
|
||||
$this->assertSame('Müller', $this->escape('Müller'));
|
||||
$this->assertSame('Ä Ö Ü ä ö ü ß', $this->escape('Ä Ö Ü ä ö ü ß'));
|
||||
$this->assertStringNotContainsString(
|
||||
'ü',
|
||||
$this->escape('Müller'),
|
||||
'Umlauts must not be turned into named entities (#3636)'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_xss_relevant_characters_are_still_escaped(): void
|
||||
{
|
||||
$this->assertSame('<script>alert(1)</script>', $this->escape('<script>alert(1)</script>'));
|
||||
$this->assertSame('" onerror="alert(1)', $this->escape('" onerror="alert(1)'));
|
||||
$this->assertSame('' onmouseover='x', $this->escape("' onmouseover='x"));
|
||||
$this->assertSame('a < b & c > d', $this->escape('a < b & c > d'));
|
||||
}
|
||||
|
||||
public function test_ampersand_is_escaped_exactly_once(): void
|
||||
{
|
||||
// The double-escape the user actually saw came from & being encoded here and again
|
||||
// by Blade. One pass here must produce exactly one &.
|
||||
$this->assertSame('Müller & Söhne', $this->escape('Müller & Söhne'));
|
||||
}
|
||||
|
||||
public function test_null_is_an_empty_string(): void
|
||||
{
|
||||
$this->assertSame('', $this->escape(null));
|
||||
}
|
||||
}
|
||||
112
tests/Unit/app/Core/UI/ThemeTest.php
Normal file
112
tests/Unit/app/Core/UI/ThemeTest.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\UI;
|
||||
|
||||
use Leantime\Core\Configuration\AppSettings;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Files\FileManager;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\UI\Theme;
|
||||
use Leantime\Domain\Setting\Repositories\Setting;
|
||||
|
||||
class ThemeTest extends \Unit\TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/**
|
||||
* The test object
|
||||
*
|
||||
* @var Theme
|
||||
*/
|
||||
protected $theme;
|
||||
|
||||
protected $settingsRepoMock;
|
||||
|
||||
protected $languageMock;
|
||||
|
||||
protected $configMock;
|
||||
|
||||
protected $appSettingsMock;
|
||||
|
||||
protected $fileManagerMock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
|
||||
parent::setUp();
|
||||
|
||||
if (! defined('BASE_URL')) {
|
||||
define('BASE_URL', 'http://localhost');
|
||||
}
|
||||
|
||||
$this->settingsRepoMock = $this->make(Setting::class, [
|
||||
|
||||
]);
|
||||
$this->languageMock = $this->make(Language::class, [
|
||||
|
||||
]);
|
||||
|
||||
$this->fileManagerMock = $this->make(FileManager::class, [
|
||||
|
||||
]);
|
||||
|
||||
$this->configMock = $this->make(Environment::class, [
|
||||
'primarycolor' => '#123',
|
||||
'secondarycolor' => '#123',
|
||||
|
||||
]);
|
||||
|
||||
$this->appSettingsMock = $this->make(AppSettings::class, [
|
||||
'appVersion' => '123',
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
protected function _after()
|
||||
{
|
||||
$this->theme = null;
|
||||
}
|
||||
|
||||
// Write tests below
|
||||
|
||||
/**
|
||||
* Test GetMenuTypes method
|
||||
*/
|
||||
public function test_get_default_color_scheme_with_color_env_set()
|
||||
{
|
||||
|
||||
// Load class to be tested
|
||||
$this->theme = new Theme(
|
||||
settingsRepo: $this->settingsRepoMock,
|
||||
language: $this->languageMock,
|
||||
config: $this->configMock,
|
||||
appSettings: $this->appSettingsMock,
|
||||
fileManager: $this->fileManagerMock
|
||||
);
|
||||
|
||||
$colorScheme = $this->theme->getColorScheme();
|
||||
$this->assertEquals('companyColors', $colorScheme);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GetMenuTypes method
|
||||
*/
|
||||
public function test_get_default_color_scheme_without_env()
|
||||
{
|
||||
|
||||
$configMock = $this->make(Environment::class, []);
|
||||
|
||||
$theme = new Theme(
|
||||
settingsRepo: $this->settingsRepoMock,
|
||||
language: $this->languageMock,
|
||||
config: $configMock,
|
||||
appSettings: $this->appSettingsMock,
|
||||
fileManager: $this->fileManagerMock
|
||||
);
|
||||
|
||||
$colorScheme = $theme->getColorScheme();
|
||||
$this->assertEquals('themeDefault', $colorScheme);
|
||||
|
||||
}
|
||||
}
|
||||
264
tests/Unit/app/Domain/Api/Controllers/JsonrpcTest.php
Normal file
264
tests/Unit/app/Domain/Api/Controllers/JsonrpcTest.php
Normal file
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\app\Domain\Api\Controllers;
|
||||
|
||||
use Leantime\Core\Application;
|
||||
use Leantime\Core\Auth\Permissions\PermissionEnforcer;
|
||||
use Leantime\Core\Bootstrap\LoadConfig;
|
||||
use Leantime\Core\Bootstrap\SetRequestForConsole;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\UI\Template;
|
||||
use Leantime\Domain\Api\Controllers\Jsonrpc;
|
||||
|
||||
/**
|
||||
* The controller now builds its envelopes through the JsonRpcResponse / JsonRpcErrorResponse
|
||||
* response types, so these tests assert on the actual JSON body of the returned Response
|
||||
* (behavior) rather than on the Template::displayJson() call that used to construct it.
|
||||
*/
|
||||
class JsonrpcTest extends \Unit\TestCase
|
||||
{
|
||||
private Jsonrpc $controller;
|
||||
|
||||
private Template $template;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->app = new Application(APP_ROOT);
|
||||
$this->app->bootstrapWith([LoadConfig::class, SetRequestForConsole::class]);
|
||||
|
||||
$this->app->boot();
|
||||
$this->app['view'] = $this->createMock(\Illuminate\View\Factory::class);
|
||||
$this->app['session'] = $this->createMock(\Illuminate\Session\SessionManager::class);
|
||||
$this->app->bootstrapWith([LoadConfig::class, SetRequestForConsole::class]);
|
||||
|
||||
// Jsonrpc::init() now type-hints PermissionEnforcer (resolved via app()->call in the
|
||||
// base Controller constructor). Bind a no-op mock so the controller builds without
|
||||
// pulling in the full permission engine (PermissionService -> Repository -> Db), which
|
||||
// this minimal test container can't resolve. These tests don't exercise authorization.
|
||||
$this->app->instance(PermissionEnforcer::class, $this->createMock(PermissionEnforcer::class));
|
||||
|
||||
$this->template = $this->createMock(Template::class);
|
||||
$language = $this->createMock(Language::class);
|
||||
$this->controller = new Jsonrpc($this->app['request'], $this->template, $language);
|
||||
$_SERVER['REQUEST_METHOD'] = 'post';
|
||||
}
|
||||
|
||||
private function bodyOf($response): array
|
||||
{
|
||||
return json_decode($response->getContent(), true);
|
||||
}
|
||||
|
||||
public function test_method_string_parsing()
|
||||
{
|
||||
$params = [
|
||||
'method' => 'leantime.rpc.Comments.pollComments',
|
||||
'params' => ['projectId' => 1],
|
||||
'id' => 1,
|
||||
'jsonrpc' => '2.0',
|
||||
];
|
||||
|
||||
$body = $this->bodyOf($this->controller->post($params));
|
||||
|
||||
$this->assertIsArray($body);
|
||||
$this->assertArrayHasKey('jsonrpc', $body);
|
||||
$this->assertEquals('2.0', $body['jsonrpc']);
|
||||
}
|
||||
|
||||
public function test_invalid_method_string()
|
||||
{
|
||||
$params = [
|
||||
'method' => 'invalid.method.string',
|
||||
'params' => ['projectId' => 1],
|
||||
'id' => 1,
|
||||
'jsonrpc' => '2.0',
|
||||
];
|
||||
|
||||
$body = $this->bodyOf($this->controller->post($params));
|
||||
|
||||
$this->assertArrayHasKey('error', $body);
|
||||
$this->assertEquals(-32602, $body['error']['code']);
|
||||
}
|
||||
|
||||
public function test_missing_json_rpc_version()
|
||||
{
|
||||
$params = [
|
||||
'method' => 'leantime.rpc.Comments.pollComments',
|
||||
'params' => ['projectId' => 1],
|
||||
'id' => 1,
|
||||
];
|
||||
|
||||
$body = $this->bodyOf($this->controller->post($params));
|
||||
|
||||
$this->assertArrayHasKey('error', $body);
|
||||
$this->assertEquals(-32600, $body['error']['code']);
|
||||
}
|
||||
|
||||
public function test_batch_request()
|
||||
{
|
||||
$params = [
|
||||
[
|
||||
'method' => 'leantime.rpc.Comments.pollComments',
|
||||
'params' => ['projectId' => 1],
|
||||
'id' => 1,
|
||||
'jsonrpc' => '2.0',
|
||||
],
|
||||
[
|
||||
'method' => 'leantime.rpc.Comments.pollComments',
|
||||
'params' => ['projectId' => 2],
|
||||
'id' => 2,
|
||||
'jsonrpc' => '2.0',
|
||||
],
|
||||
];
|
||||
|
||||
$body = $this->bodyOf($this->controller->post($params));
|
||||
|
||||
// The batch response is an array with one envelope per sub-request.
|
||||
$this->assertIsArray($body);
|
||||
$this->assertCount(2, $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* The riskiest behavioral change: the service-call catch is now catch(\Throwable) and an
|
||||
* UNEXPECTED throwable must be collapsed to a generic -32000 with its message NOT leaked.
|
||||
* Driven end-to-end through the controller by rebinding the (@api) Comments service to a
|
||||
* stub that throws.
|
||||
*/
|
||||
public function test_service_throwing_unknown_error_is_generic_and_not_leaked()
|
||||
{
|
||||
$secret = 'super-secret-internal-detail';
|
||||
|
||||
$this->app->bind(
|
||||
\Leantime\Domain\Comments\Services\Comments::class,
|
||||
fn () => new class($secret)
|
||||
{
|
||||
public function __construct(private string $secret) {}
|
||||
|
||||
public function pollComments(?int $projectId = null, ?int $moduleId = null): array
|
||||
{
|
||||
throw new \RuntimeException($this->secret);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
$params = [
|
||||
'method' => 'leantime.rpc.Comments.pollComments',
|
||||
'params' => ['projectId' => 1],
|
||||
'id' => 42,
|
||||
'jsonrpc' => '2.0',
|
||||
];
|
||||
|
||||
$response = $this->controller->post($params);
|
||||
$body = $this->bodyOf($response);
|
||||
|
||||
$this->assertSame(-32000, $body['error']['code']);
|
||||
$this->assertSame('Server error', $body['error']['message']);
|
||||
$this->assertSame(42, $body['id']);
|
||||
$this->assertStringNotContainsString($secret, $response->getContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* A typed Leantime exception thrown by a service maps to ITS JSON-RPC code (here -32001 for
|
||||
* AuthorizationException), not the generic -32000, with the request id preserved.
|
||||
*/
|
||||
public function test_service_throwing_typed_exception_maps_to_its_rpc_code()
|
||||
{
|
||||
$this->app->bind(
|
||||
\Leantime\Domain\Comments\Services\Comments::class,
|
||||
fn () => new class
|
||||
{
|
||||
public function pollComments(?int $projectId = null, ?int $moduleId = null): array
|
||||
{
|
||||
throw new \Leantime\Core\Exceptions\AuthorizationException;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
$params = [
|
||||
'method' => 'leantime.rpc.Comments.pollComments',
|
||||
'params' => ['projectId' => 1],
|
||||
'id' => 7,
|
||||
'jsonrpc' => '2.0',
|
||||
];
|
||||
|
||||
$body = $this->bodyOf($this->controller->post($params));
|
||||
|
||||
$this->assertSame(-32001, $body['error']['code']);
|
||||
$this->assertSame(7, $body['id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* A notification (no id) whose service call fails must NOT be responded to — the controller
|
||||
* returns an empty 200 instead of a JSON-RPC error envelope (JSON-RPC 2.0).
|
||||
*/
|
||||
public function test_notification_service_failure_returns_empty_200()
|
||||
{
|
||||
$this->app->bind(
|
||||
\Leantime\Domain\Comments\Services\Comments::class,
|
||||
fn () => new class
|
||||
{
|
||||
public function pollComments(?int $projectId = null, ?int $moduleId = null): array
|
||||
{
|
||||
throw new \RuntimeException('boom');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// No 'id' => JSON-RPC notification.
|
||||
$params = [
|
||||
'method' => 'leantime.rpc.Comments.pollComments',
|
||||
'params' => ['projectId' => 1],
|
||||
'jsonrpc' => '2.0',
|
||||
];
|
||||
|
||||
$response = $this->controller->post($params);
|
||||
|
||||
$this->assertSame(200, $response->getStatusCode());
|
||||
$this->assertSame('', $response->getContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* @api detection must only recognize the tag at the START of a docblock line (" * @api").
|
||||
* A method whose docblock merely MENTIONS @api in prose (e.g. a de-@api'd internal helper
|
||||
* documented as "not exposed, unlike @api methods") must NOT become JSON-RPC reachable —
|
||||
* regression guard for the IDOR fix where "Not @api:" still matched the old /@api\b/ regex.
|
||||
*/
|
||||
public function test_api_detection_requires_the_tag_at_a_docblock_line_start(): void
|
||||
{
|
||||
$isApiMethod = new \ReflectionMethod(Jsonrpc::class, 'isApiMethod');
|
||||
$isApiMethod->setAccessible(true);
|
||||
$invoke = fn (string $class, string $method): bool => $isApiMethod->invoke($this->controller, $class, $method);
|
||||
|
||||
// A genuine ` * @api` docblock line IS recognized.
|
||||
$this->assertTrue($invoke(IsApiFixture::class, 'realApiMethod'));
|
||||
// A prose mention of @api (and a method with no docblock) must NOT be recognized.
|
||||
$this->assertFalse($invoke(IsApiFixture::class, 'proseMentionMethod'));
|
||||
$this->assertFalse($invoke(IsApiFixture::class, 'noDocblockMethod'));
|
||||
|
||||
// The real de-@api'd internal helpers must NOT be JSON-RPC reachable (IDOR fixes):
|
||||
$this->assertFalse($invoke(\Leantime\Domain\Clients\Services\Clients::class, 'getUserClients'));
|
||||
$this->assertFalse($invoke(\Leantime\Domain\Users\Services\Users::class, 'setProfilePicture'));
|
||||
$this->assertFalse($invoke(\Leantime\Domain\Users\Services\Users::class, 'editOwn'));
|
||||
// ...while a genuine @api service method stays reachable.
|
||||
$this->assertTrue($invoke(\Leantime\Domain\Clients\Services\Clients::class, 'getAll'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture for isApiMethod() docblock-detection tests.
|
||||
*/
|
||||
class IsApiFixture
|
||||
{
|
||||
/**
|
||||
* @api
|
||||
*/
|
||||
public function realApiMethod(): void {}
|
||||
|
||||
/**
|
||||
* @internal Not exposed over JSON-RPC, unlike @api methods — a prose mention only.
|
||||
*/
|
||||
public function proseMentionMethod(): void {}
|
||||
|
||||
public function noDocblockMethod(): void {}
|
||||
}
|
||||
220
tests/Unit/app/Domain/Api/Services/ApiServiceTest.php
Normal file
220
tests/Unit/app/Domain/Api/Services/ApiServiceTest.php
Normal file
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Api\Services;
|
||||
|
||||
use Leantime\Domain\Api\Repositories\Api as ApiRepository;
|
||||
use Leantime\Domain\Api\Services\Api as ApiService;
|
||||
use Leantime\Domain\Menu\Repositories\Menu as MenuRepository;
|
||||
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
|
||||
use Leantime\Domain\Users\Repositories\Users as UserRepository;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the Api service helpers extracted during the thin-controller
|
||||
* refactor (project relation reconciliation, API key creation/update, image
|
||||
* response building and user filtering).
|
||||
*/
|
||||
class ApiServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/**
|
||||
* Builds a real Api service, allowing each dependency to be overridden with
|
||||
* a stub so we can observe the persistence calls.
|
||||
*/
|
||||
private function makeService(
|
||||
?ApiRepository $apiRepo = null,
|
||||
?UserRepository $userRepo = null,
|
||||
?ProjectRepository $projectRepo = null,
|
||||
?MenuRepository $menuRepo = null,
|
||||
): ApiService {
|
||||
return new ApiService(
|
||||
$apiRepo ?? $this->make(ApiRepository::class),
|
||||
$userRepo ?? $this->make(UserRepository::class),
|
||||
$projectRepo ?? $this->make(ProjectRepository::class),
|
||||
$menuRepo ?? $this->make(MenuRepository::class),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_get_project_relation_ids_extracts_project_ids(): void
|
||||
{
|
||||
$projectRepo = $this->make(ProjectRepository::class, [
|
||||
'getUserProjectRelation' => fn () => [
|
||||
['projectId' => 5],
|
||||
['projectId' => 9],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->makeService(projectRepo: $projectRepo)->getProjectRelationIds(3);
|
||||
|
||||
$this->assertSame([5, 9], $result);
|
||||
}
|
||||
|
||||
public function test_create_api_key_with_projects_sets_relations_when_projects_selected(): void
|
||||
{
|
||||
$editCalledWith = null;
|
||||
$deleteCalled = false;
|
||||
|
||||
$userRepo = $this->make(UserRepository::class, [
|
||||
'addUser' => fn () => '77',
|
||||
]);
|
||||
$projectRepo = $this->make(ProjectRepository::class, [
|
||||
'editUserProjectRelations' => function ($id, $projects) use (&$editCalledWith) {
|
||||
$editCalledWith = [$id, $projects];
|
||||
|
||||
return true;
|
||||
},
|
||||
'deleteAllProjectRelations' => function () use (&$deleteCalled) {
|
||||
$deleteCalled = true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService(userRepo: $userRepo, projectRepo: $projectRepo)
|
||||
->createApiKeyWithProjects(['firstname' => 'Key', 'role' => '20'], ['3', '4']);
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$this->assertSame('77', $result['id']);
|
||||
// id is cast to int when reconciling relations.
|
||||
$this->assertSame([77, ['3', '4']], $editCalledWith);
|
||||
$this->assertFalse($deleteCalled);
|
||||
}
|
||||
|
||||
public function test_create_api_key_with_projects_clears_relations_when_leading_zero(): void
|
||||
{
|
||||
$editCalled = false;
|
||||
$deleteCalledWith = null;
|
||||
|
||||
$userRepo = $this->make(UserRepository::class, [
|
||||
'addUser' => fn () => '88',
|
||||
]);
|
||||
$projectRepo = $this->make(ProjectRepository::class, [
|
||||
'editUserProjectRelations' => function () use (&$editCalled) {
|
||||
$editCalled = true;
|
||||
|
||||
return true;
|
||||
},
|
||||
'deleteAllProjectRelations' => function ($id) use (&$deleteCalledWith) {
|
||||
$deleteCalledWith = $id;
|
||||
},
|
||||
]);
|
||||
|
||||
$this->makeService(userRepo: $userRepo, projectRepo: $projectRepo)
|
||||
->createApiKeyWithProjects(['firstname' => 'Key'], ['0']);
|
||||
|
||||
$this->assertFalse($editCalled);
|
||||
$this->assertSame(88, $deleteCalledWith);
|
||||
}
|
||||
|
||||
public function test_create_api_key_with_projects_skips_reconcile_when_no_projects(): void
|
||||
{
|
||||
$touched = false;
|
||||
|
||||
$userRepo = $this->make(UserRepository::class, [
|
||||
'addUser' => fn () => '5',
|
||||
]);
|
||||
$projectRepo = $this->make(ProjectRepository::class, [
|
||||
'editUserProjectRelations' => function () use (&$touched) {
|
||||
$touched = true;
|
||||
|
||||
return true;
|
||||
},
|
||||
'deleteAllProjectRelations' => function () use (&$touched) {
|
||||
$touched = true;
|
||||
},
|
||||
]);
|
||||
|
||||
// null projects and empty array both mean "do nothing".
|
||||
$this->makeService(userRepo: $userRepo, projectRepo: $projectRepo)
|
||||
->createApiKeyWithProjects(['firstname' => 'Key'], null);
|
||||
|
||||
$this->assertFalse($touched);
|
||||
}
|
||||
|
||||
public function test_create_api_key_with_projects_returns_false_when_user_not_created(): void
|
||||
{
|
||||
$userRepo = $this->make(UserRepository::class, [
|
||||
'addUser' => fn () => false,
|
||||
]);
|
||||
|
||||
$result = $this->makeService(userRepo: $userRepo)
|
||||
->createApiKeyWithProjects(['firstname' => 'Key'], ['3']);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function test_update_api_key_edits_user_and_reconciles_relations(): void
|
||||
{
|
||||
$editUserCalledWith = null;
|
||||
$editRelationsCalledWith = null;
|
||||
|
||||
$userRepo = $this->make(UserRepository::class, [
|
||||
'getUser' => fn () => [
|
||||
'firstname' => 'Old',
|
||||
'username' => 'lt_old',
|
||||
'status' => 'i',
|
||||
'role' => '10',
|
||||
],
|
||||
'editUser' => function ($values, $id) use (&$editUserCalledWith) {
|
||||
$editUserCalledWith = [$values, $id];
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
$projectRepo = $this->make(ProjectRepository::class, [
|
||||
'editUserProjectRelations' => function ($id, $projects) use (&$editRelationsCalledWith) {
|
||||
$editRelationsCalledWith = [$id, $projects];
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService(userRepo: $userRepo, projectRepo: $projectRepo)
|
||||
->updateApiKey(12, ['firstname' => 'New', 'status' => 'a', 'role' => '20'], ['7']);
|
||||
|
||||
$this->assertTrue($result);
|
||||
// Posted firstname/status/role applied, username preserved from row, source forced to 'api'.
|
||||
$this->assertSame(12, $editUserCalledWith[1]);
|
||||
$this->assertSame('New', $editUserCalledWith[0]['firstname']);
|
||||
$this->assertSame('a', $editUserCalledWith[0]['status']);
|
||||
$this->assertSame('20', $editUserCalledWith[0]['role']);
|
||||
$this->assertSame('lt_old', $editUserCalledWith[0]['user']);
|
||||
$this->assertSame('api', $editUserCalledWith[0]['source']);
|
||||
$this->assertSame([12, ['7']], $editRelationsCalledWith);
|
||||
}
|
||||
|
||||
public function test_update_api_key_falls_back_to_row_values_when_not_posted(): void
|
||||
{
|
||||
$editUserCalledWith = null;
|
||||
|
||||
$userRepo = $this->make(UserRepository::class, [
|
||||
'getUser' => fn () => [
|
||||
'firstname' => 'Old',
|
||||
'username' => 'lt_old',
|
||||
'status' => 'i',
|
||||
'role' => '10',
|
||||
],
|
||||
'editUser' => function ($values) use (&$editUserCalledWith) {
|
||||
$editUserCalledWith = $values;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
$projectRepo = $this->make(ProjectRepository::class, [
|
||||
'deleteAllProjectRelations' => fn () => null,
|
||||
]);
|
||||
|
||||
$this->makeService(userRepo: $userRepo, projectRepo: $projectRepo)
|
||||
->updateApiKey(12, [], null);
|
||||
|
||||
$this->assertSame('Old', $editUserCalledWith['firstname']);
|
||||
$this->assertSame('i', $editUserCalledWith['status']);
|
||||
$this->assertSame('10', $editUserCalledWith['role']);
|
||||
}
|
||||
|
||||
public function test_update_api_key_throws_on_invalid_id(): void
|
||||
{
|
||||
$this->expectException(\Exception::class);
|
||||
|
||||
$this->makeService()->updateApiKey(0, [], null);
|
||||
}
|
||||
}
|
||||
45
tests/Unit/app/Domain/Api/Services/I18nServiceTest.php
Normal file
45
tests/Unit/app/Domain/Api/Services/I18nServiceTest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Api\Services;
|
||||
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Domain\Api\Services\I18n as I18nService;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the I18n service that assembles the JavaScript i18n
|
||||
* dictionary payload extracted from the I18n controller.
|
||||
*/
|
||||
class I18nServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
public function test_build_js_dictionary_embeds_dictionary_and_date_overrides(): void
|
||||
{
|
||||
$language = $this->make(Language::class, [
|
||||
'ini_array' => [
|
||||
'some.key' => 'Some value',
|
||||
'language.dateformat' => 'IGNORED',
|
||||
'language.timeformat' => 'IGNORED',
|
||||
],
|
||||
'__' => fn (string $index) => $index === 'language.dateformat' ? 'm/d/Y' : 'H:i',
|
||||
]);
|
||||
|
||||
$payload = (new I18nService($language))->buildJsDictionary();
|
||||
|
||||
// The JS wrapper is present.
|
||||
$this->assertStringContainsString('leantime', $payload);
|
||||
$this->assertStringContainsString('i18n', $payload);
|
||||
$this->assertStringContainsString('dictionary:', $payload);
|
||||
|
||||
// Extract the JSON dictionary and assert the overrides won.
|
||||
preg_match('/dictionary: (\{.*\}),/', $payload, $matches);
|
||||
$this->assertNotEmpty($matches, 'Could not find dictionary JSON in payload');
|
||||
|
||||
$decoded = json_decode($matches[1], true);
|
||||
$this->assertSame('Some value', $decoded['some.key']);
|
||||
$this->assertSame('m/d/Y', $decoded['language.dateformat']);
|
||||
$this->assertSame('H:i', $decoded['language.timeformat']);
|
||||
$this->assertArrayHasKey('usersettings.timezone', $decoded);
|
||||
}
|
||||
}
|
||||
316
tests/Unit/app/Domain/Auth/Services/AuthServiceTest.php
Normal file
316
tests/Unit/app/Domain/Auth/Services/AuthServiceTest.php
Normal file
@@ -0,0 +1,316 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Auth\Services;
|
||||
|
||||
use Illuminate\Session\SessionManager;
|
||||
use Leantime\Core\Configuration\Environment as EnvironmentCore;
|
||||
use Leantime\Core\Language as LanguageCore;
|
||||
use Leantime\Domain\Auth\Repositories\AccessTokenRepository;
|
||||
use Leantime\Domain\Auth\Repositories\Auth as AuthRepository;
|
||||
use Leantime\Domain\Auth\Services\Auth as AuthService;
|
||||
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
|
||||
use Leantime\Domain\Users\Repositories\Users as UserRepository;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the pure/business logic extracted into the Auth service during
|
||||
* the thin-controller refactor (resolveSafeRedirect, shouldHideLoginForm,
|
||||
* checkPasswordStrength, resetPassword).
|
||||
*/
|
||||
class AuthServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/**
|
||||
* Builds a real Auth service with mocked dependencies. The Environment and
|
||||
* Setting repository can be overridden so config/setting driven behavior can
|
||||
* be exercised.
|
||||
*/
|
||||
private function makeService(
|
||||
?EnvironmentCore $config = null,
|
||||
?SettingRepository $settingsRepo = null,
|
||||
?AuthRepository $authRepo = null
|
||||
): AuthService {
|
||||
return new AuthService(
|
||||
$config ?? $this->make(EnvironmentCore::class),
|
||||
$this->make(SessionManager::class),
|
||||
$this->make(LanguageCore::class),
|
||||
$settingsRepo ?? $this->make(SettingRepository::class),
|
||||
$authRepo ?? $this->make(AuthRepository::class),
|
||||
$this->make(UserRepository::class),
|
||||
$this->make(AccessTokenRepository::class),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_resolve_safe_redirect_defaults_to_dashboard(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
$this->assertSame(BASE_URL.'/dashboard/home', $service->resolveSafeRedirect(null));
|
||||
$this->assertSame(BASE_URL.'/dashboard/home', $service->resolveSafeRedirect(''));
|
||||
$this->assertSame(BASE_URL.'/dashboard/home', $service->resolveSafeRedirect('/'));
|
||||
}
|
||||
|
||||
public function test_resolve_safe_redirect_allows_internal_path(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
$this->assertSame(BASE_URL.'/tickets/showAll', $service->resolveSafeRedirect('tickets/showAll'));
|
||||
}
|
||||
|
||||
public function test_resolve_safe_redirect_blocks_external_url(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
// An absolute external URL is a valid URL, so it is rejected and the
|
||||
// default dashboard target is returned instead.
|
||||
$this->assertSame(
|
||||
BASE_URL.'/dashboard/home',
|
||||
$service->resolveSafeRedirect('https://evil.example.com')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_resolve_safe_redirect_allows_same_origin_absolute_url(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
// Same-origin absolute URL — must be treated the same as a relative
|
||||
// path by stripping the BASE_URL prefix. This is the exact scenario
|
||||
// the maintainer flagged: the login form often submits a full
|
||||
// absolute URL in the redirectUrl hidden field.
|
||||
$this->assertSame(
|
||||
BASE_URL.'/dashboard/home',
|
||||
$service->resolveSafeRedirect(BASE_URL.'/dashboard/home')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_resolve_safe_redirect_allows_same_origin_absolute_url_with_deep_path(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
$this->assertSame(
|
||||
BASE_URL.'/tickets/showAll',
|
||||
$service->resolveSafeRedirect(BASE_URL.'/tickets/showAll')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_resolve_safe_redirect_allows_url_encoded_same_origin_absolute_url(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
// URL-encoded same-origin absolute URL — rawurldecode is called first,
|
||||
// then BASE_URL is stripped.
|
||||
$this->assertSame(
|
||||
BASE_URL.'/tickets/showAll',
|
||||
$service->resolveSafeRedirect(urlencode(BASE_URL.'/tickets/showAll'))
|
||||
);
|
||||
}
|
||||
|
||||
public function test_resolve_safe_redirect_rejects_external_url_disguised_with_base_url_prefix(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
// An external URL whose path happens to start with the same characters
|
||||
// as BASE_URL — str_starts_with won't match because the scheme+host
|
||||
// differ. This gets rejected as external.
|
||||
$this->assertSame(
|
||||
BASE_URL.'/dashboard/home',
|
||||
$service->resolveSafeRedirect('https://evil.example.com/'.BASE_URL.'/dashboard/home')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_resolve_safe_redirect_rejects_host_prefix_without_a_boundary(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
// The real prefix hazard: a host that merely *begins* with our host, e.g.
|
||||
// BASE_URL https://host vs https://hostile.example.com. A bare
|
||||
// str_starts_with($url, BASE_URL) strips the prefix and rewrites this into the
|
||||
// bogus internal path /ile.example.com/pwn instead of rejecting it outright.
|
||||
// Stripping only on a boundary (end, '/', '?', '#') keeps it external.
|
||||
$this->assertSame(
|
||||
BASE_URL.'/dashboard/home',
|
||||
$service->resolveSafeRedirect(BASE_URL.'ile.example.com/pwn')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_resolve_safe_redirect_returns_dashboard_for_base_url_itself(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
// Exactly BASE_URL (with and without a trailing slash) has no path to go to —
|
||||
// it must fall back to the dashboard rather than the bare app root.
|
||||
$this->assertSame(BASE_URL.'/dashboard/home', $service->resolveSafeRedirect(BASE_URL));
|
||||
$this->assertSame(BASE_URL.'/dashboard/home', $service->resolveSafeRedirect(BASE_URL.'/'));
|
||||
}
|
||||
|
||||
public function test_resolve_safe_redirect_blocks_logout_including_variants(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
// Redirecting to logout right after login is a forced-logout loop. An exact
|
||||
// string match on '/auth/logout' is walkable with a trailing slash, a query
|
||||
// string or different casing, so the normalized path is what gets compared.
|
||||
foreach ([
|
||||
'/auth/logout',
|
||||
'/auth/logout/',
|
||||
'auth/logout',
|
||||
'/auth/logout?next=/dashboard/home',
|
||||
'/auth/logout#x',
|
||||
'/AUTH/logout',
|
||||
BASE_URL.'/auth/logout',
|
||||
] as $variant) {
|
||||
$this->assertSame(
|
||||
BASE_URL.'/dashboard/home',
|
||||
$service->resolveSafeRedirect($variant),
|
||||
sprintf('logout variant "%s" must not be an accepted redirect target', $variant)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_resolve_safe_redirect_strips_control_characters(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
// Encoded CR/LF must never reach the Location header, and leading whitespace
|
||||
// must not be usable to pad a protocol-relative URL past the '//' guard.
|
||||
$this->assertSame(
|
||||
BASE_URL.'/dashboard/home',
|
||||
$service->resolveSafeRedirect('%09//evil.example.com')
|
||||
);
|
||||
$this->assertSame(
|
||||
BASE_URL.'/dashboard/home',
|
||||
$service->resolveSafeRedirect(' //evil.example.com')
|
||||
);
|
||||
$this->assertStringNotContainsString(
|
||||
"\r",
|
||||
$service->resolveSafeRedirect('tickets/showAll%0d%0aSet-Cookie:x')
|
||||
);
|
||||
$this->assertStringNotContainsString(
|
||||
"\n",
|
||||
$service->resolveSafeRedirect('tickets/showAll%0d%0aSet-Cookie:x')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_resolve_safe_redirect_preserves_plus_in_query_strings(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
// rawurldecode (not urldecode) is used precisely so a '+' in a query string
|
||||
// survives instead of silently becoming a space.
|
||||
$this->assertSame(
|
||||
BASE_URL.'/tickets/showAll?searchTerm=a+b',
|
||||
$service->resolveSafeRedirect('tickets/showAll?searchTerm=a+b')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_resolve_safe_redirect_rejects_protocol_relative_url(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
// Protocol-relative URL (//attacker.com) — FILTER_VALIDATE_URL
|
||||
// treats these as valid URLs, so they are correctly rejected
|
||||
// and the default dashboard redirect is returned.
|
||||
$this->assertSame(
|
||||
BASE_URL.'/dashboard/home',
|
||||
$service->resolveSafeRedirect('//attacker.com')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_resolve_safe_redirect_rejects_backslash_protocol_trick(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
// Backslash variant (\/\/attacker.com) — some parsers treat
|
||||
// this as a protocol-relative URL. Verify it is rejected.
|
||||
$this->assertSame(
|
||||
BASE_URL.'/dashboard/home',
|
||||
$service->resolveSafeRedirect('\/\/attacker.com')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_check_password_strength_rejects_weak_and_accepts_strong(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
$this->assertFalse($service->checkPasswordStrength('weak'));
|
||||
$this->assertFalse($service->checkPasswordStrength('alllowercase1!'));
|
||||
$this->assertFalse($service->checkPasswordStrength('NoNumber!!'));
|
||||
$this->assertFalse($service->checkPasswordStrength('NoSpecial123'));
|
||||
$this->assertFalse($service->checkPasswordStrength('Aa1!aaa')); // 7 chars
|
||||
$this->assertTrue($service->checkPasswordStrength('StrongPass1!'));
|
||||
}
|
||||
|
||||
public function test_reset_password_reports_mismatch(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
$this->assertSame('mismatch', $service->resetPassword('', '', 'hash'));
|
||||
$this->assertSame('mismatch', $service->resetPassword('StrongPass1!', 'Different1!', 'hash'));
|
||||
}
|
||||
|
||||
public function test_reset_password_reports_weak(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
$this->assertSame('weak', $service->resetPassword('weak', 'weak', 'hash'));
|
||||
}
|
||||
|
||||
public function test_reset_password_success_and_error_map_to_repository(): void
|
||||
{
|
||||
$successRepo = $this->make(AuthRepository::class, [
|
||||
'changePW' => fn () => true,
|
||||
]);
|
||||
$this->assertSame('success', $this->makeService(null, null, $successRepo)
|
||||
->resetPassword('StrongPass1!', 'StrongPass1!', 'hash'));
|
||||
|
||||
$failRepo = $this->make(AuthRepository::class, [
|
||||
'changePW' => fn () => false,
|
||||
]);
|
||||
$this->assertSame('error', $this->makeService(null, null, $failRepo)
|
||||
->resetPassword('StrongPass1!', 'StrongPass1!', 'hash'));
|
||||
}
|
||||
|
||||
public function test_should_hide_login_form_when_setting_on(): void
|
||||
{
|
||||
$settingsRepo = $this->make(SettingRepository::class, [
|
||||
'getSetting' => fn () => 'on',
|
||||
]);
|
||||
|
||||
$this->assertTrue($this->makeService(null, $settingsRepo)->shouldHideLoginForm());
|
||||
}
|
||||
|
||||
public function test_should_hide_login_form_falls_back_to_config(): void
|
||||
{
|
||||
$config = new EnvironmentCore;
|
||||
$config->set('disableLoginForm', true);
|
||||
|
||||
$settingsRepo = $this->make(SettingRepository::class, [
|
||||
'getSetting' => fn () => false,
|
||||
]);
|
||||
|
||||
$this->assertTrue($this->makeService($config, $settingsRepo)->shouldHideLoginForm());
|
||||
|
||||
$config2 = new EnvironmentCore;
|
||||
$config2->set('disableLoginForm', false);
|
||||
|
||||
$this->assertFalse($this->makeService($config2, $settingsRepo)->shouldHideLoginForm());
|
||||
}
|
||||
|
||||
public function test_login_input_placeholder_depends_on_ldap(): void
|
||||
{
|
||||
$ldapConfig = new EnvironmentCore;
|
||||
$ldapConfig->set('useLdap', true);
|
||||
$this->assertSame(
|
||||
'input.placeholders.enter_email_or_username',
|
||||
$this->makeService($ldapConfig)->getLoginInputPlaceholder()
|
||||
);
|
||||
|
||||
$noLdapConfig = new EnvironmentCore;
|
||||
$noLdapConfig->set('useLdap', false);
|
||||
$this->assertSame(
|
||||
'input.placeholders.enter_email',
|
||||
$this->makeService($noLdapConfig)->getLoginInputPlaceholder()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Auth\Services;
|
||||
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
use Leantime\Domain\Auth\Services\Auth;
|
||||
use Leantime\Domain\Auth\Services\AuthUser;
|
||||
|
||||
/**
|
||||
* Regression guard for the 3.9.x Bearer-auth role bug.
|
||||
*
|
||||
* AuthUser is the userdata builder on the Sanctum (Bearer) guard path: AccessToken::findToken ->
|
||||
* AuthUser::setUser -> setUserSession. It stored the RAW DB role int ("50") in session('userdata'),
|
||||
* while the permission engine's Auth::getRoleToCheck() validates the session role against
|
||||
* Roles::getRoles() (the role-NAME list). So "50" resolved to false and the engine denied every
|
||||
* #[RequiresPermission] @api method with -32001 — for every Bearer/Sanctum integrator, on any
|
||||
* server that exposes the Authorization header (production). CI missed it because its Apache hid
|
||||
* the header, routing Bearer through the fallback path (which builds userdata via
|
||||
* Api::setApiUserSession, and that one DOES convert the role).
|
||||
*
|
||||
* The fix: AuthUser::setUserSession must store the role NAME string, matching the other two
|
||||
* userdata builders. This asserts the resulting session role is engine-valid for every built-in
|
||||
* role — it FAILS on the raw-int bug and PASSES on the fix, independent of web server config.
|
||||
*/
|
||||
class AuthUserSessionRoleTest extends \Unit\TestCase
|
||||
{
|
||||
private function userRow(int $role): array
|
||||
{
|
||||
return [
|
||||
'id' => 1,
|
||||
'firstname' => 'Test',
|
||||
'username' => 'test@leantime.io',
|
||||
'profileId' => 0,
|
||||
'clientId' => 0,
|
||||
'role' => $role,
|
||||
'settings' => '',
|
||||
'twoFAEnabled' => false,
|
||||
'twoFASecret' => '',
|
||||
'createdOn' => '2026-01-01 00:00:00',
|
||||
'modified' => '2026-01-01 00:00:00',
|
||||
];
|
||||
}
|
||||
|
||||
public function test_sanctum_guard_session_role_is_engine_valid_for_every_builtin_role(): void
|
||||
{
|
||||
// setUserSession touches no instance state, so a constructor-less instance avoids the DB.
|
||||
$authUser = (new \ReflectionClass(AuthUser::class))->newInstanceWithoutConstructor();
|
||||
$setUserSession = new \ReflectionMethod(AuthUser::class, 'setUserSession');
|
||||
$setUserSession->setAccessible(true);
|
||||
|
||||
foreach (array_keys(Roles::getRoles()) as $roleInt) {
|
||||
session()->forget('userdata');
|
||||
|
||||
$setUserSession->invoke($authUser, $this->userRow((int) $roleInt));
|
||||
|
||||
$sessionRole = session('userdata.role');
|
||||
|
||||
$this->assertContains(
|
||||
$sessionRole,
|
||||
Roles::getRoles(),
|
||||
"AuthUser stored an engine-invalid role for DB int $roleInt: ".var_export($sessionRole, true)
|
||||
);
|
||||
$this->assertNotFalse(
|
||||
Auth::getRoleToCheck(false),
|
||||
"getRoleToCheck() rejected the Sanctum-guard session role for DB int $roleInt"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
161
tests/Unit/app/Domain/Auth/Services/OnboardingServiceTest.php
Normal file
161
tests/Unit/app/Domain/Auth/Services/OnboardingServiceTest.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Auth\Services;
|
||||
|
||||
use Leantime\Core\Language as LanguageCore;
|
||||
use Leantime\Core\UI\Theme;
|
||||
use Leantime\Domain\Auth\Services\Auth as AuthService;
|
||||
use Leantime\Domain\Auth\Services\Onboarding as OnboardingService;
|
||||
use Leantime\Domain\Setting\Services\Setting as SettingService;
|
||||
use Leantime\Domain\Users\Services\Users as UserService;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the onboarding/invite business logic extracted from the
|
||||
* UserInvite controller during the thin-controller refactor.
|
||||
*/
|
||||
class OnboardingServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/**
|
||||
* Builds a real Onboarding service with mocked dependencies.
|
||||
*/
|
||||
private function makeService(
|
||||
?UserService $userService = null,
|
||||
?SettingService $settingService = null,
|
||||
?Theme $theme = null,
|
||||
?AuthService $authService = null
|
||||
): OnboardingService {
|
||||
return new OnboardingService(
|
||||
$authService ?? $this->make(AuthService::class),
|
||||
$userService ?? $this->make(UserService::class),
|
||||
$settingService ?? $this->make(SettingService::class),
|
||||
$theme ?? $this->make(Theme::class),
|
||||
$this->make(LanguageCore::class),
|
||||
);
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
session()->forget('tempPassword');
|
||||
}
|
||||
|
||||
public function test_save_account_rejects_weak_password(): void
|
||||
{
|
||||
$userService = $this->make(UserService::class, [
|
||||
'checkPasswordStrength' => fn () => false,
|
||||
'editUser' => function () {
|
||||
$this->fail('editUser must not be called for a weak password');
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService($userService)->saveAccount(
|
||||
['id' => 5, 'username' => 'jane@example.com'],
|
||||
'Jane Doe',
|
||||
'Engineer',
|
||||
'weak'
|
||||
);
|
||||
|
||||
$this->assertSame('weak', $result);
|
||||
$this->assertNull(session('tempPassword'));
|
||||
}
|
||||
|
||||
public function test_save_account_splits_name_and_persists(): void
|
||||
{
|
||||
$captured = null;
|
||||
$userService = $this->make(UserService::class, [
|
||||
'checkPasswordStrength' => fn () => true,
|
||||
'editUser' => function ($values, $id) use (&$captured) {
|
||||
$captured = ['values' => $values, 'id' => $id];
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService($userService)->saveAccount(
|
||||
['id' => 5, 'username' => 'jane@example.com'],
|
||||
'Jane Doe',
|
||||
'Engineer',
|
||||
'StrongPass1!'
|
||||
);
|
||||
|
||||
$this->assertSame('saved', $result);
|
||||
$this->assertSame(5, $captured['id']);
|
||||
$this->assertSame('Jane', $captured['values']['firstname']);
|
||||
$this->assertSame('Doe', $captured['values']['lastname']);
|
||||
$this->assertSame('Engineer', $captured['values']['jobTitle']);
|
||||
$this->assertSame('i', $captured['values']['status']);
|
||||
$this->assertSame('jane@example.com', $captured['values']['user']);
|
||||
$this->assertSame('StrongPass1!', $captured['values']['password']);
|
||||
// Temp password is stored so the user can be auto-logged-in later.
|
||||
$this->assertSame('StrongPass1!', session('tempPassword'));
|
||||
}
|
||||
|
||||
public function test_save_account_handles_single_word_name(): void
|
||||
{
|
||||
$captured = null;
|
||||
$userService = $this->make(UserService::class, [
|
||||
'checkPasswordStrength' => fn () => true,
|
||||
'editUser' => function ($values) use (&$captured) {
|
||||
$captured = $values;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$this->makeService($userService)->saveAccount(
|
||||
['id' => 9, 'username' => 'mono@example.com'],
|
||||
'Cher',
|
||||
'',
|
||||
'StrongPass1!'
|
||||
);
|
||||
|
||||
$this->assertSame('Cher', $captured['firstname']);
|
||||
$this->assertSame('', $captured['lastname']);
|
||||
}
|
||||
|
||||
public function test_save_account_reports_error_when_persist_fails(): void
|
||||
{
|
||||
$userService = $this->make(UserService::class, [
|
||||
'checkPasswordStrength' => fn () => true,
|
||||
'editUser' => fn () => false,
|
||||
]);
|
||||
|
||||
$result = $this->makeService($userService)->saveAccount(
|
||||
['id' => 5, 'username' => 'jane@example.com'],
|
||||
'Jane Doe',
|
||||
'Engineer',
|
||||
'StrongPass1!'
|
||||
);
|
||||
|
||||
$this->assertSame('error', $result);
|
||||
}
|
||||
|
||||
public function test_get_invite_settings_applies_defaults_when_unset(): void
|
||||
{
|
||||
$settingService = $this->make(SettingService::class, [
|
||||
'getSetting' => fn () => false,
|
||||
]);
|
||||
|
||||
$theme = $this->make(Theme::class, [
|
||||
'getAvailableColorSchemes' => fn () => ['companyColors'],
|
||||
'getAvailableFonts' => fn () => ['Roboto'],
|
||||
'getAll' => fn () => ['default'],
|
||||
]);
|
||||
|
||||
$settings = $this->makeService(null, $settingService, $theme)
|
||||
->getInviteSettings(['id' => 7]);
|
||||
|
||||
$this->assertSame('default', $settings['userTheme']);
|
||||
$this->assertSame('light', $settings['userColorMode']);
|
||||
$this->assertSame('companyColors', $settings['userColorScheme']);
|
||||
$this->assertSame('Roboto', $settings['themeFont']);
|
||||
$this->assertSame($this->makeService()->getDefaultWorkdays(), $settings['workdays']);
|
||||
$this->assertSame($this->makeService()->getDefaultDaySchedule(), $settings['daySchedule']);
|
||||
$this->assertArrayHasKey('dayHourOptions', $settings);
|
||||
$this->assertArrayHasKey('dateTimeValues', $settings);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Auth\Services;
|
||||
|
||||
use Leantime\Domain\Api\Services\Api;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
use Leantime\Domain\Auth\Services\AuthUser;
|
||||
use Leantime\Domain\Auth\Services\UserSessionBuilder;
|
||||
|
||||
/**
|
||||
* Guards the userdata-builder bug family (3.9.x Bearer regression + the twoFAVerified twin).
|
||||
*
|
||||
* Every auth path now builds session('userdata') through UserSessionBuilder, so a field can't
|
||||
* silently drift between paths. These tests pin the two invariants that historically broke:
|
||||
* - role is ALWAYS the engine-valid NAME string (never the raw DB int), for every built-in role;
|
||||
* - the two token paths (Sanctum/Bearer via AuthUser, x-api-key via Api) agree on role +
|
||||
* twoFAVerified.
|
||||
*/
|
||||
class UserSessionBuilderTest extends \Unit\TestCase
|
||||
{
|
||||
private function userRow(int $role): array
|
||||
{
|
||||
return [
|
||||
'id' => 1,
|
||||
'firstname' => 'Test',
|
||||
'username' => 'test@leantime.io',
|
||||
'profileId' => 0,
|
||||
'clientId' => 0,
|
||||
'role' => $role,
|
||||
'settings' => '',
|
||||
'twoFAEnabled' => false,
|
||||
'twoFASecret' => '',
|
||||
'createdOn' => '2026-01-01 00:00:00',
|
||||
'modified' => '2026-01-01 00:00:00',
|
||||
];
|
||||
}
|
||||
|
||||
public function test_role_is_engine_valid_name_string_for_every_builtin_role(): void
|
||||
{
|
||||
foreach (array_keys(Roles::getRoles()) as $roleInt) {
|
||||
$userdata = UserSessionBuilder::build($this->userRow((int) $roleInt));
|
||||
|
||||
$this->assertSame(Roles::getRoleString((int) $roleInt), $userdata['role']);
|
||||
$this->assertContains(
|
||||
$userdata['role'],
|
||||
Roles::getRoles(),
|
||||
"Factory produced an engine-invalid role for DB int $roleInt: ".var_export($userdata['role'], true)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_flags_are_honored(): void
|
||||
{
|
||||
$tokenSession = UserSessionBuilder::build($this->userRow(50), isExternalAuth: true, twoFAVerified: true);
|
||||
$this->assertTrue($tokenSession['isExternalAuth']);
|
||||
$this->assertTrue($tokenSession['twoFAVerified']);
|
||||
|
||||
$default = UserSessionBuilder::build($this->userRow(50));
|
||||
$this->assertFalse($default['isExternalAuth']);
|
||||
$this->assertFalse($default['twoFAVerified']);
|
||||
}
|
||||
|
||||
public function test_both_token_paths_build_consistent_role_and_twofa(): void
|
||||
{
|
||||
// The Sanctum/Bearer path (AuthUser) and the x-api-key path (Api) are both token auth and
|
||||
// must produce the same role + twoFAVerified — these are the exact two fields that drifted.
|
||||
// setUserSession/setApiUserSession touch no instance state, so construct without the DB.
|
||||
$row = $this->userRow(50);
|
||||
|
||||
session()->forget('userdata');
|
||||
$authUser = (new \ReflectionClass(AuthUser::class))->newInstanceWithoutConstructor();
|
||||
$m = new \ReflectionMethod(AuthUser::class, 'setUserSession');
|
||||
$m->setAccessible(true);
|
||||
$m->invoke($authUser, $row);
|
||||
$guardSession = session('userdata');
|
||||
|
||||
session()->forget('userdata');
|
||||
$api = (new \ReflectionClass(Api::class))->newInstanceWithoutConstructor();
|
||||
$api->setApiUserSession($row, false);
|
||||
$apiKeySession = session('userdata');
|
||||
|
||||
$this->assertSame($guardSession['role'], $apiKeySession['role'], 'token paths disagree on role');
|
||||
$this->assertSame($guardSession['twoFAVerified'], $apiKeySession['twoFAVerified'], 'token paths disagree on twoFAVerified');
|
||||
$this->assertContains($guardSession['role'], Roles::getRoles());
|
||||
$this->assertTrue($guardSession['twoFAVerified'], 'token sessions should be 2FA-verified');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Blueprints\Controllers;
|
||||
|
||||
use Illuminate\Routing\RouteDependencyResolverTrait;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use ReflectionMethod;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Regression coverage for canvas modal actions 404ing (PR #3544).
|
||||
*
|
||||
* The Blueprints controllers are routed under `blueprints/{canvasSlug}/{action}/{id?}`.
|
||||
* Their action methods were declared `get(?string $id = null)` — omitting the
|
||||
* `{canvasSlug}` segment — so Laravel bound the FIRST route param (`canvasSlug`) to
|
||||
* the first method param (`$id`). `$id` then held the slug ("swot"), the real id was
|
||||
* dropped, and EditCanvasItem/EditCanvasComment rendered the `errors.error404` partial
|
||||
* (at HTTP 200) for every add/edit. The fix declares `$canvasSlug` first so `$id`
|
||||
* binds to the real id. These tests assert that binding via the real route table —
|
||||
* no DB/session/browser needed.
|
||||
*/
|
||||
class CanvasRouteBindingTest extends TestCase
|
||||
{
|
||||
/** Routed Blueprints actions that take an {id?} segment, by action name. */
|
||||
public static function canvasActionProvider(): array
|
||||
{
|
||||
return [
|
||||
'showCanvas' => ['showCanvas'],
|
||||
'editCanvasItem' => ['editCanvasItem'],
|
||||
'editCanvasComment' => ['editCanvasComment'],
|
||||
'boardDialog' => ['boardDialog'],
|
||||
'delCanvas' => ['delCanvas'],
|
||||
'delCanvasItem' => ['delCanvasItem'],
|
||||
'export' => ['export'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the real Blueprints routes into the current app's router. Uses the
|
||||
* actual routes.php (not a hand-rolled copy) so the test tracks the real
|
||||
* definitions. Re-required per test because each test gets a fresh app/router.
|
||||
*/
|
||||
private function matchRoute(string $uri): \Illuminate\Routing\Route
|
||||
{
|
||||
require APP_ROOT.'/app/Domain/Blueprints/routes.php';
|
||||
|
||||
$request = IncomingRequest::create($uri, 'GET');
|
||||
$route = $this->app->make('router')->getRoutes()->match($request);
|
||||
$request->setRouteResolver(fn () => $route);
|
||||
|
||||
return $route;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the arguments the controller action actually RECEIVES for $route, modelling
|
||||
* exactly what Illuminate\Routing\ControllerDispatcher::dispatch() does:
|
||||
*
|
||||
* $controller->{$method}(...array_values($resolvedParameters));
|
||||
*
|
||||
* The values are spread POSITIONALLY, so what matters is each method parameter's
|
||||
* position, not the route key. This is the layer the bug lived in: with
|
||||
* `get(?string $id)` the slug (first positional value) landed in `$id`. Returns a
|
||||
* [paramName => boundValue] map.
|
||||
*/
|
||||
private function actionReceives(\Illuminate\Routing\Route $route): array
|
||||
{
|
||||
$resolver = new class($this->app)
|
||||
{
|
||||
use RouteDependencyResolverTrait;
|
||||
|
||||
public function __construct(public $container) {}
|
||||
|
||||
public function resolve($route): array
|
||||
{
|
||||
return $this->resolveClassMethodDependencies(
|
||||
$route->parametersWithoutNulls(),
|
||||
$route->getControllerClass(),
|
||||
$route->getActionMethod(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
$positional = array_values($resolver->resolve($route));
|
||||
$params = (new ReflectionMethod($route->getControllerClass(), $route->getActionMethod()))->getParameters();
|
||||
|
||||
$bound = [];
|
||||
foreach ($params as $i => $param) {
|
||||
$bound[$param->getName()] = $positional[$i] ?? null;
|
||||
}
|
||||
|
||||
return $bound;
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider canvasActionProvider
|
||||
*/
|
||||
public function test_route_binds_real_id_not_canvas_slug(string $action): void
|
||||
{
|
||||
$received = $this->actionReceives($this->matchRoute("/blueprints/swot/{$action}/42"));
|
||||
|
||||
$this->assertSame('swot', $received['canvasSlug'] ?? null, "{$action}: \$canvasSlug must receive the slug");
|
||||
$this->assertSame('42', $received['id'] ?? null, "{$action}: \$id must receive the route id, not the slug");
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider canvasActionProvider
|
||||
*/
|
||||
public function test_missing_id_does_not_leak_slug_into_id(string $action): void
|
||||
{
|
||||
$received = $this->actionReceives($this->matchRoute("/blueprints/swot/{$action}"));
|
||||
|
||||
$this->assertSame('swot', $received['canvasSlug'] ?? null, "{$action}: \$canvasSlug must receive the slug");
|
||||
$this->assertNull($received['id'] ?? null, "{$action}: omitted {id?} must not leak the slug into \$id");
|
||||
}
|
||||
|
||||
/**
|
||||
* The structural invariant behind the fix: any Blueprints action routed under the
|
||||
* {canvasSlug} prefix must declare `canvasSlug` as its first parameter, so route
|
||||
* params line up with method params. Guards against re-introducing the bug on a
|
||||
* new action.
|
||||
*
|
||||
* @dataProvider canvasActionProvider
|
||||
*/
|
||||
public function test_action_declares_canvas_slug_as_first_parameter(string $action): void
|
||||
{
|
||||
$route = $this->matchRoute("/blueprints/swot/{$action}");
|
||||
|
||||
$params = (new ReflectionMethod($route->getControllerClass(), $route->getActionMethod()))->getParameters();
|
||||
|
||||
$this->assertNotEmpty($params, "{$action}: action must declare parameters");
|
||||
$this->assertSame('canvasSlug', $params[0]->getName(), "{$action}: first parameter must be \$canvasSlug");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Blueprints\Models;
|
||||
|
||||
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Phase 4 of the content-templates rollout: blueprint YAMLs gain an
|
||||
* optional `startContent:` field that references a ContentTemplates key.
|
||||
* This locks in the field's parse rules.
|
||||
*/
|
||||
class CanvasTemplateStartContentTest extends TestCase
|
||||
{
|
||||
public function test_start_content_is_null_when_absent(): void
|
||||
{
|
||||
$tpl = new CanvasTemplate([
|
||||
'slug' => 'swot',
|
||||
'icon' => 'fa-x',
|
||||
'boxes' => [],
|
||||
]);
|
||||
|
||||
$this->assertNull($tpl->startContent);
|
||||
}
|
||||
|
||||
public function test_start_content_is_null_when_empty_string(): void
|
||||
{
|
||||
$tpl = new CanvasTemplate([
|
||||
'slug' => 'swot',
|
||||
'icon' => 'fa-x',
|
||||
'boxes' => [],
|
||||
'startContent' => '',
|
||||
]);
|
||||
|
||||
$this->assertNull($tpl->startContent);
|
||||
}
|
||||
|
||||
public function test_start_content_carries_through_when_set(): void
|
||||
{
|
||||
$tpl = new CanvasTemplate([
|
||||
'slug' => 'leancanvas',
|
||||
'icon' => 'fa-x',
|
||||
'boxes' => [],
|
||||
'startContent' => 'lean-starter-saas',
|
||||
]);
|
||||
|
||||
$this->assertSame('lean-starter-saas', $tpl->startContent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Blueprints\Models;
|
||||
|
||||
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the CanvasTemplate value object: identifier derivation and the
|
||||
* label-resolution rules (omitted/"default"/null fall back to base defaults,
|
||||
* an explicit empty array means "no labels", an explicit array is used as-is).
|
||||
*/
|
||||
class CanvasTemplateTest extends TestCase
|
||||
{
|
||||
public function test_derives_database_type_comment_module_and_session_key(): void
|
||||
{
|
||||
$template = new CanvasTemplate(['slug' => 'swot']);
|
||||
|
||||
$this->assertSame('swotcanvas', $template->getDatabaseType());
|
||||
$this->assertSame('swotcanvasitem', $template->getCommentModule());
|
||||
$this->assertSame('currentSWOTCanvas', $template->getSessionKey());
|
||||
}
|
||||
|
||||
public function test_applies_scalar_defaults_when_not_provided(): void
|
||||
{
|
||||
$template = new CanvasTemplate(['slug' => 'x']);
|
||||
|
||||
$this->assertSame('fa-x', $template->icon);
|
||||
$this->assertSame('', $template->disclaimer);
|
||||
$this->assertSame(2, $template->minColumns);
|
||||
$this->assertSame(0, $template->minWidthOffset);
|
||||
$this->assertSame([], $template->boxes);
|
||||
$this->assertSame([], $template->layout);
|
||||
}
|
||||
|
||||
public function test_omitted_status_labels_fall_back_to_defaults(): void
|
||||
{
|
||||
$template = new CanvasTemplate(['slug' => 'x']);
|
||||
|
||||
$this->assertArrayHasKey('status_draft', $template->statusLabels);
|
||||
$this->assertArrayHasKey('status_valid', $template->statusLabels);
|
||||
$this->assertArrayHasKey('relates_none', $template->relatesLabels);
|
||||
}
|
||||
|
||||
public function test_default_keyword_falls_back_to_defaults(): void
|
||||
{
|
||||
$template = new CanvasTemplate(['slug' => 'x', 'statusLabels' => 'default', 'relatesLabels' => 'default']);
|
||||
|
||||
$this->assertArrayHasKey('status_draft', $template->statusLabels);
|
||||
$this->assertArrayHasKey('relates_customers', $template->relatesLabels);
|
||||
}
|
||||
|
||||
public function test_explicit_empty_array_means_no_labels(): void
|
||||
{
|
||||
// This is the SWOT case: statusLabels: {} (hide the status dropdown).
|
||||
$template = new CanvasTemplate(['slug' => 'swot', 'statusLabels' => []]);
|
||||
|
||||
$this->assertSame([], $template->statusLabels);
|
||||
// relatesLabels was omitted, so it still gets the defaults.
|
||||
$this->assertArrayHasKey('relates_none', $template->relatesLabels);
|
||||
}
|
||||
|
||||
public function test_explicit_labels_are_used_as_is(): void
|
||||
{
|
||||
$custom = [
|
||||
'status_observation' => ['icon' => 'fa-eye', 'color' => 'blue', 'title' => 'status.ea.observation', 'dropdown' => 'info', 'active' => true],
|
||||
];
|
||||
|
||||
$template = new CanvasTemplate(['slug' => 'ea', 'statusLabels' => $custom]);
|
||||
|
||||
$this->assertSame($custom, $template->statusLabels);
|
||||
$this->assertArrayNotHasKey('status_draft', $template->statusLabels);
|
||||
}
|
||||
|
||||
public function test_data_labels_default_and_override(): void
|
||||
{
|
||||
$defaulted = new CanvasTemplate(['slug' => 'x']);
|
||||
$this->assertArrayHasKey(1, $defaulted->dataLabels);
|
||||
$this->assertSame('assumptions', $defaulted->dataLabels[1]['field']);
|
||||
|
||||
$custom = [1 => ['title' => 'label.description', 'field' => 'conclusion', 'active' => true]];
|
||||
$overridden = new CanvasTemplate(['slug' => 'swot', 'dataLabels' => $custom]);
|
||||
$this->assertSame($custom, $overridden->dataLabels);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Blueprints\Services;
|
||||
|
||||
use Leantime\Domain\Blueprints\Services\Blueprints as BlueprintsService;
|
||||
use Leantime\Domain\Blueprints\Services\BlueprintsExport;
|
||||
use Leantime\Domain\Blueprints\Services\TemplateRegistry;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the BlueprintsExport service (XML generation).
|
||||
*
|
||||
* exportToXml reads the board + items through the Blueprints SERVICE (getBoard / getBoardItems),
|
||||
* which authorizes VIEW against the board's real project and returns false / [] for a
|
||||
* missing/foreign/unauthorized board — so these stub the service, not the repository.
|
||||
*/
|
||||
class BlueprintsExportTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
public function test_exports_a_canvas_board_to_xml(): void
|
||||
{
|
||||
$service = $this->make(BlueprintsService::class, [
|
||||
'getBoard' => fn () => [['title' => 'My SWOT', 'projectId' => 1]],
|
||||
'getBoardItems' => fn () => [
|
||||
[
|
||||
'box' => 'swot_strengths', 'description' => 'Strong brand', 'author' => 5,
|
||||
'status' => '', 'relates' => '', 'assumptions' => '', 'data' => '', 'conclusion' => '',
|
||||
'created' => '2026-01-01 00:00:00', 'modified' => '2026-01-02 00:00:00',
|
||||
'authorFirstname' => 'Jo', 'authorLastname' => 'Doe',
|
||||
],
|
||||
],
|
||||
'getTranslatedBoxes' => fn () => [
|
||||
'swot_strengths' => ['title' => 'Strengths'],
|
||||
'swot_weaknesses' => ['title' => 'Weaknesses'],
|
||||
],
|
||||
]);
|
||||
|
||||
$xml = (new BlueprintsExport($service, new TemplateRegistry))->exportToXml(7, 'swot');
|
||||
|
||||
$this->assertNotNull($xml);
|
||||
$this->assertStringContainsString('<canvas key="swotcanvas">', $xml);
|
||||
$this->assertStringContainsString('<title>My SWOT</title>', $xml);
|
||||
$this->assertStringContainsString('<element key="swot_strengths">', $xml);
|
||||
$this->assertStringContainsString('<description>Strong brand</description>', $xml);
|
||||
// An empty box still emits its element wrapper.
|
||||
$this->assertStringContainsString('<element key="swot_weaknesses">', $xml);
|
||||
}
|
||||
|
||||
public function test_returns_null_for_unknown_canvas_type(): void
|
||||
{
|
||||
$export = new BlueprintsExport(
|
||||
$this->make(BlueprintsService::class),
|
||||
new TemplateRegistry,
|
||||
);
|
||||
|
||||
$this->assertNull($export->exportToXml(7, 'doesnotexist'));
|
||||
}
|
||||
|
||||
public function test_returns_null_when_board_does_not_exist(): void
|
||||
{
|
||||
// getBoard returns false for a missing/foreign/unauthorized board.
|
||||
$service = $this->make(BlueprintsService::class, ['getBoard' => fn () => false]);
|
||||
|
||||
$export = new BlueprintsExport($service, new TemplateRegistry);
|
||||
|
||||
$this->assertNull($export->exportToXml(7, 'swot'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,822 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Blueprints\Services;
|
||||
|
||||
use Codeception\Test\Feature\Stub;
|
||||
use Leantime\Core\Auth\Permissions\PermissionService;
|
||||
use Leantime\Core\Exceptions\AuthorizationException;
|
||||
use Leantime\Core\Language as LanguageCore;
|
||||
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
|
||||
use Leantime\Domain\Blueprints\Repositories\Blueprints as BlueprintsRepository;
|
||||
use Leantime\Domain\Blueprints\Services\Blueprints as BlueprintsService;
|
||||
use Leantime\Domain\Blueprints\Services\TemplateRegistry;
|
||||
use Leantime\Domain\ContentTemplates\Models\ContentTemplate;
|
||||
use Leantime\Domain\ContentTemplates\Services\ContentTemplateRegistry;
|
||||
use Leantime\Domain\Users\Repositories\Users as UserRepository;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the Blueprints service: label translation helpers and the
|
||||
* board-progress calculation (filled boxes / total boxes, max across boards).
|
||||
*/
|
||||
class BlueprintsServiceTest extends TestCase
|
||||
{
|
||||
use Stub;
|
||||
|
||||
/**
|
||||
* Build the service with a language stub that prefixes keys with "T:" so we
|
||||
* can assert translation happened, plus optional repo/registry overrides.
|
||||
*/
|
||||
private function service(?BlueprintsRepository $repo = null, ?TemplateRegistry $registry = null): BlueprintsService
|
||||
{
|
||||
$language = $this->make(LanguageCore::class, ['__' => fn (string $index) => 'T:'.$index]);
|
||||
|
||||
return new BlueprintsService(
|
||||
$repo ?? $this->make(BlueprintsRepository::class),
|
||||
$registry ?? new TemplateRegistry,
|
||||
$language,
|
||||
new ContentTemplateRegistry,
|
||||
);
|
||||
}
|
||||
|
||||
public function test_translated_boxes_run_titles_through_language(): void
|
||||
{
|
||||
$template = new CanvasTemplate([
|
||||
'slug' => 'swot',
|
||||
'boxes' => ['swot_strengths' => ['icon' => 'fa-x', 'title' => 'box.swot.strengths']],
|
||||
]);
|
||||
|
||||
$boxes = $this->service()->getTranslatedBoxes($template);
|
||||
|
||||
$this->assertSame('T:box.swot.strengths', $boxes['swot_strengths']['title']);
|
||||
$this->assertSame('fa-x', $boxes['swot_strengths']['icon']);
|
||||
}
|
||||
|
||||
public function test_translates_status_relates_and_data_labels(): void
|
||||
{
|
||||
$service = $this->service();
|
||||
$template = new CanvasTemplate(['slug' => 'x']); // base defaults
|
||||
|
||||
$this->assertSame('T:status.draft', $service->getTranslatedStatusLabels($template)['status_draft']['title']);
|
||||
$this->assertSame('T:relates.none', $service->getTranslatedRelatesLabels($template)['relates_none']['title']);
|
||||
$this->assertSame('T:label.assumptions', $service->getTranslatedDataLabels($template)[1]['title']);
|
||||
}
|
||||
|
||||
public function test_disclaimer_is_empty_when_unset_and_translated_otherwise(): void
|
||||
{
|
||||
$service = $this->service();
|
||||
|
||||
$this->assertSame('', $service->getTranslatedDisclaimer(new CanvasTemplate(['slug' => 'x'])));
|
||||
$this->assertSame(
|
||||
'T:text.lean.disclaimer',
|
||||
$service->getTranslatedDisclaimer(new CanvasTemplate(['slug' => 'lean', 'disclaimer' => 'text.lean.disclaimer']))
|
||||
);
|
||||
}
|
||||
|
||||
public function test_board_progress_is_fraction_of_filled_boxes(): void
|
||||
{
|
||||
// SWOT has 4 boxes; board 1 has 2 boxes with items -> 0.5.
|
||||
$repo = $this->make(BlueprintsRepository::class, [
|
||||
'getCanvasProgressCount' => fn () => [
|
||||
['canvasType' => 'swotcanvas', 'canvasId' => 1, 'box' => 'swot_strengths', 'boxItems' => 3],
|
||||
['canvasType' => 'swotcanvas', 'canvasId' => 1, 'box' => 'swot_threats', 'boxItems' => 1],
|
||||
['canvasType' => 'swotcanvas', 'canvasId' => 1, 'box' => 'swot_weaknesses', 'boxItems' => 0],
|
||||
],
|
||||
]);
|
||||
|
||||
$progress = $this->service($repo)->getBoardProgress('1', ['swotcanvas']);
|
||||
|
||||
$this->assertEqualsWithDelta(0.5, $progress['swotcanvas'], 0.001);
|
||||
}
|
||||
|
||||
public function test_board_progress_takes_max_across_boards(): void
|
||||
{
|
||||
// Board 2 has all 4 SWOT boxes filled -> max progress 1.0.
|
||||
$repo = $this->make(BlueprintsRepository::class, [
|
||||
'getCanvasProgressCount' => fn () => [
|
||||
['canvasType' => 'swotcanvas', 'canvasId' => 1, 'box' => 'swot_strengths', 'boxItems' => 1],
|
||||
['canvasType' => 'swotcanvas', 'canvasId' => 2, 'box' => 'swot_strengths', 'boxItems' => 1],
|
||||
['canvasType' => 'swotcanvas', 'canvasId' => 2, 'box' => 'swot_weaknesses', 'boxItems' => 1],
|
||||
['canvasType' => 'swotcanvas', 'canvasId' => 2, 'box' => 'swot_opportunities', 'boxItems' => 1],
|
||||
['canvasType' => 'swotcanvas', 'canvasId' => 2, 'box' => 'swot_threats', 'boxItems' => 1],
|
||||
],
|
||||
]);
|
||||
|
||||
$progress = $this->service($repo)->getBoardProgress('1', ['swotcanvas']);
|
||||
|
||||
$this->assertEqualsWithDelta(1.0, $progress['swotcanvas'], 0.001);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Boards overview (absorbed from the former Strategy service).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public function test_build_recent_progress_seeds_metadata_and_removes_used_type(): void
|
||||
{
|
||||
$service = $this->service();
|
||||
|
||||
$metadata = $service->getBoardMetadata();
|
||||
|
||||
$recentlyUpdated = [
|
||||
['type' => 'valuecanvas', 'title' => 'My Value Board', 'modified' => '2026-05-20 10:00:00', 'id' => 11],
|
||||
];
|
||||
|
||||
$result = $service->buildRecentProgressCanvas($recentlyUpdated, $metadata);
|
||||
|
||||
$this->assertArrayHasKey('valuecanvas', $result);
|
||||
$this->assertSame(1, $result['valuecanvas']['count']);
|
||||
$this->assertSame('My Value Board', $result['valuecanvas']['lastTitle']);
|
||||
$this->assertSame('2026-05-20 10:00:00', $result['valuecanvas']['lastUpdate']);
|
||||
$this->assertSame(11, $result['valuecanvas']['lastCanvasId']);
|
||||
// Board links point at the consolidated Blueprints routes.
|
||||
$this->assertSame('blueprints/value', $result['valuecanvas']['module']);
|
||||
|
||||
// The consumed type must be removed from the remaining "other" boards map.
|
||||
$this->assertArrayNotHasKey('valuecanvas', $metadata);
|
||||
$this->assertArrayHasKey('swotcanvas', $metadata);
|
||||
}
|
||||
|
||||
public function test_build_recent_progress_increments_count_for_repeat_type(): void
|
||||
{
|
||||
$service = $this->service();
|
||||
|
||||
$metadata = $service->getBoardMetadata();
|
||||
|
||||
$recentlyUpdated = [
|
||||
['type' => 'swotcanvas', 'title' => 'First', 'modified' => '2026-05-21 09:00:00', 'id' => 1],
|
||||
['type' => 'swotcanvas', 'title' => 'Second', 'modified' => '2026-05-22 09:00:00', 'id' => 2],
|
||||
['type' => 'swotcanvas', 'title' => 'Third', 'modified' => '2026-05-23 09:00:00', 'id' => 3],
|
||||
];
|
||||
|
||||
$result = $service->buildRecentProgressCanvas($recentlyUpdated, $metadata);
|
||||
|
||||
$this->assertSame(3, $result['swotcanvas']['count']);
|
||||
// The seeded values come from the FIRST occurrence only.
|
||||
$this->assertSame('First', $result['swotcanvas']['lastTitle']);
|
||||
$this->assertSame(1, $result['swotcanvas']['lastCanvasId']);
|
||||
}
|
||||
|
||||
public function test_build_recent_progress_with_empty_input_returns_empty(): void
|
||||
{
|
||||
$service = $this->service();
|
||||
|
||||
$metadata = $service->getBoardMetadata();
|
||||
$metadataCountBefore = count($metadata);
|
||||
|
||||
$result = $service->buildRecentProgressCanvas([], $metadata);
|
||||
|
||||
$this->assertSame([], $result);
|
||||
// Nothing consumed, so the metadata map is untouched.
|
||||
$this->assertCount($metadataCountBefore, $metadata);
|
||||
}
|
||||
|
||||
public function test_boards_overview_assembles_render_ready_struct(): void
|
||||
{
|
||||
$recentlyUpdated = [
|
||||
['type' => 'leancanvas', 'title' => 'Lean A', 'modified' => '2026-05-25 12:00:00', 'id' => 99],
|
||||
];
|
||||
$progress = ['leancanvas' => 0.5];
|
||||
|
||||
// getBoardsOverview now self-calls getLastUpdatedCanvas()/getBoardProgress(),
|
||||
// so partial-mock just those two and exercise the real assembly logic.
|
||||
$service = $this->make(BlueprintsService::class, [
|
||||
'getLastUpdatedCanvas' => fn () => $recentlyUpdated,
|
||||
'getBoardProgress' => fn () => $progress,
|
||||
]);
|
||||
|
||||
$overview = $service->getBoardsOverview(7);
|
||||
|
||||
$this->assertArrayHasKey('recentProgressCanvas', $overview);
|
||||
$this->assertArrayHasKey('otherBoards', $overview);
|
||||
$this->assertArrayHasKey('recentlyUpdatedCanvas', $overview);
|
||||
$this->assertArrayHasKey('canvasProgress', $overview);
|
||||
|
||||
$this->assertSame($recentlyUpdated, $overview['recentlyUpdatedCanvas']);
|
||||
$this->assertSame($progress, $overview['canvasProgress']);
|
||||
|
||||
// leancanvas was recently used, so it lands in recentProgressCanvas
|
||||
// and is removed from the remaining "other" boards.
|
||||
$this->assertArrayHasKey('leancanvas', $overview['recentProgressCanvas']);
|
||||
$this->assertSame('Lean A', $overview['recentProgressCanvas']['leancanvas']['lastTitle']);
|
||||
$this->assertArrayNotHasKey('leancanvas', $overview['otherBoards']);
|
||||
}
|
||||
|
||||
public function test_boards_overview_passes_project_id_to_self_calls(): void
|
||||
{
|
||||
$capturedLastUpdatedId = null;
|
||||
$capturedProgressId = null;
|
||||
|
||||
$service = $this->make(BlueprintsService::class, [
|
||||
'getLastUpdatedCanvas' => function ($projectId) use (&$capturedLastUpdatedId) {
|
||||
$capturedLastUpdatedId = $projectId;
|
||||
|
||||
return [];
|
||||
},
|
||||
'getBoardProgress' => function ($projectId) use (&$capturedProgressId) {
|
||||
$capturedProgressId = $projectId;
|
||||
|
||||
return [];
|
||||
},
|
||||
]);
|
||||
|
||||
$service->getBoardsOverview(7);
|
||||
|
||||
$this->assertSame(7, $capturedLastUpdatedId);
|
||||
$this->assertSame('7', $capturedProgressId, 'getBoardProgress receives the project id cast to string');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Secured by-id board/item CRUD chokepoint.
|
||||
//
|
||||
// Canvas boards/items live in the shared zp_canvas / zp_canvas_items tables (one id
|
||||
// sequence across every variant). Every by-id operation must authorize against the
|
||||
// entity's REAL project (resolved by id + canvas type), never the session project. Reads
|
||||
// soft-deny (return the neutral "missing" value) so they are not a cross-project existence
|
||||
// oracle; writes fail CLOSED with an AuthorizationException and never touch the repo.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private function allowingPermissions(): PermissionService
|
||||
{
|
||||
return $this->make(PermissionService::class, [
|
||||
'authorize' => fn () => null,
|
||||
'currentUserCan' => fn () => true,
|
||||
]);
|
||||
}
|
||||
|
||||
private function denyingPermissions(): PermissionService
|
||||
{
|
||||
return $this->make(PermissionService::class, [
|
||||
'authorize' => function (): void {
|
||||
throw new AuthorizationException;
|
||||
},
|
||||
'currentUserCan' => fn () => false,
|
||||
]);
|
||||
}
|
||||
|
||||
private function securedService(BlueprintsRepository $repo, PermissionService $perms): BlueprintsService
|
||||
{
|
||||
$service = $this->service($repo);
|
||||
$service->setPermissionService($perms);
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
public function test_get_canvas_item_returns_false_for_missing_or_foreign_item_without_loading_it(): void
|
||||
{
|
||||
// Resolver null = missing id OR an id whose board is a different canvas type. Must
|
||||
// return false WITHOUT loading the item — no cross-project existence oracle.
|
||||
$loaded = 0;
|
||||
$repo = $this->make(BlueprintsRepository::class, [
|
||||
'getCanvasItemProjectId' => fn () => null,
|
||||
'getSingleCanvasItem' => function () use (&$loaded) {
|
||||
$loaded++;
|
||||
|
||||
return ['id' => 1];
|
||||
},
|
||||
]);
|
||||
$service = $this->securedService($repo, $this->allowingPermissions());
|
||||
|
||||
$this->assertFalse($service->getCanvasItem(123, 'swotcanvas'));
|
||||
$this->assertSame(0, $loaded, 'A missing/foreign item must not be loaded');
|
||||
}
|
||||
|
||||
public function test_get_canvas_item_soft_denies_when_view_not_permitted(): void
|
||||
{
|
||||
$loaded = 0;
|
||||
$repo = $this->make(BlueprintsRepository::class, [
|
||||
'getCanvasItemProjectId' => fn () => 9,
|
||||
'getSingleCanvasItem' => function () use (&$loaded) {
|
||||
$loaded++;
|
||||
|
||||
return ['id' => 1];
|
||||
},
|
||||
]);
|
||||
$service = $this->securedService($repo, $this->make(PermissionService::class, ['currentUserCan' => fn () => false]));
|
||||
|
||||
$this->assertFalse($service->getCanvasItem(1, 'swotcanvas'));
|
||||
$this->assertSame(0, $loaded, 'An unauthorized item returns the same neutral result as a missing one');
|
||||
}
|
||||
|
||||
public function test_get_canvas_item_is_type_scoped_and_returns_item_when_authorized(): void
|
||||
{
|
||||
$resolvedType = null;
|
||||
$repo = $this->make(BlueprintsRepository::class, [
|
||||
'getCanvasItemProjectId' => function ($id, $type) use (&$resolvedType) {
|
||||
$resolvedType = $type;
|
||||
|
||||
return 9;
|
||||
},
|
||||
'getSingleCanvasItem' => fn () => ['id' => 7, 'canvasId' => 3],
|
||||
]);
|
||||
$service = $this->securedService($repo, $this->make(PermissionService::class, ['currentUserCan' => fn () => true]));
|
||||
|
||||
$item = $service->getCanvasItem(7, 'swotcanvas');
|
||||
|
||||
$this->assertSame(7, $item['id']);
|
||||
$this->assertSame('swotcanvas', $resolvedType, 'The resolver must be type-scoped so a foreign canvas type cannot match');
|
||||
}
|
||||
|
||||
public function test_get_board_items_returns_empty_for_foreign_board(): void
|
||||
{
|
||||
$loaded = 0;
|
||||
$repo = $this->make(BlueprintsRepository::class, [
|
||||
'getCanvasProjectId' => fn () => null,
|
||||
'getCanvasItemsById' => function () use (&$loaded) {
|
||||
$loaded++;
|
||||
|
||||
return [['id' => 1]];
|
||||
},
|
||||
]);
|
||||
$service = $this->securedService($repo, $this->allowingPermissions());
|
||||
|
||||
$this->assertSame([], $service->getBoardItems(999, 'swotcanvas', 'swotcanvasitem'));
|
||||
$this->assertSame(0, $loaded, 'A foreign/unknown board must not have its items read');
|
||||
}
|
||||
|
||||
public function test_patch_canvas_item_throws_and_never_writes_for_unresolved_item(): void
|
||||
{
|
||||
$patched = 0;
|
||||
$repo = $this->make(BlueprintsRepository::class, [
|
||||
'getCanvasItemProjectId' => fn () => null,
|
||||
'patchCanvasItem' => function () use (&$patched) {
|
||||
$patched++;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
// allow-all permissions: the deny must come from the null resolution, not the role.
|
||||
$service = $this->securedService($repo, $this->allowingPermissions());
|
||||
|
||||
try {
|
||||
$service->patchCanvasItem(5, ['status' => 'x'], 'swotcanvas');
|
||||
$this->fail('Expected AuthorizationException for an unresolved item');
|
||||
} catch (AuthorizationException) {
|
||||
// expected
|
||||
}
|
||||
$this->assertSame(0, $patched, 'A missing/foreign item must never be patched');
|
||||
}
|
||||
|
||||
public function test_patch_canvas_item_throws_when_edit_denied(): void
|
||||
{
|
||||
$repo = $this->make(BlueprintsRepository::class, [
|
||||
'getCanvasItemProjectId' => fn () => 9,
|
||||
'patchCanvasItem' => fn () => true,
|
||||
]);
|
||||
$service = $this->securedService($repo, $this->denyingPermissions());
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$service->patchCanvasItem(5, ['status' => 'x'], 'swotcanvas');
|
||||
}
|
||||
|
||||
public function test_update_canvas_item_resolves_project_from_item_id_not_payload_canvas_id(): void
|
||||
{
|
||||
// Relocation fence: the project is resolved from the EXISTING item's id, not from the
|
||||
// attacker-supplied canvasId in the payload.
|
||||
$resolvedItemId = null;
|
||||
$wrote = 0;
|
||||
$repo = $this->make(BlueprintsRepository::class, [
|
||||
'getCanvasItemProjectId' => function ($id) use (&$resolvedItemId) {
|
||||
$resolvedItemId = $id;
|
||||
|
||||
return 9;
|
||||
},
|
||||
'editCanvasItem' => function () use (&$wrote) {
|
||||
$wrote++;
|
||||
},
|
||||
]);
|
||||
$service = $this->securedService($repo, $this->allowingPermissions());
|
||||
|
||||
$service->updateCanvasItem(['itemId' => 42, 'canvasId' => 9999, 'description' => 'x'], 'swotcanvas');
|
||||
|
||||
$this->assertSame(42, $resolvedItemId, 'Project must be resolved from itemId, not the payload canvasId');
|
||||
$this->assertSame(1, $wrote);
|
||||
}
|
||||
|
||||
public function test_create_canvas_item_throws_and_never_inserts_for_unknown_board(): void
|
||||
{
|
||||
$inserted = 0;
|
||||
$repo = $this->make(BlueprintsRepository::class, [
|
||||
'getCanvasProjectId' => fn () => null,
|
||||
'addCanvasItem' => function () use (&$inserted) {
|
||||
$inserted++;
|
||||
|
||||
return '1';
|
||||
},
|
||||
]);
|
||||
$service = $this->securedService($repo, $this->allowingPermissions());
|
||||
|
||||
try {
|
||||
$service->createCanvasItem(['canvasId' => 9999, 'box' => 'x'], 'swotcanvas');
|
||||
$this->fail('Expected AuthorizationException for an unknown target board');
|
||||
} catch (AuthorizationException) {
|
||||
}
|
||||
$this->assertSame(0, $inserted, 'An item must never be created into an unknown/foreign board');
|
||||
}
|
||||
|
||||
public function test_delete_canvas_item_throws_and_never_deletes_for_unresolved_item(): void
|
||||
{
|
||||
$deleted = 0;
|
||||
$repo = $this->make(BlueprintsRepository::class, [
|
||||
'getCanvasItemProjectId' => fn () => null,
|
||||
'delCanvasItem' => function () use (&$deleted) {
|
||||
$deleted++;
|
||||
},
|
||||
]);
|
||||
$service = $this->securedService($repo, $this->allowingPermissions());
|
||||
|
||||
try {
|
||||
$service->deleteCanvasItem(5, 'swotcanvas');
|
||||
$this->fail('Expected AuthorizationException for an unresolved item');
|
||||
} catch (AuthorizationException) {
|
||||
}
|
||||
$this->assertSame(0, $deleted, 'A missing/foreign item must never be deleted');
|
||||
}
|
||||
|
||||
public function test_delete_board_throws_and_never_deletes_for_unresolved_board(): void
|
||||
{
|
||||
$deleted = 0;
|
||||
$repo = $this->make(BlueprintsRepository::class, [
|
||||
'getCanvasProjectId' => fn () => null,
|
||||
'deleteCanvas' => function () use (&$deleted) {
|
||||
$deleted++;
|
||||
},
|
||||
]);
|
||||
$service = $this->securedService($repo, $this->allowingPermissions());
|
||||
|
||||
try {
|
||||
$service->deleteBoard(5, 'swotcanvas');
|
||||
$this->fail('Expected AuthorizationException for an unresolved board');
|
||||
} catch (AuthorizationException) {
|
||||
}
|
||||
$this->assertSame(0, $deleted, 'A missing/foreign board must never be deleted');
|
||||
}
|
||||
|
||||
public function test_copy_board_throws_when_source_unresolved_and_never_copies(): void
|
||||
{
|
||||
$copied = 0;
|
||||
$repo = $this->make(BlueprintsRepository::class, [
|
||||
'getCanvasProjectId' => fn () => null,
|
||||
'copyCanvas' => function () use (&$copied) {
|
||||
$copied++;
|
||||
|
||||
return 1;
|
||||
},
|
||||
]);
|
||||
$service = $this->securedService($repo, $this->allowingPermissions());
|
||||
|
||||
try {
|
||||
$service->copyBoard(5, 7, 1, 'Copy', 'swotcanvas');
|
||||
$this->fail('Expected AuthorizationException for an unresolved source board');
|
||||
} catch (AuthorizationException) {
|
||||
}
|
||||
$this->assertSame(0, $copied, 'A board must never be copied from an unknown/foreign source');
|
||||
}
|
||||
|
||||
public function test_merge_board_requires_both_boards_to_resolve(): void
|
||||
{
|
||||
// Source (1) resolves but target (2) does not -> deny, never merge.
|
||||
$merged = 0;
|
||||
$repo = $this->make(BlueprintsRepository::class, [
|
||||
'getCanvasProjectId' => fn ($id) => $id === 1 ? 9 : null,
|
||||
'mergeCanvas' => function () use (&$merged) {
|
||||
$merged++;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
$service = $this->securedService($repo, $this->allowingPermissions());
|
||||
|
||||
try {
|
||||
$service->mergeBoard(2, 1, 'swotcanvas');
|
||||
$this->fail('Expected AuthorizationException when a board does not resolve');
|
||||
} catch (AuthorizationException) {
|
||||
}
|
||||
$this->assertSame(0, $merged, 'Merge must not run unless BOTH boards resolve');
|
||||
}
|
||||
|
||||
public function test_import_authorizes_create_on_target_project_before_doing_anything(): void
|
||||
{
|
||||
// import() authorizes CREATE against the passed projectId first — a denial throws
|
||||
// before the file/template/repo are ever touched (it is reachable via JSON-RPC with an
|
||||
// arbitrary projectId).
|
||||
$repo = $this->make(BlueprintsRepository::class, [
|
||||
'existCanvas' => function (): bool {
|
||||
$this->fail('import must deny before touching the repository');
|
||||
|
||||
return false;
|
||||
},
|
||||
]);
|
||||
$service = $this->securedService($repo, $this->denyingPermissions());
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$service->import('/tmp/does-not-matter.xml', 'swot', 55, 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// import() path-validation regression tests (SSRF / LFI / CWE-918).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public function test_import_rejects_ssrf_url_wrappers(): void
|
||||
{
|
||||
// URL wrappers such as http://, ftp:// resolve to false via realpath(),
|
||||
// but even if/when a stream wrapper could produce a realpath, the
|
||||
// allow-list check catches it. This test also guards the more
|
||||
// subtle case of file:///etc/passwd which some PHP builds resolve.
|
||||
$service = $this->securedService(
|
||||
$this->make(BlueprintsRepository::class),
|
||||
$this->allowingPermissions()
|
||||
);
|
||||
|
||||
// SSRF: HTTP URL — realpath() returns false, caught as "file not found".
|
||||
$this->assertFalse(
|
||||
$service->import('http://169.254.169.254/latest/meta-data/', 'lean', 55, 1),
|
||||
'HTTP URL must be rejected'
|
||||
);
|
||||
|
||||
// SSRF: FTP URL.
|
||||
$this->assertFalse(
|
||||
$service->import('ftp://evil.com/blueprint.xml', 'lean', 55, 1),
|
||||
'FTP URL must be rejected'
|
||||
);
|
||||
|
||||
// LFI: file:// wrapper. Some PHP builds resolve file:///etc/passwd
|
||||
// via realpath() and would read it without the allow-list guard.
|
||||
$this->assertFalse(
|
||||
$service->import('file:///etc/passwd', 'lean', 55, 1),
|
||||
'file:// URL must be rejected'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_import_rejects_lfi_absolute_path_to_system_file(): void
|
||||
{
|
||||
// Create an .xml file in a directory that is NOT in the allowed list.
|
||||
// base_path('storage') is reliably outside sys_temp_dir, userfiles, and
|
||||
// Blueprints/imports — unlike /var/tmp which can equal sys_get_temp_dir()
|
||||
// on some systems.
|
||||
$service = $this->securedService(
|
||||
$this->make(BlueprintsRepository::class),
|
||||
$this->allowingPermissions()
|
||||
);
|
||||
|
||||
$outOfBounds = base_path('storage/leantime_lfi_test_'.uniqid('', true).'.xml');
|
||||
file_put_contents($outOfBounds, '<canvas key="leancanvas"><title>LFI Test</title></canvas>');
|
||||
|
||||
try {
|
||||
$this->assertFalse(
|
||||
$service->import($outOfBounds, 'lean', 55, 1),
|
||||
'Absolute path to an .xml file outside allowed directories must be rejected'
|
||||
);
|
||||
} finally {
|
||||
if (file_exists($outOfBounds)) {
|
||||
unlink($outOfBounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_import_rejects_dot_dot_path_traversal(): void
|
||||
{
|
||||
// Create a real .xml file outside the allow-list (in storage/),
|
||||
// then reach it via a path that starts in sys_get_temp_dir() and
|
||||
// traverses up to the filesystem root with ../ before descending
|
||||
// into the project. realpath() must resolve the ../ segments and
|
||||
// the allow-list must reject the canonicalized path — this proves
|
||||
// both canonicalization AND allow-list work, not just extension
|
||||
// validation.
|
||||
$service = $this->securedService(
|
||||
$this->make(BlueprintsRepository::class),
|
||||
$this->allowingPermissions()
|
||||
);
|
||||
|
||||
$outOfBounds = base_path('storage/traversal_target_'.uniqid('', true).'.xml');
|
||||
file_put_contents($outOfBounds, '<canvas key="leancanvas"><title>Traversal Test</title></canvas>');
|
||||
|
||||
// Walk from temp dir up to root (depth + 1 levels), then down
|
||||
// into the project storage directory.
|
||||
$upLevels = substr_count(sys_get_temp_dir(), DIRECTORY_SEPARATOR) + 1;
|
||||
$fromRoot = ltrim($outOfBounds, DIRECTORY_SEPARATOR);
|
||||
$traversal = sys_get_temp_dir().DIRECTORY_SEPARATOR
|
||||
.str_repeat('..'.DIRECTORY_SEPARATOR, $upLevels + 1)
|
||||
.$fromRoot;
|
||||
|
||||
try {
|
||||
$this->assertFalse(
|
||||
$service->import($traversal, 'lean', 55, 1),
|
||||
'Path traversal (../) to a valid .xml outside allowed dirs must be rejected'
|
||||
);
|
||||
} finally {
|
||||
if (file_exists($outOfBounds)) {
|
||||
unlink($outOfBounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_import_rejects_sibling_prefix_bypass(): void
|
||||
{
|
||||
// str_starts_with without DIRECTORY_SEPARATOR anchoring would
|
||||
// allow imports-evil/x to match against allowed …/imports.
|
||||
// Create a sibling of the Blueprints imports directory (under
|
||||
// the project root, guaranteed writable) to test the anchor.
|
||||
$service = $this->securedService(
|
||||
$this->make(BlueprintsRepository::class),
|
||||
$this->allowingPermissions()
|
||||
);
|
||||
|
||||
$allowedDir = APP_ROOT.'/app/Domain/Blueprints/imports';
|
||||
if (! is_dir($allowedDir)) {
|
||||
mkdir($allowedDir, 0700, true);
|
||||
}
|
||||
$siblingDir = APP_ROOT.'/app/Domain/Blueprints/imports-sibling-'.uniqid('', true);
|
||||
if (! is_dir($siblingDir)) {
|
||||
mkdir($siblingDir, 0700, true);
|
||||
}
|
||||
$siblingFile = $siblingDir.'/blueprint.xml';
|
||||
file_put_contents($siblingFile, '<canvas key="leancanvas"><title>Test</title></canvas>');
|
||||
|
||||
try {
|
||||
$this->assertFalse(
|
||||
$service->import($siblingFile, 'lean', 55, 1),
|
||||
'Sibling-prefix path (e.g. /tmp-evil/…) must NOT match allowed /tmp'
|
||||
);
|
||||
} finally {
|
||||
unlink($siblingFile);
|
||||
rmdir($siblingDir);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_import_rejects_disallowed_file_extensions(): void
|
||||
{
|
||||
// Only .xml is permitted. Other extensions must be
|
||||
// rejected even when the file sits in an allowed directory.
|
||||
$service = $this->securedService(
|
||||
$this->make(BlueprintsRepository::class),
|
||||
$this->allowingPermissions()
|
||||
);
|
||||
|
||||
// Use tempnam() + rename to get unique filenames — fixed names
|
||||
// in the shared temp dir can collide with crashed-run leftovers
|
||||
// or concurrent test processes.
|
||||
$phpBase = tempnam(sys_get_temp_dir(), 'leantime.');
|
||||
$phpFile = $phpBase.'.php';
|
||||
rename($phpBase, $phpFile);
|
||||
file_put_contents($phpFile, '<?php echo "pwned";');
|
||||
|
||||
$txtBase = tempnam(sys_get_temp_dir(), 'leantime.');
|
||||
$txtFile = $txtBase.'.txt';
|
||||
rename($txtBase, $txtFile);
|
||||
file_put_contents($txtFile, 'not xml');
|
||||
|
||||
try {
|
||||
$this->assertFalse(
|
||||
$service->import($phpFile, 'lean', 55, 1),
|
||||
'.php extension must be rejected in an allowed directory'
|
||||
);
|
||||
$this->assertFalse(
|
||||
$service->import($txtFile, 'lean', 55, 1),
|
||||
'.txt extension must be rejected in an allowed directory'
|
||||
);
|
||||
} finally {
|
||||
if (file_exists($phpFile)) {
|
||||
unlink($phpFile);
|
||||
}
|
||||
if (file_exists($txtFile)) {
|
||||
unlink($txtFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_import_accepts_xml_file_in_allowed_temp_dir(): void
|
||||
{
|
||||
// A .xml file placed in sys_get_temp_dir() (the normal upload flow)
|
||||
// must pass path validation and successfully import via the repo.
|
||||
// The repository is stubbed so the import completes and returns a
|
||||
// known canvas id, proving that path validation did NOT block it.
|
||||
$expectedId = 42;
|
||||
// addCanvas()/addCanvasItem() are declared `false|string` (insertGetId), so the
|
||||
// stubs must return strings — import() casts the id to int on the way out.
|
||||
$repo = $this->make(BlueprintsRepository::class, [
|
||||
'existCanvas' => fn () => false,
|
||||
'addCanvas' => fn () => (string) $expectedId,
|
||||
'addCanvasItem' => fn () => '1',
|
||||
]);
|
||||
$service = $this->securedService($repo, $this->allowingPermissions());
|
||||
|
||||
// import() resolves UserRepository via app()->make(). Unit tests
|
||||
// disable the database, so bind a stub that never touches it.
|
||||
$usersStub = $this->make(UserRepository::class, [
|
||||
'getUserIdByName' => fn () => 1,
|
||||
]);
|
||||
app()->instance(UserRepository::class, $usersStub);
|
||||
|
||||
// Mirrors what BlueprintsExport::buildXml() actually emits — in particular
|
||||
// status/relates carry their value in a `key` attribute, which is what
|
||||
// import() reads. Element text there is silently dropped.
|
||||
$xml = <<<'XML'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<canvas key="leancanvas">
|
||||
<title>Security Test Canvas</title>
|
||||
<content>
|
||||
<element key="problem">
|
||||
<item>
|
||||
<author id="1" firstname="A" lastname="B"/>
|
||||
<description>Test item</description>
|
||||
<status key="status_draft" />
|
||||
<relates key="relates_none" />
|
||||
<assumptions>none</assumptions>
|
||||
<data>none</data>
|
||||
<conclusion>none</conclusion>
|
||||
</item>
|
||||
</element>
|
||||
</content>
|
||||
</canvas>
|
||||
XML;
|
||||
|
||||
$tmpBase = tempnam(sys_get_temp_dir(), 'leantime.');
|
||||
$tempFile = $tmpBase.'.xml';
|
||||
rename($tmpBase, $tempFile);
|
||||
file_put_contents($tempFile, $xml);
|
||||
|
||||
try {
|
||||
$result = $service->import($tempFile, 'lean', 55, 1);
|
||||
// Path validation passed and repo returned the expected canvas id.
|
||||
$this->assertSame(
|
||||
$expectedId,
|
||||
$result,
|
||||
'XML file in allowed dir must pass path validation and be imported'
|
||||
);
|
||||
} finally {
|
||||
if (file_exists($tempFile)) {
|
||||
unlink($tempFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_create_board_applies_start_content_against_the_slug_not_the_db_type(): void
|
||||
{
|
||||
// Regression test for Phase 4: createBoard() is called with the DATABASE
|
||||
// type ("swotcanvas") but both the Blueprints TemplateRegistry and the
|
||||
// ContentTemplateRegistry key by the SLUG ("swot"). The original code
|
||||
// called TemplateRegistry::get($canvasType), which required a slug and
|
||||
// silently returned null for the db-type form — making applyStartContent
|
||||
// a no-op. This test locks in the fix: getByDatabaseType() bridges, and
|
||||
// the resolved slug flows to the ContentTemplates lookups.
|
||||
$blueprint = new CanvasTemplate([
|
||||
'slug' => 'swot',
|
||||
'startContent' => 'starter-swot',
|
||||
]);
|
||||
$registry = new class($blueprint) extends TemplateRegistry
|
||||
{
|
||||
public function __construct(private CanvasTemplate $bp) {}
|
||||
|
||||
public function get(string $slug): ?CanvasTemplate
|
||||
{
|
||||
// Bug reproduction: original code called this with 'swotcanvas'.
|
||||
// The real registry only knows 'swot' — so it returned null and
|
||||
// applyStartContent bailed. Test-side we mirror that behavior.
|
||||
return $slug === 'swot' ? $this->bp : null;
|
||||
}
|
||||
|
||||
public function getByDatabaseType(string $dbType): ?CanvasTemplate
|
||||
{
|
||||
// Mirror the shipped str_ends_with/substr strip so this stub and
|
||||
// the production slug-resolution can't drift (per review CR).
|
||||
$suffix = 'canvas';
|
||||
$slug = str_ends_with($dbType, $suffix) && strlen($dbType) > strlen($suffix)
|
||||
? substr($dbType, 0, -strlen($suffix))
|
||||
: $dbType;
|
||||
|
||||
return $this->get($slug);
|
||||
}
|
||||
};
|
||||
|
||||
$contentTemplates = new class extends ContentTemplateRegistry
|
||||
{
|
||||
/** @var string[] */
|
||||
public array $seenSlugs = [];
|
||||
|
||||
// Override the parent constructor (the stub needs no deps) and record
|
||||
// the slugs get() is consulted with, so the test asserts on them
|
||||
// afterward. Avoids a by-reference property — PHP ^8.2 can't promote
|
||||
// by reference, and a typed-property reference is brittle.
|
||||
public function __construct() {}
|
||||
|
||||
public function get(string $appliesTo, string $key): ?ContentTemplate
|
||||
{
|
||||
$this->seenSlugs[] = $appliesTo;
|
||||
|
||||
return null; // null lookup exits applyStartContent early, but the assertion is on WHAT slug reached us.
|
||||
}
|
||||
};
|
||||
|
||||
$repo = $this->make(BlueprintsRepository::class, [
|
||||
'addCanvas' => fn () => '77',
|
||||
]);
|
||||
|
||||
$language = $this->make(LanguageCore::class, ['__' => fn (string $index) => 'T:'.$index]);
|
||||
$service = new BlueprintsService($repo, $registry, $language, $contentTemplates);
|
||||
$service->setPermissionService($this->allowingPermissions());
|
||||
|
||||
$service->createBoard(['projectId' => 5, 'title' => 't'], 'swotcanvas');
|
||||
|
||||
$this->assertSame(['swot'], $contentTemplates->seenSlugs, 'ContentTemplates must be consulted with the SLUG, not the DB type');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Blueprints\Services;
|
||||
|
||||
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
|
||||
use Leantime\Domain\Blueprints\Services\TemplateRegistry;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for TemplateRegistry, which loads the canvas YAML definitions from
|
||||
* app/Domain/Blueprints/Templates/definitions into CanvasTemplate objects.
|
||||
*/
|
||||
class TemplateRegistryTest extends TestCase
|
||||
{
|
||||
private function registry(): TemplateRegistry
|
||||
{
|
||||
return new TemplateRegistry;
|
||||
}
|
||||
|
||||
public function test_loads_a_known_definition(): void
|
||||
{
|
||||
$template = $this->registry()->get('swot');
|
||||
|
||||
$this->assertInstanceOf(CanvasTemplate::class, $template);
|
||||
$this->assertSame('swot', $template->slug);
|
||||
$this->assertSame('swotcanvas', $template->getDatabaseType());
|
||||
// SWOT has four boxes.
|
||||
$this->assertCount(4, $template->boxes);
|
||||
$this->assertArrayHasKey('swot_strengths', $template->boxes);
|
||||
}
|
||||
|
||||
public function test_unknown_slug_returns_null(): void
|
||||
{
|
||||
$this->assertNull($this->registry()->get('doesnotexist'));
|
||||
}
|
||||
|
||||
public function test_slug_lookup_is_case_insensitive_and_trimmed(): void
|
||||
{
|
||||
$this->assertInstanceOf(CanvasTemplate::class, $this->registry()->get(' SWOT '));
|
||||
}
|
||||
|
||||
public function test_get_by_database_type_strips_canvas_suffix(): void
|
||||
{
|
||||
$template = $this->registry()->getByDatabaseType('leancanvas');
|
||||
|
||||
$this->assertInstanceOf(CanvasTemplate::class, $template);
|
||||
$this->assertSame('lean', $template->slug);
|
||||
}
|
||||
|
||||
public function test_caches_and_returns_same_instance(): void
|
||||
{
|
||||
$registry = $this->registry();
|
||||
|
||||
$this->assertSame($registry->get('swot'), $registry->get('swot'));
|
||||
}
|
||||
|
||||
public function test_all_loads_every_definition(): void
|
||||
{
|
||||
$slugs = $this->registry()->slugs();
|
||||
|
||||
// The 16 consolidated variants all have a YAML definition.
|
||||
$expected = ['cp', 'dbm', 'ea', 'em', 'insights', 'lbm', 'lean', 'minempathy', 'obm', 'retros', 'risks', 'sb', 'sm', 'sq', 'swot', 'value'];
|
||||
foreach ($expected as $slug) {
|
||||
$this->assertContains($slug, $slugs, "Missing definition for '$slug'");
|
||||
}
|
||||
}
|
||||
|
||||
public function test_obm_carries_min_width_offset(): void
|
||||
{
|
||||
// OBM is the one layout that needed an extra +50px min-width offset.
|
||||
$this->assertSame(50, $this->registry()->get('obm')->minWidthOffset);
|
||||
}
|
||||
}
|
||||
372
tests/Unit/app/Domain/Calendar/Services/CalendarServiceTest.php
Normal file
372
tests/Unit/app/Domain/Calendar/Services/CalendarServiceTest.php
Normal file
@@ -0,0 +1,372 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Calendar\Services;
|
||||
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Exceptions\MissingParameterException;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Domain\Calendar\Repositories\Calendar as CalendarRepository;
|
||||
use Leantime\Domain\Menu\Repositories\Menu;
|
||||
use Leantime\Domain\Setting\Repositories\Setting;
|
||||
use Leantime\Domain\Tickets\Services\Tickets;
|
||||
use Spatie\IcalendarGenerator\Components\Calendar as IcalCalendar;
|
||||
use Unit\TestCase;
|
||||
|
||||
class CalendarServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
protected $calendarRepository;
|
||||
|
||||
protected $language;
|
||||
|
||||
protected $settingsRepository;
|
||||
|
||||
protected $config;
|
||||
|
||||
protected $calendar;
|
||||
|
||||
/**
|
||||
* The test object
|
||||
*
|
||||
* @var Menu
|
||||
*/
|
||||
protected $menu;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
|
||||
parent::setUp();
|
||||
|
||||
if (! defined('BASE_URL')) {
|
||||
define('BASE_URL', 'http://localhost');
|
||||
}
|
||||
|
||||
$this->calendarRepository = $this->make(CalendarRepository::class);
|
||||
$this->language = $this->make(Language::class);
|
||||
$this->settingsRepository = $this->make(Setting::class, [
|
||||
'getSetting' => 'secret',
|
||||
]);
|
||||
$this->config = $this->make(Environment::class, [
|
||||
'sessionPassword' => '123abc',
|
||||
]);
|
||||
|
||||
// Load class to be tested
|
||||
$this->calendar = new \Leantime\Domain\Calendar\Services\Calendar(
|
||||
calendarRepo: $this->calendarRepository,
|
||||
language: $this->language,
|
||||
settingsRepo: $this->settingsRepository,
|
||||
config: $this->config
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
protected function _after()
|
||||
{
|
||||
$this->calendar = null;
|
||||
}
|
||||
|
||||
// Write tests below
|
||||
|
||||
/**
|
||||
* Test GetMenuTypes method
|
||||
*/
|
||||
public function test_get_i_cal_url()
|
||||
{
|
||||
|
||||
// Sha is generated from id -1 and sessionpassword 123abc
|
||||
$sha = 'ba62fbd0d08f6607d6b3213dcccc1b50f4d82f19';
|
||||
$url = $this->calendar->getICalUrl(1);
|
||||
|
||||
$this->assertEquals(BASE_URL.'/calendar/ical/secret_'.$sha, $url, 'hash is not correct');
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* A token that does not split into exactly two hashes must throw.
|
||||
*/
|
||||
public function test_get_ical_by_request_token_rejects_malformed_token()
|
||||
{
|
||||
$this->expectException(MissingParameterException::class);
|
||||
|
||||
// No underscore -> only one part -> invalid.
|
||||
$this->calendar->getIcalByRequestToken('notavalidtoken');
|
||||
}
|
||||
|
||||
/**
|
||||
* A token taken from the request id (no 3-part act) must parse into
|
||||
* userHash/calHash and route them to the repository correctly.
|
||||
*/
|
||||
public function test_get_ical_by_request_token_parses_id_token_and_routes_hashes()
|
||||
{
|
||||
$capturedUserHash = null;
|
||||
$capturedCalHash = null;
|
||||
|
||||
$calendarRepo = $this->make(CalendarRepository::class, [
|
||||
'getCalendarBySecretHash' => function (string $userHash, string $calHash) use (&$capturedUserHash, &$capturedCalHash) {
|
||||
$capturedUserHash = $userHash;
|
||||
$capturedCalHash = $calHash;
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 1,
|
||||
'title' => 'Event',
|
||||
'description' => 'desc',
|
||||
'dateFrom' => '2025-04-16 10:00:00',
|
||||
'dateTo' => '2025-04-16 11:00:00',
|
||||
'allDay' => false,
|
||||
'eventType' => 'calendar',
|
||||
'dateContext' => 'plan',
|
||||
'url' => '',
|
||||
],
|
||||
];
|
||||
},
|
||||
]);
|
||||
|
||||
$service = new \Leantime\Domain\Calendar\Services\Calendar(
|
||||
calendarRepo: $calendarRepo,
|
||||
language: $this->language,
|
||||
settingsRepo: $this->settingsRepository,
|
||||
config: $this->config
|
||||
);
|
||||
|
||||
// Token format is {icalHash}_{userHash}.
|
||||
$result = $service->getIcalByRequestToken('calhash123_userhash456');
|
||||
|
||||
$this->assertInstanceOf(IcalCalendar::class, $result);
|
||||
$this->assertEquals('userhash456', $capturedUserHash, 'user hash should come from the second token segment');
|
||||
$this->assertEquals('calhash123', $capturedCalHash, 'cal hash should come from the first token segment');
|
||||
}
|
||||
|
||||
/**
|
||||
* When the frontcontroller act value carries the token as its third
|
||||
* dot-separated segment it must take precedence over the id token.
|
||||
*/
|
||||
public function test_get_ical_by_request_token_prefers_act_segment()
|
||||
{
|
||||
$capturedUserHash = null;
|
||||
$capturedCalHash = null;
|
||||
|
||||
$calendarRepo = $this->make(CalendarRepository::class, [
|
||||
'getCalendarBySecretHash' => function (string $userHash, string $calHash) use (&$capturedUserHash, &$capturedCalHash) {
|
||||
$capturedUserHash = $userHash;
|
||||
$capturedCalHash = $calHash;
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 1,
|
||||
'title' => 'Event',
|
||||
'description' => 'desc',
|
||||
'dateFrom' => '2025-04-16 10:00:00',
|
||||
'dateTo' => '2025-04-16 11:00:00',
|
||||
'allDay' => false,
|
||||
'eventType' => 'calendar',
|
||||
'dateContext' => 'plan',
|
||||
'url' => '',
|
||||
],
|
||||
];
|
||||
},
|
||||
]);
|
||||
|
||||
$service = new \Leantime\Domain\Calendar\Services\Calendar(
|
||||
calendarRepo: $calendarRepo,
|
||||
language: $this->language,
|
||||
settingsRepo: $this->settingsRepository,
|
||||
config: $this->config
|
||||
);
|
||||
|
||||
// act = calendar.ical.{icalHash}_{userHash}; id token is ignored.
|
||||
$result = $service->getIcalByRequestToken('ignored', 'calendar.ical.actcal_actuser');
|
||||
|
||||
$this->assertInstanceOf(IcalCalendar::class, $result);
|
||||
$this->assertEquals('actuser', $capturedUserHash, 'user hash should come from the act segment');
|
||||
$this->assertEquals('actcal', $capturedCalHash, 'cal hash should come from the act segment');
|
||||
}
|
||||
|
||||
// ---- permission-engine authorization ---------------------------------
|
||||
|
||||
/** Builds the service with a stubbed repo + PermissionService, as the session user (id 1). */
|
||||
private function makeServiceWithPermissions(
|
||||
CalendarRepository $repo,
|
||||
\Leantime\Core\Auth\Permissions\PermissionService $perms
|
||||
): \Leantime\Domain\Calendar\Services\Calendar {
|
||||
session(['userdata.id' => 1]);
|
||||
|
||||
$service = new \Leantime\Domain\Calendar\Services\Calendar(
|
||||
calendarRepo: $repo,
|
||||
language: $this->language,
|
||||
settingsRepo: $this->settingsRepository,
|
||||
config: $this->config
|
||||
);
|
||||
$service->setPermissionService($perms);
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
/** PermissionService stub: currentUserCan returns the given value for every key. */
|
||||
private function permissions(bool $allow): \Leantime\Core\Auth\Permissions\PermissionService
|
||||
{
|
||||
return $this->make(\Leantime\Core\Auth\Permissions\PermissionService::class, [
|
||||
'currentUserCan' => fn () => $allow,
|
||||
'authorize' => fn () => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_get_event_returns_own_event(): void
|
||||
{
|
||||
$repo = $this->make(CalendarRepository::class, [
|
||||
'getEvent' => fn () => ['id' => 5, 'userId' => 1, 'description' => 'mine'],
|
||||
]);
|
||||
// can(MANAGE) = false, but the session user (1) owns the event.
|
||||
$service = $this->makeServiceWithPermissions($repo, $this->permissions(false));
|
||||
|
||||
$this->assertSame(1, $service->getEvent(5)['userId']);
|
||||
}
|
||||
|
||||
public function test_get_event_soft_denies_foreign_event_without_manage(): void
|
||||
{
|
||||
$repo = $this->make(CalendarRepository::class, [
|
||||
'getEvent' => fn () => ['id' => 5, 'userId' => 2, 'description' => 'someone else'],
|
||||
]);
|
||||
// Event owned by user 2; session user 1 lacks calendar.manage → soft-deny.
|
||||
$service = $this->makeServiceWithPermissions($repo, $this->permissions(false));
|
||||
|
||||
$this->assertFalse($service->getEvent(5));
|
||||
}
|
||||
|
||||
public function test_get_event_allows_foreign_event_with_manage(): void
|
||||
{
|
||||
$repo = $this->make(CalendarRepository::class, [
|
||||
'getEvent' => fn () => ['id' => 5, 'userId' => 2, 'description' => 'someone else'],
|
||||
]);
|
||||
// calendar.manage (admin+) is the cross-user override.
|
||||
$service = $this->makeServiceWithPermissions($repo, $this->permissions(true));
|
||||
|
||||
$this->assertSame(2, $service->getEvent(5)['userId']);
|
||||
}
|
||||
|
||||
public function test_get_external_calendar_ignores_passed_userid_and_uses_session(): void
|
||||
{
|
||||
$capturedUserId = null;
|
||||
$repo = $this->make(CalendarRepository::class, [
|
||||
'getExternalCalendar' => function ($id, $userId) use (&$capturedUserId) {
|
||||
$capturedUserId = $userId;
|
||||
|
||||
return ['id' => $id, 'url' => 'https://example.com/cal.ics'];
|
||||
},
|
||||
]);
|
||||
$service = $this->makeServiceWithPermissions($repo, $this->permissions(true));
|
||||
|
||||
// Caller passes a FOREIGN userId (99); the service must query as the session user (1).
|
||||
$service->getExternalCalendar(7, 99);
|
||||
|
||||
$this->assertSame(1, $capturedUserId, 'external calendar lookup must use the session user, not the passed id');
|
||||
}
|
||||
|
||||
public function test_get_my_external_calendars_ignores_passed_userid_and_uses_session(): void
|
||||
{
|
||||
$capturedUserId = null;
|
||||
$repo = $this->make(CalendarRepository::class, [
|
||||
'getMyExternalCalendars' => function ($userId) use (&$capturedUserId) {
|
||||
$capturedUserId = $userId;
|
||||
|
||||
return [];
|
||||
},
|
||||
]);
|
||||
$service = $this->makeServiceWithPermissions($repo, $this->permissions(true));
|
||||
|
||||
$service->getMyExternalCalendars(99);
|
||||
|
||||
$this->assertSame(1, $capturedUserId, 'calendar list must use the session user, not the passed id');
|
||||
}
|
||||
|
||||
public function test_rpc_surface_is_locked(): void
|
||||
{
|
||||
$reflect = fn (string $m) => (new \ReflectionMethod(\Leantime\Domain\Calendar\Services\Calendar::class, $m))->getDocComment();
|
||||
$isApi = fn (string $m) => ($d = $reflect($m)) !== false && preg_match('/^\s*\*\s*@api\b/m', $d) === 1;
|
||||
$gate = function (string $m): ?string {
|
||||
$attrs = (new \ReflectionMethod(\Leantime\Domain\Calendar\Services\Calendar::class, $m))
|
||||
->getAttributes(\Leantime\Core\Auth\Permissions\RequiresPermission::class);
|
||||
|
||||
return $attrs === [] ? null : $attrs[0]->newInstance()->permission;
|
||||
};
|
||||
|
||||
// The iCal feed methods are served by the public hash-authed route — never RPC-callable.
|
||||
$this->assertFalse($isApi('getIcalByHash'), 'getIcalByHash must not be @api');
|
||||
$this->assertFalse($isApi('getIcalByRequestToken'), 'getIcalByRequestToken must not be @api');
|
||||
|
||||
// Every @api method carries a calendar.* dispatch gate.
|
||||
$expected = [
|
||||
'getEvent' => 'calendar.view',
|
||||
'getExternalCalendar' => 'calendar.view',
|
||||
'getMyExternalCalendars' => 'calendar.view',
|
||||
'getCachedExternalCalendarContent' => 'calendar.view',
|
||||
'addEvent' => 'calendar.create',
|
||||
'addExternalCalendarUrl' => 'calendar.create',
|
||||
'editEvent' => 'calendar.edit',
|
||||
'editExternalCalendar' => 'calendar.edit',
|
||||
'patch' => 'calendar.edit',
|
||||
'delEvent' => 'calendar.delete',
|
||||
'deleteGCal' => 'calendar.delete',
|
||||
];
|
||||
foreach ($expected as $method => $permission) {
|
||||
$this->assertTrue($isApi($method), "$method should stay @api");
|
||||
$this->assertSame($permission, $gate($method), "$method must carry the $permission gate");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- calendar feed robustness ----------------------------------------
|
||||
|
||||
/**
|
||||
* Regression for #3536: a ticket with a valid planned start (editFrom) but an
|
||||
* empty/sentinel editTo used to throw in parseDbDateTime(), 500-ing the whole
|
||||
* "My Work" calendar feed and leaving the dashboard widget loading forever.
|
||||
* editTo must now be guarded and fall back to editFrom.
|
||||
*/
|
||||
public function test_get_calendar_survives_ticket_with_empty_edit_to(): void
|
||||
{
|
||||
$repo = $this->make(CalendarRepository::class, [
|
||||
'getAll' => fn () => [],
|
||||
]);
|
||||
|
||||
$ticket = [
|
||||
'id' => 10,
|
||||
'headline' => 'Planned task',
|
||||
'description' => '',
|
||||
'projectId' => 3,
|
||||
'status' => 3,
|
||||
'dateToFinish' => '', // invalid -> due-date block is skipped
|
||||
'editFrom' => '2026-06-18 09:00:00', // valid planned start
|
||||
'editTo' => '', // empty end date -> previously threw
|
||||
];
|
||||
|
||||
$tickets = $this->make(Tickets::class, [
|
||||
'getOpenUserTicketsThisWeekAndLater' => fn () => ['thisWeek' => ['tickets' => [$ticket]]],
|
||||
'getStatusLabels' => fn () => [],
|
||||
]);
|
||||
app()->instance(Tickets::class, $tickets);
|
||||
|
||||
$service = new \Leantime\Domain\Calendar\Services\Calendar(
|
||||
calendarRepo: $repo,
|
||||
language: $this->language,
|
||||
settingsRepo: $this->settingsRepository,
|
||||
config: $this->config
|
||||
);
|
||||
|
||||
$events = $service->getCalendar(1);
|
||||
|
||||
$editEvents = array_values(array_filter(
|
||||
$events,
|
||||
fn ($event) => ($event['dateContext'] ?? null) === 'edit'
|
||||
));
|
||||
|
||||
$this->assertCount(1, $editEvents, 'the planned-edit event should still be produced (no exception)');
|
||||
$this->assertSame('2026-06-18 09:00:00', $editEvents[0]['dateFrom']);
|
||||
$this->assertSame(
|
||||
'2026-06-18 09:00:00',
|
||||
$editEvents[0]['dateTo'],
|
||||
'editTo should fall back to editFrom when empty'
|
||||
);
|
||||
}
|
||||
}
|
||||
170
tests/Unit/app/Domain/Clients/Services/ClientsServiceTest.php
Normal file
170
tests/Unit/app/Domain/Clients/Services/ClientsServiceTest.php
Normal file
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Clients\Services;
|
||||
|
||||
use Leantime\Core\Exceptions\EntityExistsException;
|
||||
use Leantime\Core\Exceptions\MissingParameterException;
|
||||
use Leantime\Domain\Clients\Repositories\Clients as ClientRepository;
|
||||
use Leantime\Domain\Clients\Services\Clients as ClientService;
|
||||
use Leantime\Domain\Comments\Services\Comments as CommentService;
|
||||
use Leantime\Domain\Files\Services\Files as FileService;
|
||||
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
|
||||
use Leantime\Domain\Users\Repositories\Users as UserRepository;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the Clients service helpers extracted during the
|
||||
* thin-controller refactor (createClient, updateClient, removeUser,
|
||||
* getClientPageData).
|
||||
*/
|
||||
class ClientsServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/**
|
||||
* Builds a real Clients service, allowing each dependency to be
|
||||
* overridden with a stub so we can observe the persistence calls.
|
||||
*/
|
||||
private function makeService(
|
||||
?ClientRepository $clientRepo = null,
|
||||
?UserRepository $userRepo = null,
|
||||
?ProjectRepository $projectRepo = null,
|
||||
?CommentService $commentService = null,
|
||||
?FileService $fileService = null,
|
||||
): ClientService {
|
||||
return new ClientService(
|
||||
$projectRepo ?? $this->make(ProjectRepository::class),
|
||||
$clientRepo ?? $this->make(ClientRepository::class),
|
||||
$commentService ?? $this->make(CommentService::class),
|
||||
$fileService ?? $this->make(FileService::class),
|
||||
$userRepo ?? $this->make(UserRepository::class),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_create_client_returns_new_id_for_valid_unique_client(): void
|
||||
{
|
||||
$repo = $this->make(ClientRepository::class, [
|
||||
'isClient' => fn () => false,
|
||||
'addClient' => fn () => '42',
|
||||
]);
|
||||
|
||||
$id = $this->makeService(clientRepo: $repo)->createClient(['name' => 'Acme']);
|
||||
|
||||
$this->assertSame(42, $id);
|
||||
}
|
||||
|
||||
public function test_create_client_throws_when_name_missing(): void
|
||||
{
|
||||
$addCalls = 0;
|
||||
$repo = $this->make(ClientRepository::class, [
|
||||
'isClient' => fn () => false,
|
||||
'addClient' => function () use (&$addCalls) {
|
||||
$addCalls++;
|
||||
|
||||
return 1;
|
||||
},
|
||||
]);
|
||||
|
||||
$this->expectException(MissingParameterException::class);
|
||||
|
||||
try {
|
||||
$this->makeService(clientRepo: $repo)->createClient(['name' => '']);
|
||||
} finally {
|
||||
$this->assertSame(0, $addCalls, 'An invalid client must never reach the repository');
|
||||
}
|
||||
}
|
||||
|
||||
public function test_create_client_throws_when_client_already_exists(): void
|
||||
{
|
||||
$addCalls = 0;
|
||||
$repo = $this->make(ClientRepository::class, [
|
||||
'isClient' => fn () => true,
|
||||
'addClient' => function () use (&$addCalls) {
|
||||
$addCalls++;
|
||||
|
||||
return 1;
|
||||
},
|
||||
]);
|
||||
|
||||
$this->expectException(EntityExistsException::class);
|
||||
|
||||
try {
|
||||
$this->makeService(clientRepo: $repo)->createClient(['name' => 'Acme']);
|
||||
} finally {
|
||||
$this->assertSame(0, $addCalls, 'A duplicate client must never be persisted');
|
||||
}
|
||||
}
|
||||
|
||||
public function test_update_client_throws_when_name_missing(): void
|
||||
{
|
||||
$editCalls = 0;
|
||||
$repo = $this->make(ClientRepository::class, [
|
||||
'editClient' => function () use (&$editCalls) {
|
||||
$editCalls++;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$this->expectException(MissingParameterException::class);
|
||||
|
||||
try {
|
||||
$this->makeService(clientRepo: $repo)->updateClient(['id' => 5, 'name' => '']);
|
||||
} finally {
|
||||
$this->assertSame(0, $editCalls, 'An invalid update must never reach the repository');
|
||||
}
|
||||
}
|
||||
|
||||
public function test_update_client_persists_valid_values(): void
|
||||
{
|
||||
$captured = null;
|
||||
$repo = $this->make(ClientRepository::class, [
|
||||
'editClient' => function ($values, $id) use (&$captured) {
|
||||
$captured = ['values' => $values, 'id' => $id];
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService(clientRepo: $repo)->updateClient(['id' => 5, 'name' => 'Acme']);
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertSame(5, $captured['id']);
|
||||
$this->assertSame('Acme', $captured['values']['name']);
|
||||
}
|
||||
|
||||
public function test_remove_user_returns_false_for_missing_ids(): void
|
||||
{
|
||||
$removeCalls = 0;
|
||||
$userRepo = $this->make(UserRepository::class, [
|
||||
'removeFromClient' => function () use (&$removeCalls) {
|
||||
$removeCalls++;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$service = $this->makeService(userRepo: $userRepo);
|
||||
|
||||
$this->assertFalse($service->removeUser(0, 5));
|
||||
$this->assertFalse($service->removeUser(5, 0));
|
||||
$this->assertSame(0, $removeCalls, 'Guarded calls must not hit the repository');
|
||||
}
|
||||
|
||||
public function test_remove_user_delegates_to_user_repository(): void
|
||||
{
|
||||
$removedUserId = null;
|
||||
$userRepo = $this->make(UserRepository::class, [
|
||||
'removeFromClient' => function ($userId) use (&$removedUserId) {
|
||||
$removedUserId = $userId;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService(userRepo: $userRepo)->removeUser(3, 7);
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertSame(7, $removedUserId);
|
||||
}
|
||||
}
|
||||
330
tests/Unit/app/Domain/Comments/Services/CommentsServiceTest.php
Normal file
330
tests/Unit/app/Domain/Comments/Services/CommentsServiceTest.php
Normal file
@@ -0,0 +1,330 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Comments\Services;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\PermissionService;
|
||||
use Leantime\Core\Exceptions\AuthorizationException;
|
||||
use Leantime\Core\Language as LanguageCore;
|
||||
use Leantime\Domain\Comments\Repositories\Comments as CommentRepository;
|
||||
use Leantime\Domain\Comments\Services\Comments;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Leantime\Domain\Reactions\Services\Reactions as ReactionsService;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the Comments service: reaction orchestration plus the project-scoped
|
||||
* authorization fences (comments are read/moderated against the host entity's REAL project).
|
||||
*/
|
||||
class CommentsServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/** The session user used across the reaction tests. */
|
||||
private const SESSION_USER = 5;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
session(['userdata.id' => self::SESSION_USER]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the service. By default the comment repository resolves a real (ticket) comment in
|
||||
* project 9 and the permission engine allows everything; pass overrides to exercise denials.
|
||||
*/
|
||||
private function makeService(
|
||||
ReactionsService $reactionsService,
|
||||
?CommentRepository $repo = null,
|
||||
?PermissionService $permissions = null,
|
||||
): Comments {
|
||||
$service = new Comments(
|
||||
$repo ?? $this->defaultRepo(),
|
||||
$this->make(ProjectService::class),
|
||||
$this->make(LanguageCore::class),
|
||||
$reactionsService,
|
||||
);
|
||||
$service->setPermissionService($permissions ?? $this->allowingPermissions());
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
private function defaultRepo(): CommentRepository
|
||||
{
|
||||
return $this->make(CommentRepository::class, [
|
||||
'getComment' => fn () => ['id' => 99, 'userId' => self::SESSION_USER, 'module' => 'ticket', 'moduleId' => 1],
|
||||
'resolveModuleProjectId' => fn () => 9,
|
||||
]);
|
||||
}
|
||||
|
||||
private function allowingPermissions(): PermissionService
|
||||
{
|
||||
return $this->make(PermissionService::class, [
|
||||
'authorize' => fn () => null,
|
||||
'currentUserCan' => fn () => true,
|
||||
]);
|
||||
}
|
||||
|
||||
private function denyingPermissions(): PermissionService
|
||||
{
|
||||
return $this->make(PermissionService::class, [
|
||||
'authorize' => function (): void {
|
||||
throw new AuthorizationException;
|
||||
},
|
||||
'currentUserCan' => fn () => false,
|
||||
]);
|
||||
}
|
||||
|
||||
private function noopReactions(): ReactionsService
|
||||
{
|
||||
return $this->make(ReactionsService::class, []);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Reaction orchestration (existing behaviour, now session-pinned).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public function test_toggle_rejects_unknown_reaction_type(): void
|
||||
{
|
||||
$added = false;
|
||||
$removed = false;
|
||||
|
||||
$reactionsService = $this->make(ReactionsService::class, [
|
||||
'getReactionType' => fn () => false,
|
||||
'addReaction' => function () use (&$added) {
|
||||
$added = true;
|
||||
|
||||
return true;
|
||||
},
|
||||
'removeReaction' => function () use (&$removed) {
|
||||
$removed = true;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService($reactionsService)->toggleCommentReaction(self::SESSION_USER, 99, 'bogus');
|
||||
|
||||
$this->assertFalse($result);
|
||||
$this->assertFalse($added, 'No reaction should be added for an unknown type');
|
||||
$this->assertFalse($removed, 'No reaction should be removed for an unknown type');
|
||||
}
|
||||
|
||||
public function test_toggle_off_removes_existing_same_reaction(): void
|
||||
{
|
||||
$removeCalls = [];
|
||||
$added = false;
|
||||
|
||||
$reactionsService = $this->make(ReactionsService::class, [
|
||||
'getReactionType' => fn () => 'positive',
|
||||
'getUserReactions' => fn () => [['reaction' => 'thumbsup']],
|
||||
'removeReaction' => function ($userId, $module, $moduleId, $reaction) use (&$removeCalls) {
|
||||
$removeCalls[] = [$userId, $module, $moduleId, $reaction];
|
||||
|
||||
return true;
|
||||
},
|
||||
'addReaction' => function () use (&$added) {
|
||||
$added = true;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService($reactionsService)->toggleCommentReaction(self::SESSION_USER, 99, 'thumbsup');
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertFalse($added, 'Toggling off should not add a reaction');
|
||||
$this->assertSame([[self::SESSION_USER, 'comment', 99, 'thumbsup']], $removeCalls);
|
||||
}
|
||||
|
||||
public function test_toggle_on_replaces_existing_sentiment(): void
|
||||
{
|
||||
$removeCalls = [];
|
||||
$addCalls = [];
|
||||
|
||||
$reactionsService = $this->make(ReactionsService::class, [
|
||||
'getReactionType' => fn () => 'positive',
|
||||
'getUserReactions' => function ($userId, $module, $moduleId, $reaction = '') {
|
||||
if ($reaction !== '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [['reaction' => 'thumbsdown']];
|
||||
},
|
||||
'removeReaction' => function ($userId, $module, $moduleId, $reaction) use (&$removeCalls) {
|
||||
$removeCalls[] = [$userId, $module, $moduleId, $reaction];
|
||||
|
||||
return true;
|
||||
},
|
||||
'addReaction' => function ($userId, $module, $moduleId, $reaction) use (&$addCalls) {
|
||||
$addCalls[] = [$userId, $module, $moduleId, $reaction];
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService($reactionsService)->toggleCommentReaction(self::SESSION_USER, 99, 'thumbsup');
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertSame([[self::SESSION_USER, 'comment', 99, 'thumbsdown']], $removeCalls);
|
||||
$this->assertSame([[self::SESSION_USER, 'comment', 99, 'thumbsup']], $addCalls);
|
||||
}
|
||||
|
||||
public function test_toggle_on_with_no_existing_reactions_just_adds(): void
|
||||
{
|
||||
$removeCalls = [];
|
||||
$addCalls = [];
|
||||
|
||||
$reactionsService = $this->make(ReactionsService::class, [
|
||||
'getReactionType' => fn () => 'positive',
|
||||
'getUserReactions' => fn () => false,
|
||||
'removeReaction' => function (...$args) use (&$removeCalls) {
|
||||
$removeCalls[] = $args;
|
||||
|
||||
return true;
|
||||
},
|
||||
'addReaction' => function ($userId, $module, $moduleId, $reaction) use (&$addCalls) {
|
||||
$addCalls[] = [$userId, $module, $moduleId, $reaction];
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService($reactionsService)->toggleCommentReaction(self::SESSION_USER, 99, 'thumbsup');
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertSame([], $removeCalls, 'Nothing to remove when there are no existing reactions');
|
||||
$this->assertSame([[self::SESSION_USER, 'comment', 99, 'thumbsup']], $addCalls);
|
||||
}
|
||||
|
||||
public function test_get_comment_reactions_flattens_user_reaction_codes(): void
|
||||
{
|
||||
$reactionsService = $this->make(ReactionsService::class, [
|
||||
'getEntityReactionsWithUsers' => fn () => ['thumbsup' => ['count' => 2]],
|
||||
'getUserReactions' => fn () => [
|
||||
['reaction' => 'thumbsup'],
|
||||
['reaction' => 'heart'],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->makeService($reactionsService)->getCommentReactions(99, 5);
|
||||
|
||||
$this->assertSame(['thumbsup' => ['count' => 2]], $result['reactions']);
|
||||
$this->assertSame(['thumbsup', 'heart'], $result['userReactions']);
|
||||
}
|
||||
|
||||
public function test_get_comment_reactions_handles_anonymous_user(): void
|
||||
{
|
||||
$reactionsService = $this->make(ReactionsService::class, [
|
||||
'getEntityReactionsWithUsers' => fn () => [],
|
||||
'getUserReactions' => fn () => [['reaction' => 'thumbsup']],
|
||||
]);
|
||||
|
||||
$result = $this->makeService($reactionsService)->getCommentReactions(99, 0);
|
||||
|
||||
$this->assertSame([], $result['reactions']);
|
||||
$this->assertSame([], $result['userReactions']);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Project-scoped authorization fences (the IDOR hardening).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public function test_toggle_reaction_uses_session_user_not_caller_supplied_id(): void
|
||||
{
|
||||
// A caller passes someone else's id; the service must react as the SESSION user only.
|
||||
$addCalls = [];
|
||||
$reactionsService = $this->make(ReactionsService::class, [
|
||||
'getReactionType' => fn () => 'positive',
|
||||
'getUserReactions' => fn () => false,
|
||||
'addReaction' => function ($userId, $module, $moduleId, $reaction) use (&$addCalls) {
|
||||
$addCalls[] = [$userId, $module, $moduleId, $reaction];
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$this->makeService($reactionsService)->toggleCommentReaction(999, 99, 'thumbsup');
|
||||
|
||||
$this->assertSame([[self::SESSION_USER, 'comment', 99, 'thumbsup']], $addCalls, 'Reaction must use the session user, not the caller-supplied id');
|
||||
}
|
||||
|
||||
public function test_toggle_reaction_is_denied_for_a_foreign_project(): void
|
||||
{
|
||||
// Valid reaction type so the method reaches the project fence (not the type guard). A denied
|
||||
// cross-project comment returns false — same as a missing comment, so no existence oracle.
|
||||
$reactions = $this->make(ReactionsService::class, ['getReactionType' => fn () => 'positive']);
|
||||
$service = $this->makeService($reactions, $this->defaultRepo(), $this->denyingPermissions());
|
||||
|
||||
$this->assertFalse($service->toggleCommentReaction(self::SESSION_USER, 99, 'thumbsup'));
|
||||
}
|
||||
|
||||
public function test_get_comments_is_denied_for_a_foreign_project(): void
|
||||
{
|
||||
// getComments resolves the host entity's project and authorizes VIEW there; a denying
|
||||
// engine must throw before any comment data is returned.
|
||||
$service = $this->makeService($this->noopReactions(), $this->defaultRepo(), $this->denyingPermissions());
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$service->getComments('ticket', 123);
|
||||
}
|
||||
|
||||
public function test_delete_comment_denies_non_author_moderation_cross_project(): void
|
||||
{
|
||||
// Comment belongs to another user; the session user is NOT a moderator in the comment's
|
||||
// project (denying engine) -> deleteComment must refuse and never reach the repo delete.
|
||||
$repo = $this->make(CommentRepository::class, [
|
||||
'getComment' => fn () => ['id' => 99, 'userId' => 7, 'module' => 'ticket', 'moduleId' => 1],
|
||||
'resolveModuleProjectId' => fn () => 9,
|
||||
'deleteComment' => function (): bool {
|
||||
throw new \RuntimeException('delete must not run when moderation is denied');
|
||||
},
|
||||
]);
|
||||
$service = $this->makeService($this->noopReactions(), $repo, $this->denyingPermissions());
|
||||
|
||||
$this->assertFalse($service->deleteComment(99));
|
||||
}
|
||||
|
||||
public function test_delete_comment_allows_the_author_without_moderation(): void
|
||||
{
|
||||
$deleted = null;
|
||||
$repo = $this->make(CommentRepository::class, [
|
||||
// Authored by the session user -> author branch, no moderation check needed.
|
||||
'getComment' => fn () => ['id' => 99, 'userId' => self::SESSION_USER, 'module' => 'ticket', 'moduleId' => 1],
|
||||
'resolveModuleProjectId' => fn () => 9,
|
||||
'deleteComment' => function ($id) use (&$deleted): bool {
|
||||
$deleted = $id;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
// Denying engine proves the author path does NOT depend on comments.moderate.
|
||||
$service = $this->makeService($this->noopReactions(), $repo, $this->denyingPermissions());
|
||||
|
||||
$this->assertTrue($service->deleteComment(99));
|
||||
$this->assertSame(99, $deleted);
|
||||
}
|
||||
|
||||
public function test_get_comment_reactions_is_denied_for_a_foreign_project(): void
|
||||
{
|
||||
// A denied cross-project comment returns the SAME empty payload as a missing comment
|
||||
// (soft-deny), so reactor identities/sentiment never leak AND missing vs unauthorized are
|
||||
// indistinguishable — no commentId existence oracle.
|
||||
$service = $this->makeService($this->noopReactions(), $this->defaultRepo(), $this->denyingPermissions());
|
||||
|
||||
$this->assertSame(['reactions' => [], 'userReactions' => []], $service->getCommentReactions(99, self::SESSION_USER));
|
||||
}
|
||||
|
||||
public function test_get_comment_reactions_returns_empty_for_missing_comment(): void
|
||||
{
|
||||
// A missing comment yields the same empty payload a DENIED comment does (see above), so the
|
||||
// two are indistinguishable — no commentId existence oracle.
|
||||
$repo = $this->make(CommentRepository::class, [
|
||||
'getComment' => fn () => false,
|
||||
'resolveModuleProjectId' => fn () => 9,
|
||||
]);
|
||||
$service = $this->makeService($this->noopReactions(), $repo, $this->denyingPermissions());
|
||||
|
||||
$this->assertSame(['reactions' => [], 'userReactions' => []], $service->getCommentReactions(404, self::SESSION_USER));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Connector\Services;
|
||||
|
||||
use Leantime\Domain\Connector\Models\Integration as IntegrationModel;
|
||||
use Leantime\Domain\Connector\Repositories\Integrations as IntegrationsRepo;
|
||||
use Leantime\Domain\Connector\Repositories\LeantimeEntities;
|
||||
use Leantime\Domain\Connector\Services\Integrations;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the integration-wizard orchestration extracted from the
|
||||
* Connector\Integration controller into the Integrations service.
|
||||
*/
|
||||
class IntegrationsServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/**
|
||||
* Builds the service with a real (DB-free) LeantimeEntities repo and a
|
||||
* stubbed integration repository.
|
||||
*/
|
||||
private function makeService(array $repoOverrides = []): Integrations
|
||||
{
|
||||
return new Integrations(
|
||||
$this->make(IntegrationsRepo::class, $repoOverrides),
|
||||
new LeantimeEntities,
|
||||
);
|
||||
}
|
||||
|
||||
public function test_get_entity_fields_returns_field_map_for_known_entity(): void
|
||||
{
|
||||
$fields = $this->makeService()->getEntityFields('tickets');
|
||||
|
||||
$this->assertArrayHasKey('headline', $fields);
|
||||
$this->assertSame('Title', $fields['headline']['name']);
|
||||
}
|
||||
|
||||
public function test_get_entity_fields_returns_empty_array_for_unknown_entity(): void
|
||||
{
|
||||
$this->assertSame([], $this->makeService()->getEntityFields('does-not-exist'));
|
||||
}
|
||||
|
||||
public function test_get_available_entities_includes_core_entities(): void
|
||||
{
|
||||
$entities = $this->makeService()->getAvailableEntities();
|
||||
|
||||
$this->assertArrayHasKey('tickets', $entities);
|
||||
$this->assertArrayHasKey('projects', $entities);
|
||||
$this->assertArrayHasKey('users', $entities);
|
||||
}
|
||||
|
||||
public function test_resolve_provider_fields_uses_stored_fields_when_present(): void
|
||||
{
|
||||
$integration = new IntegrationModel;
|
||||
$integration->fields = 'colA,colB,colC';
|
||||
|
||||
$provider = new class
|
||||
{
|
||||
public function getFields(): array
|
||||
{
|
||||
return ['providerOnly'];
|
||||
}
|
||||
};
|
||||
|
||||
$result = $this->makeService()->resolveProviderFields($integration, $provider);
|
||||
|
||||
$this->assertSame(['colA', 'colB', 'colC'], $result);
|
||||
}
|
||||
|
||||
public function test_resolve_provider_fields_falls_back_to_provider(): void
|
||||
{
|
||||
$integration = new IntegrationModel;
|
||||
$integration->fields = '';
|
||||
|
||||
$provider = new class
|
||||
{
|
||||
public function getFields(): array
|
||||
{
|
||||
return ['fieldFromProvider'];
|
||||
}
|
||||
};
|
||||
|
||||
$result = $this->makeService()->resolveProviderFields($integration, $provider);
|
||||
|
||||
$this->assertSame(['fieldFromProvider'], $result);
|
||||
}
|
||||
|
||||
public function test_resolve_import_entity_uses_request_value_and_persists(): void
|
||||
{
|
||||
session()->forget('currentImportEntity');
|
||||
|
||||
$patchCalls = [];
|
||||
$service = $this->makeService([
|
||||
'patch' => function ($id, $params) use (&$patchCalls) {
|
||||
$patchCalls[] = [$id, $params];
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$integration = new IntegrationModel;
|
||||
$integration->id = 42;
|
||||
|
||||
$entity = $service->resolveImportEntity(['leantimeEntities' => 'tickets'], $integration);
|
||||
|
||||
$this->assertSame('tickets', $entity);
|
||||
$this->assertSame('tickets', $integration->entity);
|
||||
$this->assertSame('tickets', session('currentImportEntity'));
|
||||
$this->assertSame([[42, ['entity' => 'tickets']]], $patchCalls);
|
||||
}
|
||||
|
||||
public function test_resolve_import_entity_falls_back_to_session(): void
|
||||
{
|
||||
session(['currentImportEntity' => 'projects']);
|
||||
|
||||
$patchCalls = [];
|
||||
$service = $this->makeService([
|
||||
'patch' => function ($id, $params) use (&$patchCalls) {
|
||||
$patchCalls[] = [$id, $params];
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$integration = new IntegrationModel;
|
||||
$integration->id = 7;
|
||||
|
||||
$entity = $service->resolveImportEntity([], $integration);
|
||||
|
||||
$this->assertSame('projects', $entity);
|
||||
$this->assertSame('projects', $integration->entity);
|
||||
$this->assertSame([[7, ['entity' => 'projects']]], $patchCalls);
|
||||
}
|
||||
|
||||
public function test_resolve_import_entity_returns_null_when_unresolvable(): void
|
||||
{
|
||||
session(['currentImportEntity' => '']);
|
||||
|
||||
$patched = false;
|
||||
$service = $this->makeService([
|
||||
'patch' => function () use (&$patched) {
|
||||
$patched = true;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$integration = new IntegrationModel;
|
||||
$integration->id = 1;
|
||||
|
||||
$entity = $service->resolveImportEntity([], $integration);
|
||||
|
||||
$this->assertNull($entity);
|
||||
$this->assertFalse($patched, 'No record should be patched when the entity cannot be resolved');
|
||||
}
|
||||
|
||||
public function test_get_cached_import_payload_decodes_session_serialized_data(): void
|
||||
{
|
||||
$fields = [['sourceField' => 'a', 'leantimeField' => 'headline']];
|
||||
$values = [['a' => 'Hello']];
|
||||
|
||||
session(['serFields' => serialize($fields)]);
|
||||
session(['serValues' => serialize($values)]);
|
||||
|
||||
$payload = $this->makeService()->getCachedImportPayload();
|
||||
|
||||
$this->assertSame($fields, $payload['fields']);
|
||||
$this->assertSame($values, $payload['values']);
|
||||
}
|
||||
|
||||
public function test_get_cached_import_payload_defaults_to_empty_arrays(): void
|
||||
{
|
||||
session()->forget('serFields');
|
||||
session()->forget('serValues');
|
||||
|
||||
$payload = $this->makeService()->getCachedImportPayload();
|
||||
|
||||
$this->assertSame([], $payload['fields']);
|
||||
$this->assertSame([], $payload['values']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\ContentTemplates\Models;
|
||||
|
||||
use Leantime\Domain\ContentTemplates\Models\ContentTemplate;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the ContentTemplate value object.
|
||||
*
|
||||
* Two responsibilities:
|
||||
* - From a parsed YAML array, pull metadata fields and the appliesTo-keyed payload.
|
||||
* - Mark itself as "usable" only when key, appliesTo, and a non-empty payload are present.
|
||||
*/
|
||||
class ContentTemplateTest extends TestCase
|
||||
{
|
||||
public function test_from_array_extracts_canvas_payload_under_applies_to_key(): void
|
||||
{
|
||||
$tpl = ContentTemplate::fromArray([
|
||||
'key' => 'education-k12',
|
||||
'title' => 'K-12 Education Program',
|
||||
'description' => 'After-school tutoring.',
|
||||
'appliesTo' => 'logicmodel',
|
||||
'sector' => 'education',
|
||||
'icon' => 'fa-graduation-cap',
|
||||
'author' => 'Leantime',
|
||||
'version' => '1.0.0',
|
||||
'license' => 'CC0',
|
||||
'logicmodel' => [
|
||||
'items' => [
|
||||
['box' => 'lm_inputs', 'title' => 'Funding', 'description' => 'Annual grants.'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertSame('education-k12', $tpl->key);
|
||||
$this->assertSame('K-12 Education Program', $tpl->title);
|
||||
$this->assertSame('logicmodel', $tpl->appliesTo);
|
||||
$this->assertSame('education', $tpl->sector);
|
||||
$this->assertSame('fa-graduation-cap', $tpl->icon);
|
||||
$this->assertSame('Leantime', $tpl->author);
|
||||
$this->assertSame('1.0.0', $tpl->version);
|
||||
$this->assertSame('CC0', $tpl->license);
|
||||
$this->assertCount(1, $tpl->payload['items']);
|
||||
$this->assertTrue($tpl->isUsable());
|
||||
}
|
||||
|
||||
public function test_from_array_extracts_wiki_payload_under_applies_to_key(): void
|
||||
{
|
||||
$tpl = ContentTemplate::fromArray([
|
||||
'key' => 'meeting-notes',
|
||||
'title' => 'Meeting Notes',
|
||||
'description' => 'Standard meeting template.',
|
||||
'appliesTo' => 'wiki',
|
||||
'wiki' => [
|
||||
'articles' => [
|
||||
['title' => 'Notes', 'content' => '<h1>Hi</h1>'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertSame('wiki', $tpl->appliesTo);
|
||||
$this->assertCount(1, $tpl->payload['articles']);
|
||||
$this->assertTrue($tpl->isUsable());
|
||||
}
|
||||
|
||||
public function test_optional_fields_default_to_null_when_missing(): void
|
||||
{
|
||||
$tpl = ContentTemplate::fromArray([
|
||||
'key' => 'x',
|
||||
'title' => 'X',
|
||||
'description' => '',
|
||||
'appliesTo' => 'logicmodel',
|
||||
'logicmodel' => ['items' => [['box' => 'a']]],
|
||||
]);
|
||||
|
||||
$this->assertNull($tpl->sector);
|
||||
$this->assertNull($tpl->icon);
|
||||
$this->assertNull($tpl->author);
|
||||
$this->assertNull($tpl->version);
|
||||
$this->assertNull($tpl->license);
|
||||
}
|
||||
|
||||
public function test_is_usable_returns_false_for_missing_key_or_applies_to_or_empty_payload(): void
|
||||
{
|
||||
$missingKey = ContentTemplate::fromArray([
|
||||
'title' => 'X',
|
||||
'description' => '',
|
||||
'appliesTo' => 'logicmodel',
|
||||
'logicmodel' => ['items' => [['box' => 'a']]],
|
||||
]);
|
||||
$missingAppliesTo = ContentTemplate::fromArray([
|
||||
'key' => 'x',
|
||||
'title' => 'X',
|
||||
'description' => '',
|
||||
]);
|
||||
$emptyPayload = ContentTemplate::fromArray([
|
||||
'key' => 'x',
|
||||
'title' => 'X',
|
||||
'description' => '',
|
||||
'appliesTo' => 'logicmodel',
|
||||
]);
|
||||
|
||||
$this->assertFalse($missingKey->isUsable());
|
||||
$this->assertFalse($missingAppliesTo->isUsable());
|
||||
$this->assertFalse($emptyPayload->isUsable());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\ContentTemplates\Services\Appliers;
|
||||
|
||||
use Leantime\Core\Db\Db as DbCore;
|
||||
use Leantime\Domain\ContentTemplates\Models\ContentTemplate;
|
||||
use Leantime\Domain\ContentTemplates\Services\Appliers\CanvasItemsApplier;
|
||||
use Leantime\Domain\ContentTemplates\Services\Appliers\WikiApplier;
|
||||
use Leantime\Domain\ContentTemplates\Services\ContentTemplateRegistry;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the appliers' supports() routing logic and early-return
|
||||
* safety. Actual DB writes are exercised in integration tests once Phase 2
|
||||
* wires real templates; here we lock in the routing contract.
|
||||
*/
|
||||
class AppliersTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
public function test_wiki_applier_supports_only_wiki(): void
|
||||
{
|
||||
$applier = new WikiApplier($this->makeDbCore());
|
||||
|
||||
$this->assertTrue($applier->supports('wiki'));
|
||||
$this->assertFalse($applier->supports('logicmodel'));
|
||||
$this->assertFalse($applier->supports('goal'));
|
||||
$this->assertFalse($applier->supports(''));
|
||||
}
|
||||
|
||||
public function test_canvas_applier_supports_any_non_wiki_non_empty_applies_to(): void
|
||||
{
|
||||
$applier = new CanvasItemsApplier($this->makeDbCore());
|
||||
|
||||
$this->assertTrue($applier->supports('logicmodel'));
|
||||
$this->assertTrue($applier->supports('goal'));
|
||||
$this->assertTrue($applier->supports('leancanvas'));
|
||||
$this->assertTrue($applier->supports('swot'));
|
||||
$this->assertTrue($applier->supports('any-future-canvas-type'));
|
||||
|
||||
$this->assertFalse($applier->supports('wiki'));
|
||||
$this->assertFalse($applier->supports(''));
|
||||
}
|
||||
|
||||
public function test_canvas_applier_returns_zero_for_invalid_target_id(): void
|
||||
{
|
||||
$applier = new CanvasItemsApplier($this->makeDbCore());
|
||||
|
||||
$this->assertSame(0, $applier->apply(0, $this->makeCanvasTemplate()));
|
||||
$this->assertSame(0, $applier->apply(-1, $this->makeCanvasTemplate()));
|
||||
}
|
||||
|
||||
public function test_canvas_applier_returns_zero_for_unusable_template(): void
|
||||
{
|
||||
$applier = new CanvasItemsApplier($this->makeDbCore());
|
||||
|
||||
$unusable = ContentTemplate::fromArray([
|
||||
'key' => '',
|
||||
'title' => 'X',
|
||||
'description' => '',
|
||||
'appliesTo' => 'logicmodel',
|
||||
]);
|
||||
|
||||
$this->assertSame(0, $applier->apply(42, $unusable));
|
||||
}
|
||||
|
||||
public function test_canvas_applier_returns_zero_for_empty_items_payload(): void
|
||||
{
|
||||
$applier = new CanvasItemsApplier($this->makeDbCore());
|
||||
|
||||
$emptyItems = ContentTemplate::fromArray([
|
||||
'key' => 'empty',
|
||||
'title' => 'Empty',
|
||||
'description' => '',
|
||||
'appliesTo' => 'logicmodel',
|
||||
'logicmodel' => ['items' => []],
|
||||
]);
|
||||
|
||||
$this->assertSame(0, $applier->apply(42, $emptyItems));
|
||||
}
|
||||
|
||||
public function test_wiki_applier_returns_zero_for_invalid_target_id(): void
|
||||
{
|
||||
$applier = new WikiApplier($this->makeDbCore());
|
||||
|
||||
$this->assertSame(0, $applier->apply(0, $this->makeWikiTemplate()));
|
||||
}
|
||||
|
||||
public function test_wiki_applier_returns_zero_for_empty_articles_payload(): void
|
||||
{
|
||||
$applier = new WikiApplier($this->makeDbCore());
|
||||
|
||||
$emptyArticles = ContentTemplate::fromArray([
|
||||
'key' => 'empty',
|
||||
'title' => 'Empty',
|
||||
'description' => '',
|
||||
'appliesTo' => 'wiki',
|
||||
'wiki' => ['articles' => []],
|
||||
]);
|
||||
|
||||
$this->assertSame(0, $applier->apply(42, $emptyArticles));
|
||||
}
|
||||
|
||||
public function test_registry_applier_for_falls_back_to_supports_when_no_explicit_binding(): void
|
||||
{
|
||||
$registry = new ContentTemplateRegistry;
|
||||
$canvas = new CanvasItemsApplier($this->makeDbCore());
|
||||
$wiki = new WikiApplier($this->makeDbCore());
|
||||
|
||||
// Bind WikiApplier on 'wiki' and CanvasItemsApplier on 'logicmodel'.
|
||||
// Ask the registry for 'cp' (a canvas type that nobody explicitly
|
||||
// bound). The fallback should find CanvasItemsApplier via supports().
|
||||
$registry->registerApplier('wiki', $wiki);
|
||||
$registry->registerApplier('logicmodel', $canvas);
|
||||
|
||||
$this->assertSame($canvas, $registry->applierFor('cp'));
|
||||
$this->assertSame($canvas, $registry->applierFor('swot'));
|
||||
$this->assertSame($wiki, $registry->applierFor('wiki'));
|
||||
$this->assertSame($canvas, $registry->applierFor('logicmodel'));
|
||||
}
|
||||
|
||||
public function test_registry_applier_for_returns_null_when_no_applier_supports_type(): void
|
||||
{
|
||||
$registry = new ContentTemplateRegistry;
|
||||
$registry->registerApplier('wiki', new WikiApplier($this->makeDbCore()));
|
||||
|
||||
// 'logicmodel' isn't bound and WikiApplier doesn't support it.
|
||||
$this->assertNull($registry->applierFor('logicmodel'));
|
||||
}
|
||||
|
||||
private function makeDbCore(): DbCore
|
||||
{
|
||||
// The supports() / early-return paths never call the connection, so a
|
||||
// bare stub is enough. Methods that DO write are exercised in
|
||||
// integration tests (Phase 2+).
|
||||
return $this->make(DbCore::class);
|
||||
}
|
||||
|
||||
private function makeCanvasTemplate(): ContentTemplate
|
||||
{
|
||||
return ContentTemplate::fromArray([
|
||||
'key' => 'k',
|
||||
'title' => 'T',
|
||||
'description' => '',
|
||||
'appliesTo' => 'logicmodel',
|
||||
'logicmodel' => [
|
||||
'items' => [
|
||||
['box' => 'lm_inputs', 'title' => 'A', 'description' => 'aa'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function makeWikiTemplate(): ContentTemplate
|
||||
{
|
||||
return ContentTemplate::fromArray([
|
||||
'key' => 'k',
|
||||
'title' => 'T',
|
||||
'description' => '',
|
||||
'appliesTo' => 'wiki',
|
||||
'wiki' => [
|
||||
'articles' => [
|
||||
['title' => 'A', 'content' => '<p>aa</p>'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\ContentTemplates\Services;
|
||||
|
||||
use Leantime\Domain\ContentTemplates\Models\ContentTemplate;
|
||||
use Leantime\Domain\ContentTemplates\Services\ContentTemplateRegistry;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for ContentTemplateRegistry.
|
||||
*
|
||||
* Sets up a tmp library root containing one logicmodel template and one wiki
|
||||
* template, then exercises load / forAppliesTo / get / overrides.
|
||||
*/
|
||||
class ContentTemplateRegistryTest extends TestCase
|
||||
{
|
||||
private string $tmpRoot;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->tmpRoot = sys_get_temp_dir().'/ct-registry-test-'.uniqid();
|
||||
mkdir($this->tmpRoot.'/logicmodel', 0o777, true);
|
||||
mkdir($this->tmpRoot.'/wiki', 0o777, true);
|
||||
|
||||
file_put_contents($this->tmpRoot.'/logicmodel/sample.yaml', <<<'YAML'
|
||||
key: "sample"
|
||||
title: "Sample LM"
|
||||
description: "Test fixture."
|
||||
appliesTo: "logicmodel"
|
||||
sector: "test"
|
||||
logicmodel:
|
||||
items:
|
||||
- box: "lm_inputs"
|
||||
title: "Item"
|
||||
description: "Desc"
|
||||
YAML);
|
||||
|
||||
file_put_contents($this->tmpRoot.'/wiki/notes.yaml', <<<'YAML'
|
||||
key: "notes"
|
||||
title: "Notes"
|
||||
description: "Wiki test."
|
||||
appliesTo: "wiki"
|
||||
wiki:
|
||||
articles:
|
||||
- title: "Hello"
|
||||
content: "<p>Hi</p>"
|
||||
YAML);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->rmrf($this->tmpRoot);
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_for_applies_to_returns_templates_under_that_bucket(): void
|
||||
{
|
||||
$registry = new ContentTemplateRegistry;
|
||||
$registry->registerLibraryRoot($this->tmpRoot);
|
||||
|
||||
$lm = $registry->forAppliesTo('logicmodel');
|
||||
$wiki = $registry->forAppliesTo('wiki');
|
||||
|
||||
// The registry constructor auto-registers the core library root, so
|
||||
// built-in templates show up alongside the tmp ones. Pin contract on
|
||||
// "tmp fixtures are present and well-shaped" rather than exact count.
|
||||
$this->assertArrayHasKey('sample', $lm);
|
||||
$this->assertInstanceOf(ContentTemplate::class, $lm['sample']);
|
||||
$this->assertSame('logicmodel', $lm['sample']->appliesTo);
|
||||
|
||||
$this->assertArrayHasKey('notes', $wiki);
|
||||
$this->assertSame('wiki', $wiki['notes']->appliesTo);
|
||||
}
|
||||
|
||||
public function test_get_returns_single_template_by_applies_to_and_key(): void
|
||||
{
|
||||
$registry = new ContentTemplateRegistry;
|
||||
$registry->registerLibraryRoot($this->tmpRoot);
|
||||
|
||||
$tpl = $registry->get('logicmodel', 'sample');
|
||||
|
||||
$this->assertNotNull($tpl);
|
||||
$this->assertSame('Sample LM', $tpl->title);
|
||||
$this->assertSame('test', $tpl->sector);
|
||||
}
|
||||
|
||||
public function test_get_returns_null_for_unknown_template(): void
|
||||
{
|
||||
$registry = new ContentTemplateRegistry;
|
||||
$registry->registerLibraryRoot($this->tmpRoot);
|
||||
|
||||
$this->assertNull($registry->get('logicmodel', 'does-not-exist'));
|
||||
$this->assertNull($registry->get('unknown-applies-to', 'sample'));
|
||||
}
|
||||
|
||||
public function test_directory_name_overrides_applies_to_in_yaml(): void
|
||||
{
|
||||
// YAML claims appliesTo=wiki but the file is in the logicmodel
|
||||
// directory. The registry should rewrite the appliesTo to match the
|
||||
// directory, so a typo in the YAML can't pollute the wrong bucket.
|
||||
file_put_contents($this->tmpRoot.'/logicmodel/lies.yaml', <<<'YAML'
|
||||
key: "lies"
|
||||
title: "Liar"
|
||||
description: "Wrong appliesTo claim."
|
||||
appliesTo: "wiki"
|
||||
wiki:
|
||||
articles:
|
||||
- title: "Bait"
|
||||
content: ""
|
||||
logicmodel:
|
||||
items:
|
||||
- box: "lm_inputs"
|
||||
title: "Item"
|
||||
YAML);
|
||||
|
||||
$registry = new ContentTemplateRegistry;
|
||||
$registry->registerLibraryRoot($this->tmpRoot);
|
||||
|
||||
$byLm = $registry->get('logicmodel', 'lies');
|
||||
$this->assertNotNull($byLm);
|
||||
$this->assertSame('logicmodel', $byLm->appliesTo);
|
||||
|
||||
$this->assertNull($registry->get('wiki', 'lies'));
|
||||
}
|
||||
|
||||
public function test_later_library_root_overrides_earlier_on_collision(): void
|
||||
{
|
||||
$secondRoot = sys_get_temp_dir().'/ct-registry-test-second-'.uniqid();
|
||||
mkdir($secondRoot.'/logicmodel', 0o777, true);
|
||||
file_put_contents($secondRoot.'/logicmodel/sample.yaml', <<<'YAML'
|
||||
key: "sample"
|
||||
title: "Override Title"
|
||||
description: "From second root."
|
||||
appliesTo: "logicmodel"
|
||||
logicmodel:
|
||||
items:
|
||||
- box: "lm_outputs"
|
||||
title: "Override Item"
|
||||
YAML);
|
||||
|
||||
try {
|
||||
$registry = new ContentTemplateRegistry;
|
||||
$registry->registerLibraryRoot($this->tmpRoot);
|
||||
$registry->registerLibraryRoot($secondRoot);
|
||||
|
||||
$tpl = $registry->get('logicmodel', 'sample');
|
||||
|
||||
$this->assertNotNull($tpl);
|
||||
$this->assertSame('Override Title', $tpl->title);
|
||||
} finally {
|
||||
$this->rmrf($secondRoot);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_register_library_root_is_idempotent(): void
|
||||
{
|
||||
$registry = new ContentTemplateRegistry;
|
||||
$registry->registerLibraryRoot($this->tmpRoot);
|
||||
$registry->registerLibraryRoot($this->tmpRoot);
|
||||
$registry->registerLibraryRoot($this->tmpRoot.'/');
|
||||
|
||||
$lm = $registry->forAppliesTo('logicmodel');
|
||||
|
||||
$this->assertCount(1, $lm);
|
||||
}
|
||||
|
||||
public function test_invalid_yaml_is_skipped_without_crashing(): void
|
||||
{
|
||||
file_put_contents($this->tmpRoot.'/logicmodel/broken.yaml', "key: [unterminated\n");
|
||||
|
||||
$registry = new ContentTemplateRegistry;
|
||||
$registry->registerLibraryRoot($this->tmpRoot);
|
||||
|
||||
$lm = $registry->forAppliesTo('logicmodel');
|
||||
|
||||
$this->assertCount(1, $lm);
|
||||
$this->assertArrayHasKey('sample', $lm);
|
||||
$this->assertArrayNotHasKey('broken', $lm);
|
||||
}
|
||||
|
||||
private function rmrf(string $dir): void
|
||||
{
|
||||
if (! is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
foreach (scandir($dir) as $f) {
|
||||
if ($f === '.' || $f === '..') {
|
||||
continue;
|
||||
}
|
||||
$path = $dir.'/'.$f;
|
||||
is_dir($path) ? $this->rmrf($path) : unlink($path);
|
||||
}
|
||||
rmdir($dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\ContentTemplates\Support;
|
||||
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Domain\ContentTemplates\Support\TranslationResolver;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for TranslationResolver — the small helper that lets YAML
|
||||
* content templates carry t:KEY translation references through to
|
||||
* consumers without each consumer learning the convention.
|
||||
*/
|
||||
class TranslationResolverTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// __() routes through the bound Language. Stub it to a deterministic
|
||||
// prefix so the test doesn't depend on real locale files being present.
|
||||
$this->app->instance(Language::class, $this->make(Language::class, [
|
||||
'__' => fn (string $key): string => 'T:'.$key,
|
||||
]));
|
||||
}
|
||||
|
||||
public function test_passes_through_strings_without_t_references_untouched(): void
|
||||
{
|
||||
$this->assertSame('plain string', TranslationResolver::resolve('plain string'));
|
||||
$this->assertSame('', TranslationResolver::resolve(''));
|
||||
$this->assertSame('<h1>Hello</h1>', TranslationResolver::resolve('<h1>Hello</h1>'));
|
||||
}
|
||||
|
||||
public function test_whole_string_t_prefix_resolves_via_translator(): void
|
||||
{
|
||||
$this->assertSame('T:templates.prd.title', TranslationResolver::resolve('t:templates.prd.title'));
|
||||
$this->assertSame('T:status.draft', TranslationResolver::resolve('t:status.draft'));
|
||||
}
|
||||
|
||||
public function test_substring_t_substitution_replaces_each_occurrence_in_place(): void
|
||||
{
|
||||
$resolved = TranslationResolver::resolve('<h1>{{ t:templates.prd.title }}</h1>');
|
||||
$this->assertSame('<h1>T:templates.prd.title</h1>', $resolved);
|
||||
|
||||
// Multiple substitutions in one string, with various whitespace inside braces.
|
||||
$resolved = TranslationResolver::resolve('{{t:templates.author}} Gloria — {{ t:templates.dates }} 2026');
|
||||
$this->assertSame('T:templates.author Gloria — T:templates.dates 2026', $resolved);
|
||||
}
|
||||
|
||||
public function test_resolve_array_walks_recursively_and_resolves_strings_only(): void
|
||||
{
|
||||
$resolved = TranslationResolver::resolveArray([
|
||||
'title' => 't:templates.prd.title',
|
||||
'description' => '{{ t:templates.prd.description }} (extra)',
|
||||
'nested' => [
|
||||
'content' => '<p>{{ t:templates.author }}</p>',
|
||||
'count' => 7, // non-strings pass through
|
||||
'flag' => true,
|
||||
],
|
||||
'plain' => 'no references here',
|
||||
]);
|
||||
|
||||
$this->assertSame([
|
||||
'title' => 'T:templates.prd.title',
|
||||
'description' => 'T:templates.prd.description (extra)',
|
||||
'nested' => [
|
||||
'content' => '<p>T:templates.author</p>',
|
||||
'count' => 7,
|
||||
'flag' => true,
|
||||
],
|
||||
'plain' => 'no references here',
|
||||
], $resolved);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\ContentTemplates;
|
||||
|
||||
use Leantime\Domain\ContentTemplates\Services\ContentTemplateRegistry;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Integration check for Phase 3 of the content-templates rollout: wiki
|
||||
* YAML templates dropped into Library/wiki/ are discoverable via the
|
||||
* registry and carry the article shape the legacy template list expects.
|
||||
*
|
||||
* Not a controller test — that lives in Acceptance. This pins the data
|
||||
* contract between the YAML on disk and the consumer.
|
||||
*/
|
||||
class WikiTemplatesDiscoveryTest extends TestCase
|
||||
{
|
||||
public function test_built_in_wiki_templates_are_discoverable_via_registry(): void
|
||||
{
|
||||
$registry = new ContentTemplateRegistry;
|
||||
|
||||
$wikiTemplates = $registry->forAppliesTo('wiki');
|
||||
|
||||
// Phase 3 ships at least the two demo templates (decision-record,
|
||||
// weekly-status). Asserting on count keeps this honest if either gets
|
||||
// removed.
|
||||
$this->assertGreaterThanOrEqual(2, count($wikiTemplates));
|
||||
$this->assertArrayHasKey('decision-record', $wikiTemplates);
|
||||
$this->assertArrayHasKey('weekly-status', $wikiTemplates);
|
||||
}
|
||||
|
||||
public function test_built_in_wiki_template_has_single_article_with_html_content(): void
|
||||
{
|
||||
$registry = new ContentTemplateRegistry;
|
||||
$tpl = $registry->get('wiki', 'decision-record');
|
||||
|
||||
$this->assertNotNull($tpl);
|
||||
$this->assertSame('wiki', $tpl->appliesTo);
|
||||
|
||||
$articles = $tpl->payload['articles'] ?? [];
|
||||
$this->assertNotEmpty($articles);
|
||||
$this->assertIsArray($articles[0]);
|
||||
|
||||
// The wiki Templates partial maps payload.articles[0].content into the
|
||||
// legacy Template->content field. HTML body, not markdown.
|
||||
$this->assertNotEmpty($articles[0]['content'] ?? '');
|
||||
$this->assertStringContainsString('<h1>', $articles[0]['content']);
|
||||
}
|
||||
}
|
||||
103
tests/Unit/app/Domain/CsvImport/Services/CsvImportTest.php
Normal file
103
tests/Unit/app/Domain/CsvImport/Services/CsvImportTest.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\CsvImport\Services;
|
||||
|
||||
use Leantime\Domain\Connector\Models\Integration;
|
||||
use Leantime\Domain\Connector\Services\Integrations;
|
||||
use Leantime\Domain\CsvImport\Services\CsvImport;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the CSV upload processing extracted from the
|
||||
* CsvImport Upload controller into the CsvImport service.
|
||||
*/
|
||||
class CsvImportTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
private ?string $tmpFile = null;
|
||||
|
||||
protected function _after(): void
|
||||
{
|
||||
if ($this->tmpFile !== null && file_exists($this->tmpFile)) {
|
||||
unlink($this->tmpFile);
|
||||
}
|
||||
|
||||
$this->tmpFile = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the given CSV content to a temp file and wrap it in an UploadedFile.
|
||||
*/
|
||||
private function makeCsvUpload(string $content): UploadedFile
|
||||
{
|
||||
$this->tmpFile = tempnam(sys_get_temp_dir(), 'csvimport_test_');
|
||||
file_put_contents($this->tmpFile, $content);
|
||||
|
||||
return new UploadedFile($this->tmpFile, 'import.csv', 'text/csv', null, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the service with a mocked Integrations dependency.
|
||||
*/
|
||||
private function makeService(Integrations $integrationService): CsvImport
|
||||
{
|
||||
return new CsvImport($integrationService);
|
||||
}
|
||||
|
||||
public function test_process_upload_stores_records_and_builds_integration_from_header(): void
|
||||
{
|
||||
session()->forget('csv_records');
|
||||
|
||||
$captured = null;
|
||||
$integrationService = $this->make(Integrations::class, [
|
||||
'create' => function (object|array $object) use (&$captured) {
|
||||
$captured = $object;
|
||||
|
||||
return 77;
|
||||
},
|
||||
]);
|
||||
|
||||
$csv = "name,email,role\nAlice,alice@example.com,admin\nBob,bob@example.com,editor\n";
|
||||
$file = $this->makeCsvUpload($csv);
|
||||
|
||||
$id = $this->makeService($integrationService)->processUpload($file);
|
||||
|
||||
// Returns the integration id from the service.
|
||||
$this->assertSame(77, $id);
|
||||
|
||||
// Integration model is built from the comma-joined header row.
|
||||
$this->assertInstanceOf(Integration::class, $captured);
|
||||
$this->assertSame('name,email,role', $captured->fields);
|
||||
|
||||
// All data rows (excluding the header) are materialized into the session.
|
||||
$records = session('csv_records');
|
||||
$this->assertCount(2, $records);
|
||||
$this->assertSame('Alice', $records[0]['name']);
|
||||
$this->assertSame('alice@example.com', $records[0]['email']);
|
||||
$this->assertSame('Bob', $records[1]['name']);
|
||||
$this->assertSame('editor', $records[1]['role']);
|
||||
}
|
||||
|
||||
public function test_process_upload_persists_all_rows_not_an_exhausted_iterator(): void
|
||||
{
|
||||
// Regression test for the latent iterator-exhaustion bug: the session
|
||||
// must contain the actual rows, not an empty set.
|
||||
session()->forget('csv_records');
|
||||
|
||||
$integrationService = $this->make(Integrations::class, [
|
||||
'create' => fn () => 1,
|
||||
]);
|
||||
|
||||
$csv = "col\nfirst\nsecond\nthird\n";
|
||||
$file = $this->makeCsvUpload($csv);
|
||||
|
||||
$this->makeService($integrationService)->processUpload($file);
|
||||
|
||||
$records = session('csv_records');
|
||||
$this->assertCount(3, $records);
|
||||
$this->assertSame('first', $records[0]['col']);
|
||||
$this->assertSame('third', $records[2]['col']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Dashboard\Services;
|
||||
|
||||
use Leantime\Domain\Comments\Repositories\Comments as CommentRepository;
|
||||
use Leantime\Domain\Comments\Services\Comments as CommentService;
|
||||
use Leantime\Domain\Dashboard\Services\Dashboard;
|
||||
use Leantime\Domain\Reactions\Services\Reactions as ReactionService;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the Dashboard service logic extracted from the
|
||||
* Dashboard\Show controller.
|
||||
*/
|
||||
class DashboardServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
private function makeService(
|
||||
?CommentService $commentService = null,
|
||||
?CommentRepository $commentRepository = null,
|
||||
?ReactionService $reactionsService = null
|
||||
): Dashboard {
|
||||
return new Dashboard(
|
||||
$commentService ?? $this->make(CommentService::class),
|
||||
$commentRepository ?? $this->make(CommentRepository::class),
|
||||
$reactionsService ?? $this->make(ReactionService::class),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_get_project_comments_attaches_replies_per_comment(): void
|
||||
{
|
||||
$commentService = $this->make(CommentService::class, [
|
||||
'getComments' => fn () => [
|
||||
['id' => 1, 'text' => 'first'],
|
||||
['id' => 2, 'text' => 'second'],
|
||||
],
|
||||
]);
|
||||
$commentRepository = $this->make(CommentRepository::class, [
|
||||
'getReplies' => fn ($id) => [['id' => 100 + $id, 'commentParent' => $id]],
|
||||
]);
|
||||
|
||||
$result = $this->makeService($commentService, $commentRepository)
|
||||
->getProjectCommentsWithReplies(7);
|
||||
|
||||
$this->assertCount(2, $result);
|
||||
$this->assertSame([['id' => 101, 'commentParent' => 1]], $result[0]['replies']);
|
||||
$this->assertSame([['id' => 102, 'commentParent' => 2]], $result[1]['replies']);
|
||||
}
|
||||
|
||||
public function test_get_project_comments_returns_empty_when_no_comments(): void
|
||||
{
|
||||
$commentService = $this->make(CommentService::class, [
|
||||
'getComments' => fn () => false,
|
||||
]);
|
||||
|
||||
$result = $this->makeService($commentService)->getProjectCommentsWithReplies(7);
|
||||
|
||||
$this->assertSame([], $result);
|
||||
}
|
||||
|
||||
public function test_get_project_comments_rejects_invalid_project_id(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
$this->makeService()->getProjectCommentsWithReplies(0);
|
||||
}
|
||||
|
||||
public function test_count_project_comments_casts_to_int(): void
|
||||
{
|
||||
$commentRepository = $this->make(CommentRepository::class, [
|
||||
'countComments' => fn () => '5',
|
||||
]);
|
||||
|
||||
$result = $this->makeService(null, $commentRepository)->countProjectComments(3);
|
||||
|
||||
$this->assertSame(5, $result);
|
||||
}
|
||||
|
||||
public function test_count_project_comments_rejects_invalid_project_id(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
$this->makeService()->countProjectComments(-1);
|
||||
}
|
||||
|
||||
public function test_user_has_favorited_project_true_when_reactions_present(): void
|
||||
{
|
||||
$reactionsService = $this->make(ReactionService::class, [
|
||||
'getUserReactions' => fn () => [['reaction' => 'favorite']],
|
||||
]);
|
||||
|
||||
$result = $this->makeService(null, null, $reactionsService)
|
||||
->userHasFavoritedProject(42, 9);
|
||||
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
public function test_user_has_favorited_project_false_when_empty(): void
|
||||
{
|
||||
$reactionsService = $this->make(ReactionService::class, [
|
||||
'getUserReactions' => fn () => [],
|
||||
]);
|
||||
|
||||
$result = $this->makeService(null, null, $reactionsService)
|
||||
->userHasFavoritedProject(42, 9);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function test_user_has_favorited_project_false_when_repo_returns_false(): void
|
||||
{
|
||||
$reactionsService = $this->make(ReactionService::class, [
|
||||
'getUserReactions' => fn () => false,
|
||||
]);
|
||||
|
||||
$result = $this->makeService(null, null, $reactionsService)
|
||||
->userHasFavoritedProject(42, 9);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
}
|
||||
524
tests/Unit/app/Domain/Files/Services/FilesServiceTest.php
Normal file
524
tests/Unit/app/Domain/Files/Services/FilesServiceTest.php
Normal file
@@ -0,0 +1,524 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Files\Services;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\PermissionService;
|
||||
use Leantime\Core\Exceptions\AuthorizationException;
|
||||
use Leantime\Core\Files\FileManager;
|
||||
use Leantime\Core\Language as LanguageCore;
|
||||
use Leantime\Domain\Files\Repositories\Files as FileRepository;
|
||||
use Leantime\Domain\Files\Services\Files;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the Files service: the pure helpers extracted during the thin-controller refactor
|
||||
* (getImageExtensions, isOwnerRestrictedModule, handleFileAction) plus the authorization the native
|
||||
* permission engine added. The authz tests prove the four IDOR-prone @api methods fail closed:
|
||||
* - getFilesByModule resolves the target's project and denies non-members (no enumeration)
|
||||
* - upload authorizes against the target project (commenter+) on the JSON-RPC path too
|
||||
* - deleteFile preserves owner-delete but scopes the non-owner path to the file's project (editor+),
|
||||
* closing the old manager-global cross-project bypass
|
||||
* - getFileForUser authorizes the SESSION user, never the spoofable $userId argument
|
||||
*/
|
||||
class FilesServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// The current (session) user the service authorizes as.
|
||||
session(['userdata.id' => 1]);
|
||||
}
|
||||
|
||||
/** Permission stub that grants everything. */
|
||||
private function allowingPermissions(): PermissionService
|
||||
{
|
||||
return $this->make(PermissionService::class, [
|
||||
'currentUserCan' => fn () => true,
|
||||
'authorize' => fn () => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Permission stub that denies everything (authorize throws, currentUserCan is false). */
|
||||
private function denyingPermissions(): PermissionService
|
||||
{
|
||||
return $this->make(PermissionService::class, [
|
||||
'currentUserCan' => fn () => false,
|
||||
'authorize' => function (): void {
|
||||
throw new AuthorizationException;
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
private function makeService(
|
||||
?FileRepository $repo = null,
|
||||
?FileManager $fileManager = null,
|
||||
?PermissionService $perms = null,
|
||||
): Files {
|
||||
$service = new Files(
|
||||
$repo ?? $this->make(FileRepository::class),
|
||||
$fileManager ?? $this->make(FileManager::class),
|
||||
$this->make(LanguageCore::class),
|
||||
);
|
||||
$service->setPermissionService($perms ?? $this->allowingPermissions());
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
// ---- pure helpers -----------------------------------------------------
|
||||
|
||||
public function test_get_image_extensions_returns_the_shared_whitelist(): void
|
||||
{
|
||||
/** @var Files $service */
|
||||
$service = $this->make(Files::class);
|
||||
|
||||
$extensions = $service->getImageExtensions();
|
||||
|
||||
$this->assertContains('jpg', $extensions);
|
||||
$this->assertContains('webp', $extensions);
|
||||
$this->assertSame(
|
||||
['jpg', 'jpeg', 'png', 'gif', 'psd', 'bmp', 'tif', 'thm', 'yuv', 'webp'],
|
||||
$extensions
|
||||
);
|
||||
}
|
||||
|
||||
public function test_is_owner_restricted_module_flags_private_modules(): void
|
||||
{
|
||||
/** @var Files $service */
|
||||
$service = $this->make(Files::class);
|
||||
|
||||
$this->assertTrue($service->isOwnerRestrictedModule(['module' => 'private']));
|
||||
$this->assertTrue($service->isOwnerRestrictedModule(['module' => 'user']));
|
||||
$this->assertTrue($service->isOwnerRestrictedModule(['module' => 'lead']));
|
||||
$this->assertTrue($service->isOwnerRestrictedModule(['module' => 'export']));
|
||||
}
|
||||
|
||||
public function test_is_owner_restricted_module_allows_shared_modules(): void
|
||||
{
|
||||
/** @var Files $service */
|
||||
$service = $this->make(Files::class);
|
||||
|
||||
$this->assertFalse($service->isOwnerRestrictedModule(['module' => 'project']));
|
||||
$this->assertFalse($service->isOwnerRestrictedModule(['module' => 'ticket']));
|
||||
$this->assertFalse($service->isOwnerRestrictedModule(['module' => 'client']));
|
||||
$this->assertFalse($service->isOwnerRestrictedModule([]));
|
||||
}
|
||||
|
||||
// ---- handleFileAction (controller helper; delegates self-authorize) ---
|
||||
|
||||
public function test_handle_file_action_deletes_when_del_file_present(): void
|
||||
{
|
||||
$captured = null;
|
||||
/** @var Files $service */
|
||||
$service = $this->make(Files::class, [
|
||||
'deleteFile' => function ($fileId) use (&$captured) {
|
||||
$captured = $fileId;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $service->handleFileAction(['delFile' => '42'], [], 'project', 7);
|
||||
|
||||
$this->assertSame('delete', $result['action']);
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertSame('42', $captured);
|
||||
}
|
||||
|
||||
public function test_handle_file_action_reports_failed_delete(): void
|
||||
{
|
||||
/** @var Files $service */
|
||||
$service = $this->make(Files::class, [
|
||||
'deleteFile' => fn () => false,
|
||||
]);
|
||||
|
||||
$result = $service->handleFileAction(['delFile' => '42'], [], 'project', 7);
|
||||
|
||||
$this->assertSame('delete', $result['action']);
|
||||
$this->assertFalse($result['success']);
|
||||
}
|
||||
|
||||
public function test_handle_file_action_uploads_when_file_present(): void
|
||||
{
|
||||
$uploadArgs = null;
|
||||
/** @var Files $service */
|
||||
$service = $this->make(Files::class, [
|
||||
'upload' => function ($files, $module, $moduleId) use (&$uploadArgs) {
|
||||
$uploadArgs = [$files, $module, $moduleId];
|
||||
|
||||
return ['fileId' => 99];
|
||||
},
|
||||
]);
|
||||
|
||||
$files = ['file' => ['name' => 'a.png']];
|
||||
$result = $service->handleFileAction(['upload' => '1'], $files, 'project', 7);
|
||||
|
||||
$this->assertSame('upload', $result['action']);
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertSame([$files, 'project', 7], $uploadArgs);
|
||||
}
|
||||
|
||||
public function test_handle_file_action_reports_upload_without_file(): void
|
||||
{
|
||||
/** @var Files $service */
|
||||
$service = $this->make(Files::class, [
|
||||
'upload' => fn () => $this->fail('upload should not be called when no file is present'),
|
||||
]);
|
||||
|
||||
$result = $service->handleFileAction(['upload' => '1'], [], 'project', 7);
|
||||
|
||||
$this->assertSame('upload', $result['action']);
|
||||
$this->assertFalse($result['success']);
|
||||
}
|
||||
|
||||
public function test_handle_file_action_returns_null_action_for_empty_payload(): void
|
||||
{
|
||||
/** @var Files $service */
|
||||
$service = $this->make(Files::class);
|
||||
|
||||
$result = $service->handleFileAction([], [], 'project', 7);
|
||||
|
||||
$this->assertNull($result['action']);
|
||||
$this->assertFalse($result['success']);
|
||||
}
|
||||
|
||||
// ---- getFilesByModule -------------------------------------------------
|
||||
|
||||
public function test_get_files_by_module_denies_non_member(): void
|
||||
{
|
||||
$repo = $this->make(FileRepository::class, [
|
||||
'getFilesByModule' => fn () => $this->fail('Repository must not be queried when files.view is denied'),
|
||||
]);
|
||||
|
||||
$service = $this->makeService($repo, null, $this->denyingPermissions());
|
||||
|
||||
// module=project → projectId resolves to moduleId (5) directly; can(VIEW,5)=false → soft-deny.
|
||||
$this->assertSame([], $service->getFilesByModule('project', 5));
|
||||
}
|
||||
|
||||
public function test_get_files_by_module_allows_member(): void
|
||||
{
|
||||
$repo = $this->make(FileRepository::class, [
|
||||
'getFilesByModule' => fn () => [['id' => 99, 'module' => 'project', 'moduleId' => 5]],
|
||||
]);
|
||||
|
||||
$service = $this->makeService($repo, null, $this->allowingPermissions());
|
||||
|
||||
$this->assertCount(1, $service->getFilesByModule('project', 5));
|
||||
}
|
||||
|
||||
public function test_get_files_by_module_empty_module_returns_empty_without_dumping(): void
|
||||
{
|
||||
$repo = $this->make(FileRepository::class, [
|
||||
'getFilesByModule' => fn () => $this->fail('An empty module must never dump the file table'),
|
||||
]);
|
||||
|
||||
// Even with allow-all permissions, an empty module has no project context and must refuse.
|
||||
$service = $this->makeService($repo, null, $this->allowingPermissions());
|
||||
|
||||
$this->assertSame([], $service->getFilesByModule(''));
|
||||
}
|
||||
|
||||
public function test_get_files_by_module_owner_restricted_denies_other_user(): void
|
||||
{
|
||||
$repo = $this->make(FileRepository::class, [
|
||||
'getFilesByModule' => fn () => $this->fail('Owner-restricted listing must not return another user\'s files'),
|
||||
]);
|
||||
|
||||
// module=user, entityId=2 (not the session user 1) → soft-deny regardless of role.
|
||||
$service = $this->makeService($repo, null, $this->allowingPermissions());
|
||||
|
||||
$this->assertSame([], $service->getFilesByModule('user', 2));
|
||||
}
|
||||
|
||||
public function test_get_files_by_module_owner_restricted_allows_owner(): void
|
||||
{
|
||||
$repo = $this->make(FileRepository::class, [
|
||||
'getFilesByModule' => fn () => [['id' => 7, 'module' => 'user', 'moduleId' => 1]],
|
||||
]);
|
||||
|
||||
// module=user, entityId=1 == session user → allowed.
|
||||
$service = $this->makeService($repo, null, $this->allowingPermissions());
|
||||
|
||||
$this->assertCount(1, $service->getFilesByModule('user', 1));
|
||||
}
|
||||
|
||||
public function test_get_files_by_module_client_without_id_returns_empty(): void
|
||||
{
|
||||
$repo = $this->make(FileRepository::class, [
|
||||
'getFilesByModule' => fn () => $this->fail('client listing with no id must not dump every client\'s files'),
|
||||
]);
|
||||
|
||||
// module=client has no project mapping; with no specific client id it must refuse rather
|
||||
// than enumerate all client files (the legitimate ShowClient path always passes an id).
|
||||
$service = $this->makeService($repo, null, $this->allowingPermissions());
|
||||
|
||||
$this->assertSame([], $service->getFilesByModule('client'));
|
||||
}
|
||||
|
||||
public function test_get_files_by_module_client_with_id_passes_through(): void
|
||||
{
|
||||
$repo = $this->make(FileRepository::class, [
|
||||
'getFilesByModule' => fn () => [['id' => 8, 'module' => 'client', 'moduleId' => 3]],
|
||||
]);
|
||||
|
||||
// A specific client id is the legitimate ShowClient call; authz remains a Clients-domain
|
||||
// follow-up, so it passes through.
|
||||
$service = $this->makeService($repo, null, $this->allowingPermissions());
|
||||
|
||||
$this->assertCount(1, $service->getFilesByModule('client', 3));
|
||||
}
|
||||
|
||||
// ---- deleteFile -------------------------------------------------------
|
||||
|
||||
public function test_delete_file_allows_owner_even_without_permission(): void
|
||||
{
|
||||
$deleted = false;
|
||||
$repo = $this->make(FileRepository::class, [
|
||||
'getFile' => fn () => ['id' => 10, 'userId' => 1, 'module' => 'project', 'moduleId' => 5],
|
||||
'deleteFile' => function () use (&$deleted) {
|
||||
$deleted = true;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
// Deny-all permissions: the owner path must still delete (file.userId === session user 1).
|
||||
$service = $this->makeService($repo, null, $this->denyingPermissions());
|
||||
|
||||
$this->assertTrue($service->deleteFile(10));
|
||||
$this->assertTrue($deleted, 'Owner deletion should reach the repository');
|
||||
}
|
||||
|
||||
public function test_delete_file_denies_non_owner_without_permission(): void
|
||||
{
|
||||
$repo = $this->make(FileRepository::class, [
|
||||
'getFile' => fn () => ['id' => 10, 'userId' => 2, 'module' => 'project', 'moduleId' => 5],
|
||||
'deleteFile' => fn () => $this->fail('A non-owner without files.delete must not delete'),
|
||||
]);
|
||||
|
||||
// File owned by user 2; session user is 1 without files.delete in project 5 → soft-deny.
|
||||
$service = $this->makeService($repo, null, $this->denyingPermissions());
|
||||
|
||||
$this->assertFalse($service->deleteFile(10));
|
||||
}
|
||||
|
||||
public function test_delete_file_allows_non_owner_with_project_permission(): void
|
||||
{
|
||||
$deleted = false;
|
||||
$repo = $this->make(FileRepository::class, [
|
||||
'getFile' => fn () => ['id' => 10, 'userId' => 2, 'module' => 'project', 'moduleId' => 5],
|
||||
'deleteFile' => function () use (&$deleted) {
|
||||
$deleted = true;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
// Non-owner, but allow-all grants files.delete in the file's project (editor+).
|
||||
$service = $this->makeService($repo, null, $this->allowingPermissions());
|
||||
|
||||
$this->assertTrue($service->deleteFile(10));
|
||||
$this->assertTrue($deleted);
|
||||
}
|
||||
|
||||
public function test_delete_file_missing_returns_false(): void
|
||||
{
|
||||
$repo = $this->make(FileRepository::class, [
|
||||
'getFile' => fn () => false,
|
||||
]);
|
||||
|
||||
$service = $this->makeService($repo, null, $this->allowingPermissions());
|
||||
|
||||
$this->assertFalse($service->deleteFile(999));
|
||||
}
|
||||
|
||||
public function test_delete_file_owner_restricted_non_owner_denied(): void
|
||||
{
|
||||
$repo = $this->make(FileRepository::class, [
|
||||
'getFile' => fn () => ['id' => 11, 'userId' => 2, 'module' => 'user', 'moduleId' => 2],
|
||||
'deleteFile' => fn () => $this->fail('A no-project file may only be deleted by its uploader'),
|
||||
]);
|
||||
|
||||
// Owner-restricted (module=user → no project); session user 1 is not the owner (2) →
|
||||
// deny even with allow-all (the old manager-global delete of others' files is dropped).
|
||||
$service = $this->makeService($repo, null, $this->allowingPermissions());
|
||||
|
||||
$this->assertFalse($service->deleteFile(11));
|
||||
}
|
||||
|
||||
// ---- upload -----------------------------------------------------------
|
||||
|
||||
public function test_upload_throws_when_project_upload_denied(): void
|
||||
{
|
||||
$service = $this->makeService(null, null, $this->denyingPermissions());
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
// Passes the initial validation (module/moduleId present, file is an array) and reaches the
|
||||
// project authorize() before any file is written → throws on deny.
|
||||
$service->upload(['file' => []], 'project', 5);
|
||||
}
|
||||
|
||||
public function test_upload_throws_for_project_scoped_module_with_unresolvable_project(): void
|
||||
{
|
||||
// A project-scoped module (ticket) whose project can't be resolved (invalid/deleted id)
|
||||
// fails closed even with allow-all permissions — no orphan-file upload bypass.
|
||||
$repo = $this->make(FileRepository::class, ['getProjectIdForFile' => fn () => null]);
|
||||
$service = $this->makeService($repo, null, $this->allowingPermissions());
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$service->upload(['file' => []], 'ticket', 999);
|
||||
}
|
||||
|
||||
public function test_user_can_upload_to_module_denies_project_scoped_with_unresolvable_project(): void
|
||||
{
|
||||
$repo = $this->make(FileRepository::class, ['getProjectIdForFile' => fn () => null]);
|
||||
$service = $this->makeService($repo, null, $this->allowingPermissions());
|
||||
|
||||
$this->assertFalse($service->userCanUploadToModule('ticket', 999));
|
||||
}
|
||||
|
||||
public function test_user_can_upload_to_module_reflects_project_permission(): void
|
||||
{
|
||||
$this->assertTrue(
|
||||
$this->makeService(null, null, $this->allowingPermissions())->userCanUploadToModule('project', 5)
|
||||
);
|
||||
|
||||
$this->assertFalse(
|
||||
$this->makeService(null, null, $this->denyingPermissions())->userCanUploadToModule('project', 5)
|
||||
);
|
||||
}
|
||||
|
||||
public function test_user_can_upload_to_module_nonproject_preserved(): void
|
||||
{
|
||||
// Non-project modules (user avatar, ...) have no project context; preserved as allowed even
|
||||
// under deny-all (their flows pin moduleId server-side).
|
||||
$service = $this->makeService(null, null, $this->denyingPermissions());
|
||||
|
||||
$this->assertTrue($service->userCanUploadToModule('user', 9));
|
||||
}
|
||||
|
||||
// ---- getFileForUser ---------------------------------------------------
|
||||
|
||||
public function test_get_file_for_user_denies_non_member(): void
|
||||
{
|
||||
$repo = $this->make(FileRepository::class, [
|
||||
'getFileByEncName' => fn () => [
|
||||
'id' => 12, 'realName' => 'doc.pdf', 'extension' => 'pdf',
|
||||
'module' => 'project', 'moduleId' => 5, 'userId' => 2,
|
||||
],
|
||||
]);
|
||||
$fileManager = $this->make(FileManager::class, [
|
||||
'getFile' => fn () => $this->fail('A non-member must not receive file bytes'),
|
||||
]);
|
||||
|
||||
$service = $this->makeService($repo, $fileManager, $this->denyingPermissions());
|
||||
|
||||
$response = $service->getFileForUser('abc123', 1);
|
||||
$this->assertSame(403, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function test_get_file_for_user_allows_member(): void
|
||||
{
|
||||
$repo = $this->make(FileRepository::class, [
|
||||
'getFileByEncName' => fn () => [
|
||||
'id' => 12, 'realName' => 'doc.pdf', 'extension' => 'pdf',
|
||||
'module' => 'project', 'moduleId' => 5, 'userId' => 2,
|
||||
],
|
||||
]);
|
||||
$fileManager = $this->make(FileManager::class, [
|
||||
'getFile' => fn () => new Response('bytes', 200),
|
||||
]);
|
||||
|
||||
$service = $this->makeService($repo, $fileManager, $this->allowingPermissions());
|
||||
|
||||
$response = $service->getFileForUser('abc123', 1);
|
||||
$this->assertSame(200, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function test_get_file_for_user_owner_restricted_uses_session_user_not_arg(): void
|
||||
{
|
||||
// Owner-restricted file owned by user 2. Session user is 1. Even though the caller passes
|
||||
// userId=2 (spoofing the owner) the check uses the SESSION user (1) and denies.
|
||||
$repo = $this->make(FileRepository::class, [
|
||||
'getFileByEncName' => fn () => [
|
||||
'id' => 13, 'realName' => 'secret.txt', 'extension' => 'txt',
|
||||
'module' => 'private', 'moduleId' => 2, 'userId' => 2,
|
||||
],
|
||||
]);
|
||||
$fileManager = $this->make(FileManager::class, [
|
||||
'getFile' => fn () => $this->fail('Owner-restricted file must not be served to a non-owner'),
|
||||
]);
|
||||
|
||||
$service = $this->makeService($repo, $fileManager, $this->allowingPermissions());
|
||||
|
||||
$response = $service->getFileForUser('enc', 2);
|
||||
$this->assertSame(403, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function test_get_file_for_user_missing_returns_404(): void
|
||||
{
|
||||
$repo = $this->make(FileRepository::class, [
|
||||
'getFileByEncName' => fn () => false,
|
||||
]);
|
||||
|
||||
$service = $this->makeService($repo, null, $this->allowingPermissions());
|
||||
|
||||
$this->assertSame(404, $service->getFileForUser('missing', 1)->getStatusCode());
|
||||
}
|
||||
|
||||
public function test_get_file_for_user_denies_orphaned_project_file(): void
|
||||
{
|
||||
// A ticket file whose ticket was deleted resolves to no project; rather than fall through
|
||||
// to the non-project serve path it must be denied (fail closed), even with allow-all perms.
|
||||
$repo = $this->make(FileRepository::class, [
|
||||
'getFileByEncName' => fn () => [
|
||||
'id' => 14, 'realName' => 'a.pdf', 'extension' => 'pdf',
|
||||
'module' => 'ticket', 'moduleId' => 999, 'userId' => 2,
|
||||
],
|
||||
'getProjectIdForFile' => fn () => null,
|
||||
]);
|
||||
$fileManager = $this->make(FileManager::class, [
|
||||
'getFile' => fn () => $this->fail('an orphaned ticket file must not be served'),
|
||||
]);
|
||||
|
||||
$service = $this->makeService($repo, $fileManager, $this->allowingPermissions());
|
||||
|
||||
$this->assertSame(403, $service->getFileForUser('enc', 1)->getStatusCode());
|
||||
}
|
||||
|
||||
// ---- handleFileAction result reflects upload() outcome ----------------
|
||||
|
||||
public function test_handle_file_action_reports_failure_when_upload_is_denied(): void
|
||||
{
|
||||
/** @var Files $service */
|
||||
$service = $this->make(Files::class, [
|
||||
'upload' => function () {
|
||||
throw new AuthorizationException;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $service->handleFileAction(['upload' => '1'], ['file' => ['name' => 'a.png']], 'ticket', 5);
|
||||
|
||||
$this->assertSame('upload', $result['action']);
|
||||
$this->assertFalse($result['success']);
|
||||
}
|
||||
|
||||
public function test_handle_file_action_reports_failure_when_upload_returns_error_string(): void
|
||||
{
|
||||
/** @var Files $service */
|
||||
$service = $this->make(Files::class, [
|
||||
'upload' => fn () => 'Error uploading file',
|
||||
]);
|
||||
|
||||
$result = $service->handleFileAction(['upload' => '1'], ['file' => ['name' => 'a.png']], 'project', 5);
|
||||
|
||||
$this->assertSame('upload', $result['action']);
|
||||
$this->assertFalse($result['success']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Goalcanvas\Repositories;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Leantime\Domain\Goalcanvas\Repositories\Goalcanvas;
|
||||
use Mockery;
|
||||
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Repository-level regression tests for the goal↔milestone link chokepoints.
|
||||
* Two contracts here are load-bearing and were previously untested (every
|
||||
* service test mocks the repository away):
|
||||
*
|
||||
* 1. addGoalMilestoneLink fails CLOSED — the same-project product rule
|
||||
* (goal↔milestone links never cross projects) plus the live-milestone
|
||||
* type check are enforced in real SQL here and nowhere else.
|
||||
* 2. removeGoalMilestoneLink / removeAllGoalMilestoneLinks keep the legacy
|
||||
* milestoneId column in sync — without the clear, the column-union in
|
||||
* getGoalsByMilestone() resurrects an explicitly unlinked goal.
|
||||
*
|
||||
* Faked connection, no DB — the fakes model each table's terminal calls.
|
||||
*/
|
||||
class GoalcanvasMilestoneLinkTest extends TestCase
|
||||
{
|
||||
use MockeryPHPUnitIntegration;
|
||||
|
||||
/** @var array<int, array<string, mixed>> rows captured by edge inserts */
|
||||
private array $insertedEdges = [];
|
||||
|
||||
/** @var array<int, array<string, mixed>> update payloads captured on zp_canvas_items */
|
||||
private array $columnUpdates = [];
|
||||
|
||||
/**
|
||||
* Build a Goalcanvas repo whose dbConnection serves the given per-table
|
||||
* behavior.
|
||||
*
|
||||
* @param int|null $goalProjectId value('cb.projectId') for the goal lookup (null = goal missing/foreign)
|
||||
* @param object|null $milestoneRow first(['projectId']) for the milestone lookup (null = not a live milestone)
|
||||
* @param bool $edgeExists exists() for the dedup check inside the insert transaction
|
||||
* @param int $edgeDeleteCount delete() return for edge removals
|
||||
* @param int $columnUpdateCount update() return for the legacy-column clear
|
||||
*/
|
||||
private function repo(
|
||||
?int $goalProjectId = null,
|
||||
?object $milestoneRow = null,
|
||||
bool $edgeExists = false,
|
||||
int $edgeDeleteCount = 0,
|
||||
int $columnUpdateCount = 0
|
||||
): Goalcanvas {
|
||||
$this->insertedEdges = [];
|
||||
$this->columnUpdates = [];
|
||||
$inserted = &$this->insertedEdges;
|
||||
$updates = &$this->columnUpdates;
|
||||
|
||||
$builderFor = function (string $table) use ($goalProjectId, $milestoneRow, $edgeExists, $edgeDeleteCount, $columnUpdateCount, &$inserted, &$updates) {
|
||||
return new class($table, $goalProjectId, $milestoneRow, $edgeExists, $edgeDeleteCount, $columnUpdateCount, $inserted, $updates)
|
||||
{
|
||||
public function __construct(
|
||||
private string $table,
|
||||
private ?int $goalProjectId,
|
||||
private ?object $milestoneRow,
|
||||
private bool $edgeExists,
|
||||
private int $edgeDeleteCount,
|
||||
private int $columnUpdateCount,
|
||||
array &$inserted,
|
||||
array &$updates
|
||||
) {
|
||||
// Explicit reference assignment (not promotion) so the
|
||||
// captures stay version-proof across supported PHP.
|
||||
$this->inserted = &$inserted;
|
||||
$this->updates = &$updates;
|
||||
}
|
||||
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
private array $inserted;
|
||||
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
private array $updates;
|
||||
|
||||
/** Stands in for addGoalMilestoneLink's zp_canvas_items↔zp_canvas join. */
|
||||
public function join(...$a): static
|
||||
{
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function where(...$a): static
|
||||
{
|
||||
// Record scalar predicates so tests can pin WHICH rows an
|
||||
// update/delete was scoped to (a fake that discards its
|
||||
// where() arguments can't catch a lost predicate).
|
||||
if (count($a) === 2 && is_scalar($a[1])) {
|
||||
$this->wheres[$a[0]] = $a[1];
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> scalar where() predicates seen by this builder */
|
||||
public array $wheres = [];
|
||||
|
||||
/** Stands in for the in-transaction dedup's ->lockForUpdate(). */
|
||||
public function lockForUpdate(): static
|
||||
{
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** Stands in for addGoalMilestoneLink's ->value('cb.projectId') goal-project resolve. */
|
||||
public function value($column)
|
||||
{
|
||||
return $this->goalProjectId;
|
||||
}
|
||||
|
||||
/** Stands in for addGoalMilestoneLink's zp_tickets ->first(['projectId']) milestone lookup. */
|
||||
public function first($columns = ['*'])
|
||||
{
|
||||
return $this->milestoneRow;
|
||||
}
|
||||
|
||||
/** Stands in for the edge-dedup ->exists() inside the insert transaction. */
|
||||
public function exists(): bool
|
||||
{
|
||||
return $this->edgeExists;
|
||||
}
|
||||
|
||||
/** Stands in for the zp_entity_relationship edge insert. */
|
||||
public function insert($row): bool
|
||||
{
|
||||
$this->inserted[] = $row;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Stands in for the tracked_by edge ->delete() in the removal methods. */
|
||||
public function delete(): int
|
||||
{
|
||||
return $this->edgeDeleteCount;
|
||||
}
|
||||
|
||||
/** Stands in for the legacy zp_canvas_items.milestoneId column clear. */
|
||||
public function update(array $values): int
|
||||
{
|
||||
$this->updates[] = ['table' => $this->table, 'values' => $values, 'wheres' => $this->wheres];
|
||||
|
||||
return $this->columnUpdateCount;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
$conn = Mockery::mock(ConnectionInterface::class);
|
||||
$conn->shouldReceive('table')->andReturnUsing($builderFor);
|
||||
$conn->shouldReceive('transaction')->andReturnUsing(fn (callable $fn) => $fn());
|
||||
|
||||
$repo = (new \ReflectionClass(Goalcanvas::class))->newInstanceWithoutConstructor();
|
||||
$prop = new \ReflectionProperty(Goalcanvas::class, 'dbConnection');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue($repo, $conn);
|
||||
|
||||
return $repo;
|
||||
}
|
||||
|
||||
// ── addGoalMilestoneLink: the fail-closed same-project chokepoint ──
|
||||
|
||||
public function test_add_link_rejects_a_cross_project_milestone(): void
|
||||
{
|
||||
// Goal lives in project 1, milestone in project 2 — the product rule
|
||||
// (links never cross projects) must fail the write closed.
|
||||
$repo = $this->repo(goalProjectId: 1, milestoneRow: (object) ['projectId' => 2]);
|
||||
|
||||
$this->assertFalse($repo->addGoalMilestoneLink(5, 42, 7));
|
||||
$this->assertSame([], $this->insertedEdges, 'no edge may be written for a cross-project link');
|
||||
}
|
||||
|
||||
public function test_add_link_rejects_a_dead_or_non_milestone_ticket(): void
|
||||
{
|
||||
// The milestone lookup filters type='milestone' AND status<>-1 — a
|
||||
// task id or a soft-deleted milestone resolves to null.
|
||||
$repo = $this->repo(goalProjectId: 1, milestoneRow: null);
|
||||
|
||||
$this->assertFalse($repo->addGoalMilestoneLink(5, 42, 7));
|
||||
$this->assertSame([], $this->insertedEdges);
|
||||
}
|
||||
|
||||
public function test_add_link_rejects_an_unknown_or_non_goal_item(): void
|
||||
{
|
||||
// The goal lookup filters box='goal' + canvas type='goalcanvas' — a
|
||||
// foreign/shared-table id resolves to null (fail closed, no oracle).
|
||||
$repo = $this->repo(goalProjectId: null, milestoneRow: (object) ['projectId' => 1]);
|
||||
|
||||
$this->assertFalse($repo->addGoalMilestoneLink(5, 42, 7));
|
||||
$this->assertSame([], $this->insertedEdges);
|
||||
}
|
||||
|
||||
public function test_add_link_writes_a_correct_edge_for_a_same_project_milestone(): void
|
||||
{
|
||||
$repo = $this->repo(goalProjectId: 1, milestoneRow: (object) ['projectId' => 1]);
|
||||
|
||||
$this->assertTrue($repo->addGoalMilestoneLink(5, 42, 7));
|
||||
$this->assertCount(1, $this->insertedEdges);
|
||||
$edge = $this->insertedEdges[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_add_link_is_idempotent_when_the_edge_exists(): void
|
||||
{
|
||||
$repo = $this->repo(goalProjectId: 1, milestoneRow: (object) ['projectId' => 1], edgeExists: true);
|
||||
|
||||
$this->assertTrue($repo->addGoalMilestoneLink(5, 42, 7), 'an existing link reports success');
|
||||
$this->assertSame([], $this->insertedEdges, 'but is not duplicated');
|
||||
}
|
||||
|
||||
public function test_add_link_stores_unknown_author_as_null_not_zero(): void
|
||||
{
|
||||
$repo = $this->repo(goalProjectId: 1, milestoneRow: (object) ['projectId' => 1]);
|
||||
|
||||
$repo->addGoalMilestoneLink(5, 42, 0);
|
||||
|
||||
$this->assertNull($this->insertedEdges[0]['createdBy']);
|
||||
}
|
||||
|
||||
// ── removeGoalMilestoneLink / removeAll: the clear-on-unlink dual-write ──
|
||||
|
||||
public function test_unlink_clears_the_legacy_column_alongside_the_edge(): void
|
||||
{
|
||||
$repo = $this->repo(edgeDeleteCount: 1, columnUpdateCount: 1);
|
||||
|
||||
$this->assertTrue($repo->removeGoalMilestoneLink(5, 42));
|
||||
$this->assertCount(1, $this->columnUpdates, 'the legacy milestoneId column must be cleared with the edge');
|
||||
$this->assertSame(['milestoneId' => ''], $this->columnUpdates[0]['values']);
|
||||
// The clear must be SCOPED: only the goal's row, and only when the
|
||||
// column still points at the milestone being unlinked — dropping the
|
||||
// milestoneId predicate would blank an unrelated newer link.
|
||||
$this->assertSame(5, $this->columnUpdates[0]['wheres']['id'] ?? null);
|
||||
$this->assertSame('42', $this->columnUpdates[0]['wheres']['milestoneId'] ?? null);
|
||||
}
|
||||
|
||||
public function test_unlink_reports_success_when_only_the_stale_column_held_the_link(): void
|
||||
{
|
||||
// No edge row (already gone), but the legacy column still pointed at
|
||||
// the milestone — clearing it is a real unlink and must not read as a
|
||||
// failure.
|
||||
$repo = $this->repo(edgeDeleteCount: 0, columnUpdateCount: 1);
|
||||
|
||||
$this->assertTrue($repo->removeGoalMilestoneLink(5, 42));
|
||||
}
|
||||
|
||||
public function test_unlink_reports_failure_when_neither_store_held_the_link(): void
|
||||
{
|
||||
$repo = $this->repo(edgeDeleteCount: 0, columnUpdateCount: 0);
|
||||
|
||||
$this->assertFalse($repo->removeGoalMilestoneLink(5, 42));
|
||||
}
|
||||
|
||||
public function test_remove_all_links_clears_edges_and_the_legacy_column(): void
|
||||
{
|
||||
$repo = $this->repo(edgeDeleteCount: 3, columnUpdateCount: 1);
|
||||
|
||||
$this->assertTrue($repo->removeAllGoalMilestoneLinks(5));
|
||||
$this->assertCount(1, $this->columnUpdates);
|
||||
$this->assertSame(['milestoneId' => ''], $this->columnUpdates[0]['values']);
|
||||
}
|
||||
|
||||
public function test_remove_milestone_from_all_goals_clears_edges_and_columns(): void
|
||||
{
|
||||
$repo = $this->repo(edgeDeleteCount: 2, columnUpdateCount: 2);
|
||||
|
||||
$this->assertTrue($repo->removeMilestoneFromAllGoals(42));
|
||||
$this->assertNotEmpty($this->columnUpdates, 'the milestone-delete cascade must clear matching legacy columns');
|
||||
$this->assertSame(['milestoneId' => ''], $this->columnUpdates[0]['values']);
|
||||
$this->assertSame('42', $this->columnUpdates[0]['wheres']['milestoneId'] ?? null, 'only columns pointing at THIS milestone are cleared');
|
||||
}
|
||||
|
||||
public function test_remove_milestone_from_all_goals_reports_only_edge_deletions(): void
|
||||
{
|
||||
// PINS CURRENT BEHAVIOR: unlike removeGoalMilestoneLink /
|
||||
// removeAllGoalMilestoneLinks (which return deleted OR columnCleared),
|
||||
// the cascade returns only whether edges were deleted — a stale-column
|
||||
// -only cleanup reads as false. No caller branches on this today; if
|
||||
// the sibling semantics are ever unified, this test should fail and be
|
||||
// updated deliberately.
|
||||
$repo = $this->repo(edgeDeleteCount: 0, columnUpdateCount: 3);
|
||||
|
||||
$this->assertFalse($repo->removeMilestoneFromAllGoals(42));
|
||||
$this->assertNotEmpty($this->columnUpdates, 'stale columns are still cleared even though the return is false');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,911 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Goalcanvas\Services;
|
||||
|
||||
use Codeception\Stub\Expected;
|
||||
use Leantime\Core\Auth\Permissions\PermissionService;
|
||||
use Leantime\Core\Exceptions\AuthorizationException;
|
||||
use Leantime\Domain\Goalcanvas\Repositories\Goalcanvas as GoalcanvaRepository;
|
||||
use Leantime\Domain\Goalcanvas\Services\Goalcanvas as GoalcanvasService;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the Goalcanvas service:
|
||||
* - progress math: goalProgress is (currentValue - startValue) / (endValue - startValue) * 100,
|
||||
* clamped to 0..100, and child-goal value aggregation for linkAndReport goals;
|
||||
* - the fail-closed by-id board/item CRUD chokepoint (every by-id op resolves the entity's real
|
||||
* project via the inherited resolvers, scoped to the "goalcanvas" type, and authorizes a
|
||||
* goals.* verb — reads soft-deny without loading, writes throw without writing).
|
||||
*/
|
||||
class GoalcanvasServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
private function allowingPermissions(): PermissionService
|
||||
{
|
||||
return $this->make(PermissionService::class, [
|
||||
'authorize' => fn () => null,
|
||||
'currentUserCan' => fn () => true,
|
||||
]);
|
||||
}
|
||||
|
||||
private function service(GoalcanvaRepository $repo, ?PermissionService $perms = null, ?ProjectService $projects = null): GoalcanvasService
|
||||
{
|
||||
$service = new GoalcanvasService($repo, $projects ?? $this->projectsWithAccess([]));
|
||||
$service->setPermissionService($perms ?? $this->allowingPermissions());
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
/** A Projects service granting access to exactly the given project ids. */
|
||||
private function projectsWithAccess(array $projectIds): ProjectService
|
||||
{
|
||||
return $this->make(ProjectService::class, [
|
||||
'getProjectsUserHasAccessTo' => fn () => array_map(static fn ($id) => ['id' => $id], $projectIds),
|
||||
]);
|
||||
}
|
||||
|
||||
private function denyingPermissions(): PermissionService
|
||||
{
|
||||
return $this->make(PermissionService::class, [
|
||||
'authorize' => function (): void {
|
||||
throw new AuthorizationException;
|
||||
},
|
||||
'currentUserCan' => fn () => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A repo where goal #7 lives in project 9 and returns the given milestone
|
||||
* chip rows from getMilestonesForGoals.
|
||||
*/
|
||||
private function goalRepoWithMilestones(array $milestones, int $projectId = 9): GoalcanvaRepository
|
||||
{
|
||||
return $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasItemProjectId' => fn (...$args) => $projectId,
|
||||
'getCanvasItemProjectIds' => fn (...$args) => [7 => (int) $projectId],
|
||||
'getMilestonesForGoals' => fn (...$args) => [7 => $milestones],
|
||||
]);
|
||||
}
|
||||
|
||||
private function milestone(int $id, string $statusType, int $percentDone, ?string $from, ?string $to, int $projectId = 9): array
|
||||
{
|
||||
return [
|
||||
'id' => $id, 'headline' => "MS $id", 'color' => '#ccc', 'projectId' => $projectId,
|
||||
'editFrom' => $from, 'editTo' => $to, 'status' => 3, 'statusType' => $statusType,
|
||||
'percentDone' => $percentDone,
|
||||
];
|
||||
}
|
||||
|
||||
public function test_computes_goal_progress_as_percentage_of_range(): void
|
||||
{
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasProjectId' => fn () => 9,
|
||||
'getMilestonesForGoals' => fn () => [],
|
||||
'getCanvasItemsById' => fn () => [
|
||||
['id' => 1, 'setting' => 'linkonly', 'startValue' => 0.0, 'endValue' => 100.0, 'currentValue' => 50.0],
|
||||
],
|
||||
]);
|
||||
|
||||
$goals = $this->service($repo)->getCanvasItemsById(1);
|
||||
|
||||
$this->assertEqualsWithDelta(50, $goals[0]['goalProgress'], 0.01);
|
||||
}
|
||||
|
||||
public function test_clamps_progress_between_zero_and_one_hundred(): void
|
||||
{
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasProjectId' => fn () => 9,
|
||||
'getMilestonesForGoals' => fn () => [],
|
||||
'getCanvasItemsById' => fn () => [
|
||||
['id' => 1, 'setting' => 'linkonly', 'startValue' => 0.0, 'endValue' => 100.0, 'currentValue' => 150.0],
|
||||
['id' => 2, 'setting' => 'linkonly', 'startValue' => 0.0, 'endValue' => 100.0, 'currentValue' => -20.0],
|
||||
],
|
||||
]);
|
||||
|
||||
$goals = $this->service($repo)->getCanvasItemsById(1);
|
||||
|
||||
$this->assertSame(100, $goals[0]['goalProgress']);
|
||||
$this->assertSame(0, $goals[1]['goalProgress']);
|
||||
}
|
||||
|
||||
public function test_zero_range_yields_zero_progress(): void
|
||||
{
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasProjectId' => fn () => 9,
|
||||
'getMilestonesForGoals' => fn () => [],
|
||||
'getCanvasItemsById' => fn () => [
|
||||
['id' => 1, 'setting' => 'linkonly', 'startValue' => 50.0, 'endValue' => 50.0, 'currentValue' => 50.0],
|
||||
],
|
||||
]);
|
||||
|
||||
$goals = $this->service($repo)->getCanvasItemsById(1);
|
||||
|
||||
$this->assertSame(0, $goals[0]['goalProgress']);
|
||||
}
|
||||
|
||||
public function test_child_goal_reporting_sums_by_setting(): void
|
||||
{
|
||||
// linkonly children contribute their own currentValue; linkAndReport
|
||||
// children contribute their rolled-up childCurrentValue.
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasItemProjectId' => fn () => 9,
|
||||
'getCanvasItemsByKPI' => fn () => [
|
||||
['setting' => 'linkonly', 'currentValue' => 10.0, 'childCurrentValue' => 999.0],
|
||||
['setting' => 'linkAndReport', 'currentValue' => 0.0, 'childCurrentValue' => 5.0],
|
||||
],
|
||||
]);
|
||||
|
||||
$sum = $this->service($repo)->getChildGoalsForReporting(1);
|
||||
|
||||
$this->assertEqualsWithDelta(15.0, $sum, 0.01);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Fail-closed by-id board/item CRUD chokepoint.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public function test_get_canvas_items_returns_empty_for_foreign_board_without_loading(): void
|
||||
{
|
||||
$loaded = 0;
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasProjectId' => fn () => null,
|
||||
'getCanvasItemsById' => function () use (&$loaded) {
|
||||
$loaded++;
|
||||
|
||||
return [['id' => 1]];
|
||||
},
|
||||
]);
|
||||
|
||||
$this->assertSame([], $this->service($repo)->getCanvasItemsById(999));
|
||||
$this->assertSame(0, $loaded, 'A foreign/unknown board must not have its goals read');
|
||||
}
|
||||
|
||||
public function test_child_goal_reporting_returns_zero_for_foreign_parent(): void
|
||||
{
|
||||
$loaded = 0;
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasItemProjectId' => fn () => null,
|
||||
'getCanvasItemsByKPI' => function () use (&$loaded) {
|
||||
$loaded++;
|
||||
|
||||
return [];
|
||||
},
|
||||
]);
|
||||
|
||||
$this->assertSame(0, $this->service($repo)->getChildGoalsForReporting(999));
|
||||
$this->assertSame(0, $loaded, 'A foreign/unknown parent goal must not have its children read');
|
||||
}
|
||||
|
||||
public function test_get_goal_item_soft_denies_when_view_not_permitted(): void
|
||||
{
|
||||
$loaded = 0;
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasItemProjectId' => fn () => 9,
|
||||
'getSingleCanvasItem' => function () use (&$loaded) {
|
||||
$loaded++;
|
||||
|
||||
return ['id' => 1];
|
||||
},
|
||||
]);
|
||||
|
||||
$perms = $this->make(PermissionService::class, ['currentUserCan' => fn () => false]);
|
||||
|
||||
$this->assertFalse($this->service($repo, $perms)->getGoalItem(1));
|
||||
$this->assertSame(0, $loaded, 'An unauthorized item returns false without loading (no oracle)');
|
||||
}
|
||||
|
||||
public function test_update_goal_item_resolves_project_from_item_id_not_payload_canvas_id(): void
|
||||
{
|
||||
$resolvedItemId = null;
|
||||
$wrote = 0;
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasItemProjectId' => function ($id) use (&$resolvedItemId) {
|
||||
$resolvedItemId = $id;
|
||||
|
||||
return 9;
|
||||
},
|
||||
'editCanvasItem' => function () use (&$wrote) {
|
||||
$wrote++;
|
||||
},
|
||||
]);
|
||||
|
||||
$this->service($repo)->updateGoalItem(['itemId' => 42, 'canvasId' => 9999, 'description' => 'x']);
|
||||
|
||||
$this->assertSame(42, $resolvedItemId, 'Project resolved from itemId, not the payload canvasId');
|
||||
$this->assertSame(1, $wrote);
|
||||
}
|
||||
|
||||
public function test_patch_goal_item_throws_and_never_writes_for_unresolved_item(): void
|
||||
{
|
||||
$patched = 0;
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasItemProjectId' => fn () => null,
|
||||
'patchCanvasItem' => function () use (&$patched) {
|
||||
$patched++;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->service($repo)->patchGoalItem(5, ['status' => 'x']);
|
||||
$this->fail('Expected AuthorizationException');
|
||||
} catch (AuthorizationException) {
|
||||
}
|
||||
$this->assertSame(0, $patched);
|
||||
}
|
||||
|
||||
public function test_delete_goal_item_throws_and_never_deletes_for_unresolved_item(): void
|
||||
{
|
||||
$deleted = 0;
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasItemProjectId' => fn () => null,
|
||||
'delCanvasItem' => function () use (&$deleted) {
|
||||
$deleted++;
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->service($repo)->deleteGoalItem(5);
|
||||
$this->fail('Expected AuthorizationException');
|
||||
} catch (AuthorizationException) {
|
||||
}
|
||||
$this->assertSame(0, $deleted);
|
||||
}
|
||||
|
||||
public function test_create_goal_item_throws_and_never_inserts_for_unknown_board(): void
|
||||
{
|
||||
$inserted = 0;
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasProjectId' => fn () => null,
|
||||
'addCanvasItem' => function () use (&$inserted) {
|
||||
$inserted++;
|
||||
|
||||
return '1';
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->service($repo)->createGoalItem(['canvasId' => 9999, 'box' => 'goal']);
|
||||
$this->fail('Expected AuthorizationException');
|
||||
} catch (AuthorizationException) {
|
||||
}
|
||||
$this->assertSame(0, $inserted);
|
||||
}
|
||||
|
||||
public function test_create_goal_api_throws_for_unknown_board(): void
|
||||
{
|
||||
$inserted = 0;
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasProjectId' => fn () => null,
|
||||
'createGoal' => function () use (&$inserted) {
|
||||
$inserted++;
|
||||
|
||||
return '1';
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->service($repo)->createGoal(['canvasId' => 9999, 'box' => 'goal']);
|
||||
$this->fail('Expected AuthorizationException');
|
||||
} catch (AuthorizationException) {
|
||||
}
|
||||
$this->assertSame(0, $inserted);
|
||||
}
|
||||
|
||||
public function test_delete_goal_board_throws_for_unresolved_board(): void
|
||||
{
|
||||
$deleted = 0;
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasProjectId' => fn () => null,
|
||||
'deleteCanvas' => function () use (&$deleted) {
|
||||
$deleted++;
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->service($repo)->deleteGoalBoard(5);
|
||||
$this->fail('Expected AuthorizationException');
|
||||
} catch (AuthorizationException) {
|
||||
}
|
||||
$this->assertSame(0, $deleted);
|
||||
}
|
||||
|
||||
public function test_update_goalboard_throws_for_unresolved_board(): void
|
||||
{
|
||||
$updated = 0;
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasProjectId' => fn () => null,
|
||||
'updateCanvas' => function () use (&$updated) {
|
||||
$updated++;
|
||||
|
||||
return 1;
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->service($repo)->updateGoalboard(['id' => 5, 'title' => 'x']);
|
||||
$this->fail('Expected AuthorizationException');
|
||||
} catch (AuthorizationException) {
|
||||
}
|
||||
$this->assertSame(0, $updated);
|
||||
}
|
||||
|
||||
public function test_merge_goal_board_requires_both_boards_to_resolve(): void
|
||||
{
|
||||
$merged = 0;
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasProjectId' => fn ($id) => $id === 1 ? 9 : null,
|
||||
'mergeCanvas' => function () use (&$merged) {
|
||||
$merged++;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->service($repo)->mergeGoalBoard(2, 1);
|
||||
$this->fail('Expected AuthorizationException');
|
||||
} catch (AuthorizationException) {
|
||||
}
|
||||
$this->assertSame(0, $merged);
|
||||
}
|
||||
|
||||
public function test_copy_goal_board_throws_when_source_unresolved(): void
|
||||
{
|
||||
$copied = 0;
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasProjectId' => fn () => null,
|
||||
'copyCanvas' => function () use (&$copied) {
|
||||
$copied++;
|
||||
|
||||
return 1;
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->service($repo)->copyGoalBoard(5, 7, 1, 'Copy');
|
||||
$this->fail('Expected AuthorizationException');
|
||||
} catch (AuthorizationException) {
|
||||
}
|
||||
$this->assertSame(0, $copied);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Dual-write: syncing tracked_by edges from a single milestoneId write.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a repo whose edge writers record into $added / $removed (passed by
|
||||
* reference), so a test can assert exactly which links were created/removed.
|
||||
*
|
||||
* @param array<int, int> $currentEdges Milestone ids the goal is already linked to
|
||||
* @param array<string, callable> $extraStubs Extra repo method stubs (the write path)
|
||||
* @param array<int, array{0:int,1:int}> $added Receives [goalId, milestoneId] per add
|
||||
* @param array<int, int> $removed Receives milestoneId per remove
|
||||
*/
|
||||
private function edgeRepo(array $currentEdges, array $extraStubs, array &$added, array &$removed): GoalcanvaRepository
|
||||
{
|
||||
return $this->make(GoalcanvaRepository::class, array_merge([
|
||||
'getCanvasProjectId' => fn () => 9,
|
||||
'getCanvasItemProjectId' => fn () => 9,
|
||||
'getMilestoneIdsForGoal' => fn () => $currentEdges,
|
||||
'addGoalMilestoneLink' => function ($goalId, $milestoneId, $userId) use (&$added) {
|
||||
// $userId is required (no default) so the tests fail loudly if
|
||||
// production ever stops passing the author argument.
|
||||
$added[] = [(int) $goalId, (int) $milestoneId];
|
||||
|
||||
return true;
|
||||
},
|
||||
'removeGoalMilestoneLink' => function ($goalId, $milestoneId) use (&$removed) {
|
||||
$removed[] = (int) $milestoneId;
|
||||
|
||||
return true;
|
||||
},
|
||||
], $extraStubs));
|
||||
}
|
||||
|
||||
public function test_create_goal_links_milestone_edge_when_milestone_id_present(): void
|
||||
{
|
||||
$added = [];
|
||||
$removed = [];
|
||||
$repo = $this->edgeRepo([], ['createGoal' => fn () => '50'], $added, $removed);
|
||||
|
||||
$this->service($repo)->createGoal(['canvasId' => 9, 'milestoneId' => 42]);
|
||||
|
||||
$this->assertSame([[50, 42]], $added, 'The new goal is linked to the given milestone');
|
||||
$this->assertSame([], $removed);
|
||||
}
|
||||
|
||||
public function test_create_goal_item_links_milestone_edge_when_milestone_id_present(): void
|
||||
{
|
||||
$added = [];
|
||||
$removed = [];
|
||||
$repo = $this->edgeRepo([], ['addCanvasItem' => fn () => '50'], $added, $removed);
|
||||
|
||||
$this->service($repo)->createGoalItem(['canvasId' => 9, 'box' => 'goal', 'milestoneId' => 42]);
|
||||
|
||||
$this->assertSame([[50, 42]], $added, 'A new goal item is linked to the given milestone');
|
||||
$this->assertSame([], $removed);
|
||||
}
|
||||
|
||||
public function test_update_goal_item_links_milestone_edge_when_milestone_id_present(): void
|
||||
{
|
||||
$added = [];
|
||||
$removed = [];
|
||||
$repo = $this->edgeRepo([], ['editCanvasItem' => fn () => null], $added, $removed);
|
||||
|
||||
$this->service($repo)->updateGoalItem(['itemId' => 7, 'milestoneId' => 42]);
|
||||
|
||||
$this->assertSame([[7, 42]], $added);
|
||||
$this->assertSame([], $removed);
|
||||
}
|
||||
|
||||
public function test_patch_goal_item_links_milestone_edge_when_milestone_id_present(): void
|
||||
{
|
||||
$added = [];
|
||||
$removed = [];
|
||||
$repo = $this->edgeRepo([], ['patchCanvasItem' => fn () => true], $added, $removed);
|
||||
|
||||
$this->service($repo)->patchGoalItem(7, ['milestoneId' => 42]);
|
||||
|
||||
$this->assertSame([[7, 42]], $added);
|
||||
$this->assertSame([], $removed);
|
||||
}
|
||||
|
||||
public function test_patch_goal_item_skips_edge_sync_when_patch_fails(): void
|
||||
{
|
||||
$added = [];
|
||||
$removed = [];
|
||||
// patchCanvasItem returns false → the milestoneId edge sync must NOT run,
|
||||
// else the tracked_by edges would drift from the (unchanged) column.
|
||||
$repo = $this->edgeRepo([], ['patchCanvasItem' => fn () => false], $added, $removed);
|
||||
|
||||
$result = $this->service($repo)->patchGoalItem(7, ['milestoneId' => 42]);
|
||||
|
||||
$this->assertFalse($result);
|
||||
$this->assertSame([], $added, 'no edge added when the underlying patch failed');
|
||||
$this->assertSame([], $removed);
|
||||
}
|
||||
|
||||
public function test_patch_goal_item_ignores_non_numeric_milestone_id(): void
|
||||
{
|
||||
$added = [];
|
||||
$removed = [];
|
||||
$repo = $this->edgeRepo([], ['patchCanvasItem' => fn () => true], $added, $removed);
|
||||
|
||||
// '42abc' must not cast to milestone edge 42.
|
||||
$this->service($repo)->patchGoalItem(7, ['milestoneId' => '42abc']);
|
||||
|
||||
$this->assertSame([], $added, 'a non-numeric milestoneId creates no edge');
|
||||
$this->assertSame([], $removed);
|
||||
}
|
||||
|
||||
public function test_delete_goal_item_removes_all_edges_on_successful_delete(): void
|
||||
{
|
||||
$deleted = 0;
|
||||
$cleared = 0;
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasItemProjectId' => fn () => 9,
|
||||
'delCanvasItem' => function () use (&$deleted) {
|
||||
$deleted++;
|
||||
},
|
||||
'removeAllGoalMilestoneLinks' => function ($goalId) use (&$cleared) {
|
||||
$cleared = (int) $goalId;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$this->service($repo)->deleteGoalItem(5);
|
||||
|
||||
$this->assertSame(1, $deleted);
|
||||
$this->assertSame(5, $cleared, 'deleting a goal item clears its tracked_by edges');
|
||||
}
|
||||
|
||||
public function test_empty_milestone_id_clears_existing_edges_without_adding(): void
|
||||
{
|
||||
$added = [];
|
||||
$removed = [];
|
||||
$repo = $this->edgeRepo([11], ['editCanvasItem' => fn () => null], $added, $removed);
|
||||
|
||||
$this->service($repo)->updateGoalItem(['itemId' => 7, 'milestoneId' => '']);
|
||||
|
||||
$this->assertSame([], $added, 'An empty milestoneId adds nothing');
|
||||
$this->assertSame([11], $removed, 'It unlinks the existing edge');
|
||||
}
|
||||
|
||||
public function test_zero_milestone_id_clears_existing_edges_without_adding(): void
|
||||
{
|
||||
$added = [];
|
||||
$removed = [];
|
||||
$repo = $this->edgeRepo([11], ['patchCanvasItem' => fn () => true], $added, $removed);
|
||||
|
||||
$this->service($repo)->patchGoalItem(7, ['milestoneId' => '0']);
|
||||
|
||||
$this->assertSame([], $added, 'A "0" milestoneId adds nothing');
|
||||
$this->assertSame([11], $removed, 'It unlinks the existing edge');
|
||||
}
|
||||
|
||||
public function test_unchanged_milestone_id_does_not_churn_edges(): void
|
||||
{
|
||||
$added = [];
|
||||
$removed = [];
|
||||
$repo = $this->edgeRepo([42], ['editCanvasItem' => fn () => null], $added, $removed);
|
||||
|
||||
$this->service($repo)->updateGoalItem(['itemId' => 7, 'milestoneId' => 42]);
|
||||
|
||||
$this->assertSame([], $added, 'Re-saving the same milestone neither adds');
|
||||
$this->assertSame([], $removed, 'nor removes an edge');
|
||||
}
|
||||
|
||||
public function test_missing_milestone_id_key_leaves_edges_untouched(): void
|
||||
{
|
||||
// No milestoneId key at all (e.g. a description-only edit) must not
|
||||
// touch edges — the sync only runs when the key is present.
|
||||
$synced = 0;
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasItemProjectId' => fn () => 9,
|
||||
'editCanvasItem' => fn () => null,
|
||||
'getMilestoneIdsForGoal' => function () use (&$synced) {
|
||||
$synced++;
|
||||
|
||||
return [];
|
||||
},
|
||||
]);
|
||||
|
||||
$this->service($repo)->updateGoalItem(['itemId' => 7, 'description' => 'x']);
|
||||
|
||||
$this->assertSame(0, $synced, 'Edge sync must not run when milestoneId is absent');
|
||||
}
|
||||
|
||||
// Multi-milestone chip UI actions + report read (edge model).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public function test_add_milestone_to_goal_authorizes_edit_and_links(): void
|
||||
{
|
||||
$linked = null;
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasItemProjectId' => fn () => 9,
|
||||
'addGoalMilestoneLink' => function ($goalId, $milestoneId, $userId = null) use (&$linked) {
|
||||
$linked = [(int) $goalId, (int) $milestoneId];
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$this->assertTrue($this->service($repo)->addMilestoneToGoal(7, 42));
|
||||
$this->assertSame([7, 42], $linked, 'The link is created against the resolved goal');
|
||||
}
|
||||
|
||||
public function test_add_milestone_to_goal_throws_and_never_links_for_foreign_goal(): void
|
||||
{
|
||||
$linked = 0;
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasItemProjectId' => fn () => null,
|
||||
'addGoalMilestoneLink' => function () use (&$linked) {
|
||||
$linked++;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->service($repo)->addMilestoneToGoal(999, 42);
|
||||
$this->fail('Expected AuthorizationException');
|
||||
} catch (AuthorizationException) {
|
||||
}
|
||||
$this->assertSame(0, $linked, 'A foreign/unknown goal must not have a milestone linked');
|
||||
}
|
||||
|
||||
public function test_add_milestone_to_goal_throws_and_never_links_when_edit_denied(): void
|
||||
{
|
||||
$linked = 0;
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasItemProjectId' => fn () => 9,
|
||||
'addGoalMilestoneLink' => function () use (&$linked) {
|
||||
$linked++;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->service($repo, $this->denyingPermissions())->addMilestoneToGoal(7, 42);
|
||||
$this->fail('Expected AuthorizationException');
|
||||
} catch (AuthorizationException) {
|
||||
}
|
||||
$this->assertSame(0, $linked, 'EDIT-denied on the goal project must not link');
|
||||
}
|
||||
|
||||
public function test_remove_milestone_from_goal_authorizes_edit_and_unlinks(): void
|
||||
{
|
||||
$unlinked = null;
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasItemProjectId' => fn () => 9,
|
||||
'removeGoalMilestoneLink' => function ($goalId, $milestoneId) use (&$unlinked) {
|
||||
$unlinked = [(int) $goalId, (int) $milestoneId];
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$this->assertTrue($this->service($repo)->removeMilestoneFromGoal(7, 42));
|
||||
$this->assertSame([7, 42], $unlinked);
|
||||
}
|
||||
|
||||
public function test_remove_milestone_from_goal_throws_and_never_unlinks_for_foreign_goal(): void
|
||||
{
|
||||
$unlinked = 0;
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasItemProjectId' => fn () => null,
|
||||
'removeGoalMilestoneLink' => function () use (&$unlinked) {
|
||||
$unlinked++;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->service($repo)->removeMilestoneFromGoal(999, 42);
|
||||
$this->fail('Expected AuthorizationException');
|
||||
} catch (AuthorizationException) {
|
||||
}
|
||||
$this->assertSame(0, $unlinked);
|
||||
}
|
||||
|
||||
public function test_get_goal_milestones_returns_chips_and_summarizes_by_status(): void
|
||||
{
|
||||
// Chips carry their projectId and pass the accessible-projects strip.
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasItemProjectId' => fn () => 9,
|
||||
'getMilestonesForGoals' => fn () => [
|
||||
7 => [
|
||||
['id' => 1, 'headline' => 'A', 'statusType' => 'DONE', 'projectId' => 9],
|
||||
['id' => 2, 'headline' => 'B', 'statusType' => 'INPROGRESS', 'projectId' => 9],
|
||||
['id' => 3, 'headline' => 'C', 'statusType' => 'NEW', 'projectId' => 9],
|
||||
['id' => 4, 'headline' => 'D', 'statusType' => 'NEW', 'projectId' => 9],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->service($repo, projects: $this->projectsWithAccess([9]))->getGoalMilestones(7);
|
||||
|
||||
$this->assertCount(4, $result['milestones']);
|
||||
$this->assertSame(
|
||||
['total' => 4, 'done' => 1, 'inProgress' => 1, 'notStarted' => 2],
|
||||
$result['summary'],
|
||||
);
|
||||
}
|
||||
|
||||
public function test_get_goal_milestones_strips_legacy_cross_project_chips_and_counts_only_shown(): void
|
||||
{
|
||||
// Same defensive strip as the rollup reads: a legacy cross-project row
|
||||
// (milestone in project 8, caller can only access 9) must not surface
|
||||
// in the editor chips, and the summary counts what is actually shown.
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasItemProjectId' => fn () => 9,
|
||||
'getMilestonesForGoals' => fn () => [
|
||||
7 => [
|
||||
['id' => 1, 'headline' => 'Mine', 'statusType' => 'DONE', 'projectId' => 9],
|
||||
['id' => 2, 'headline' => 'Foreign', 'statusType' => 'INPROGRESS', 'projectId' => 8],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->service($repo, projects: $this->projectsWithAccess([9]))->getGoalMilestones(7);
|
||||
|
||||
$this->assertCount(1, $result['milestones']);
|
||||
$this->assertSame('Mine', $result['milestones'][0]['headline']);
|
||||
$this->assertSame(
|
||||
['total' => 1, 'done' => 1, 'inProgress' => 0, 'notStarted' => 0],
|
||||
$result['summary'],
|
||||
);
|
||||
}
|
||||
|
||||
public function test_get_goal_milestones_soft_denies_foreign_goal_without_loading(): void
|
||||
{
|
||||
$loaded = 0;
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasItemProjectId' => fn () => null,
|
||||
'getMilestonesForGoals' => function () use (&$loaded) {
|
||||
$loaded++;
|
||||
|
||||
return [];
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->service($repo)->getGoalMilestones(999);
|
||||
|
||||
$this->assertSame([], $result['milestones']);
|
||||
$this->assertSame(['total' => 0, 'done' => 0, 'inProgress' => 0, 'notStarted' => 0], $result['summary']);
|
||||
$this->assertSame(0, $loaded, 'A foreign/unknown goal must not have its milestones read (no oracle)');
|
||||
}
|
||||
|
||||
public function test_get_goal_milestones_soft_denies_when_view_not_permitted(): void
|
||||
{
|
||||
$loaded = 0;
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasItemProjectId' => fn () => 9,
|
||||
'getMilestonesForGoals' => function () use (&$loaded) {
|
||||
$loaded++;
|
||||
|
||||
return [7 => [['id' => 1, 'headline' => 'A', 'statusType' => 'DONE']]];
|
||||
},
|
||||
]);
|
||||
|
||||
$perms = $this->make(PermissionService::class, ['currentUserCan' => fn () => false]);
|
||||
$result = $this->service($repo, $perms)->getGoalMilestones(7);
|
||||
|
||||
$this->assertSame([], $result['milestones']);
|
||||
$this->assertSame(0, $loaded, 'VIEW-denied returns the empty shape without loading');
|
||||
}
|
||||
|
||||
public function test_get_milestones_for_goals_omits_unauthorized_goals(): void
|
||||
{
|
||||
// goal 1 lives in project 7 (VIEW allowed), goal 2 in project 8 (denied) —
|
||||
// the report-read path must present the authorized goal and drop the rest.
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasItemProjectIds' => fn () => [1 => 7, 2 => 8],
|
||||
'getMilestonesForGoals' => fn (array $ids) => in_array(1, $ids, true)
|
||||
? [1 => [['id' => 10, 'headline' => 'M1', 'projectId' => 7]]]
|
||||
: [],
|
||||
]);
|
||||
$perms = $this->make(PermissionService::class, [
|
||||
'currentUserCan' => fn (string $permission, ?int $projectId = null) => $projectId === 7,
|
||||
]);
|
||||
|
||||
$result = $this->service($repo, $perms, $this->projectsWithAccess([7]))->getMilestonesForGoals([1, 2]);
|
||||
|
||||
$this->assertArrayHasKey(1, $result, 'authorized goal is present');
|
||||
$this->assertArrayNotHasKey(2, $result, 'unauthorized goal is omitted');
|
||||
$this->assertSame([['id' => 10, 'headline' => 'M1', 'projectId' => 7]], $result[1]);
|
||||
}
|
||||
|
||||
public function test_get_goals_by_milestone_soft_denies_unknown_or_foreign_milestone(): void
|
||||
{
|
||||
// The MCP getGoalsByMilestone tool wraps this verbatim, so the service
|
||||
// itself must gate: an unknown/non-milestone id returns [] without
|
||||
// reading any goals (no oracle).
|
||||
$read = 0;
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getMilestoneProjectId' => fn () => null,
|
||||
'getGoalsByMilestone' => function () use (&$read) {
|
||||
$read++;
|
||||
|
||||
return [['id' => 1]];
|
||||
},
|
||||
]);
|
||||
|
||||
$this->assertSame([], $this->service($repo)->getGoalsByMilestone(999));
|
||||
$this->assertSame(0, $read, 'goals must not be read for an unresolvable milestone');
|
||||
}
|
||||
|
||||
public function test_get_goals_by_milestone_soft_denies_when_view_not_permitted(): void
|
||||
{
|
||||
$read = 0;
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getMilestoneProjectId' => fn () => 8,
|
||||
'getGoalsByMilestone' => function () use (&$read) {
|
||||
$read++;
|
||||
|
||||
return [['id' => 1]];
|
||||
},
|
||||
]);
|
||||
$perms = $this->make(PermissionService::class, ['currentUserCan' => fn () => false]);
|
||||
|
||||
$this->assertSame([], $this->service($repo, $perms)->getGoalsByMilestone(5));
|
||||
$this->assertSame(0, $read, 'VIEW-denied returns [] without reading goals');
|
||||
}
|
||||
|
||||
public function test_get_goals_by_milestone_returns_goals_for_an_accessible_milestone(): void
|
||||
{
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getMilestoneProjectId' => fn () => 9,
|
||||
'getGoalsByMilestone' => fn () => [['id' => 1, 'title' => 'G']],
|
||||
]);
|
||||
|
||||
$this->assertSame(
|
||||
[['id' => 1, 'title' => 'G']],
|
||||
$this->service($repo)->getGoalsByMilestone(5)
|
||||
);
|
||||
}
|
||||
|
||||
public function test_get_milestones_for_goals_is_empty_safe(): void
|
||||
{
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasItemProjectIds' => fn () => [],
|
||||
]);
|
||||
|
||||
$this->assertSame([], $this->service($repo)->getMilestonesForGoals([]));
|
||||
}
|
||||
|
||||
// ─── Milestone rollup / mobile Progress (many-to-many) ────────────────
|
||||
|
||||
public function test_get_goal_rollup_aggregates_status_progress_and_span(): void
|
||||
{
|
||||
$repo = $this->goalRepoWithMilestones([
|
||||
$this->milestone(1, 'INPROGRESS', 40, '2025-01-10 00:00:00', '2025-03-01 00:00:00'),
|
||||
$this->milestone(2, 'NEW', 0, '2025-02-01 00:00:00', '2025-04-15 00:00:00'),
|
||||
$this->milestone(3, 'DONE', 100, '2024-12-01 00:00:00', '2025-01-20 00:00:00'),
|
||||
]);
|
||||
|
||||
$rollup = $this->service($repo, null, $this->projectsWithAccess([9]))->getGoalRollup(7);
|
||||
|
||||
$this->assertSame(3, $rollup['total']);
|
||||
$this->assertSame(1, $rollup['done']);
|
||||
$this->assertSame(1, $rollup['inProgress']);
|
||||
$this->assertSame(1, $rollup['notStarted']);
|
||||
$this->assertSame(47, $rollup['percentComplete']); // round((40+0+100)/3)
|
||||
$this->assertSame('2024-12-01 00:00:00', $rollup['startDate']); // earliest start
|
||||
$this->assertSame('2025-04-15 00:00:00', $rollup['endDate']); // latest due
|
||||
$this->assertSame(1, $rollup['currentMilestoneId']); // first not-done
|
||||
}
|
||||
|
||||
public function test_get_goal_rollup_skips_zero_sentinel_dates(): void
|
||||
{
|
||||
$repo = $this->goalRepoWithMilestones([
|
||||
$this->milestone(1, 'INPROGRESS', 50, '0000-00-00 00:00:00', '2025-05-01 00:00:00'),
|
||||
$this->milestone(2, 'NEW', 0, '2025-01-01 00:00:00', '0000-00-00 00:00:00'),
|
||||
]);
|
||||
|
||||
$rollup = $this->service($repo, null, $this->projectsWithAccess([9]))->getGoalRollup(7);
|
||||
|
||||
$this->assertSame('2025-01-01 00:00:00', $rollup['startDate']); // m1 start skipped
|
||||
$this->assertSame('2025-05-01 00:00:00', $rollup['endDate']); // m2 due skipped
|
||||
}
|
||||
|
||||
public function test_get_milestones_by_goal_strips_inaccessible_project_milestones(): void
|
||||
{
|
||||
$repo = $this->goalRepoWithMilestones([
|
||||
$this->milestone(1, 'NEW', 0, null, null, projectId: 9),
|
||||
$this->milestone(2, 'NEW', 0, null, null, projectId: 99), // not accessible
|
||||
]);
|
||||
|
||||
// goal is in project 9 (VIEW ok); only project 9 is accessible.
|
||||
$list = $this->service($repo, null, $this->projectsWithAccess([9]))->getMilestonesByGoal(7);
|
||||
|
||||
$this->assertCount(1, $list);
|
||||
$this->assertSame(1, $list[0]['id']);
|
||||
}
|
||||
|
||||
public function test_get_milestones_by_goal_soft_denies_unauthorized_goal(): void
|
||||
{
|
||||
$repo = $this->goalRepoWithMilestones([
|
||||
$this->milestone(1, 'NEW', 0, null, null),
|
||||
]);
|
||||
|
||||
$list = $this->service($repo, $this->denyingPermissions(), $this->projectsWithAccess([9]))
|
||||
->getMilestonesByGoal(7);
|
||||
|
||||
$this->assertSame([], $list);
|
||||
}
|
||||
|
||||
public function test_get_goal_rollup_returns_empty_shape_for_foreign_goal(): void
|
||||
{
|
||||
// getCanvasItemProjectId returns null (missing/foreign/wrong type) -> deny.
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasItemProjectId' => fn () => null,
|
||||
]);
|
||||
|
||||
$rollup = $this->service($repo)->getGoalRollup(7);
|
||||
|
||||
$this->assertSame(0, $rollup['total']);
|
||||
$this->assertNull($rollup['currentMilestoneId']);
|
||||
}
|
||||
|
||||
public function test_add_milestone_to_goal_does_not_unlink_others_many_to_many(): void
|
||||
{
|
||||
// Marcel's correction: a milestone can belong to multiple goals, so
|
||||
// linking it to a goal must NOT unlink it from any other goal.
|
||||
$repo = $this->make(GoalcanvaRepository::class, [
|
||||
'getCanvasItemProjectId' => fn () => 9,
|
||||
'addGoalMilestoneLink' => Expected::once(fn ($goalId, $milestoneId, $userId = null) => true),
|
||||
'removeMilestoneFromAllGoals' => Expected::never(),
|
||||
]);
|
||||
|
||||
$this->assertTrue($this->service($repo)->addMilestoneToGoal(7, 42));
|
||||
}
|
||||
}
|
||||
137
tests/Unit/app/Domain/Help/Services/FirstTaskStepTest.php
Normal file
137
tests/Unit/app/Domain/Help/Services/FirstTaskStepTest.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Help\Services;
|
||||
|
||||
use Leantime\Core\Exceptions\AuthorizationException;
|
||||
use Leantime\Domain\Help\Services\FirstTaskStep;
|
||||
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
|
||||
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
|
||||
use RuntimeException;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Regression tests for the first-login onboarding dead-end in GH #3683.
|
||||
*
|
||||
* Readonly users have no TicketsPermissions::CREATE, so quickAddTicket() throws an
|
||||
* AuthorizationException. That used to happen before the firstLoginCompleted flag was
|
||||
* written, leaving the onboarding modal re-rendering forever with no way out. The flag
|
||||
* write is the invariant here; the first task is only a convenience.
|
||||
*/
|
||||
class FirstTaskStepTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/** Captures every saveSetting() call as [key, value]. */
|
||||
private array $savedSettings = [];
|
||||
|
||||
/** Captures every headline quickAddTicket() was called with. */
|
||||
private array $createdHeadlines = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
session(['userdata.id' => 1]);
|
||||
|
||||
$this->savedSettings = [];
|
||||
$this->createdHeadlines = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the step with a spying Setting repo and a Tickets service that either
|
||||
* records the headline or throws the given exception.
|
||||
*/
|
||||
private function makeStep(?\Throwable $ticketFailure = null): FirstTaskStep
|
||||
{
|
||||
$settingsRepo = $this->make(SettingRepository::class, [
|
||||
'saveSetting' => function ($key, $value) {
|
||||
$this->savedSettings[] = [$key, $value];
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$ticketService = $this->make(TicketService::class, [
|
||||
'quickAddTicket' => function ($params) use ($ticketFailure) {
|
||||
if ($ticketFailure !== null) {
|
||||
throw $ticketFailure;
|
||||
}
|
||||
|
||||
$this->createdHeadlines[] = $params['headline'];
|
||||
|
||||
return 1;
|
||||
},
|
||||
]);
|
||||
|
||||
return new FirstTaskStep($settingsRepo, $ticketService);
|
||||
}
|
||||
|
||||
/** The flag write, asserted as "written exactly once with true". */
|
||||
private function assertOnboardingCompleted(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
[['user.1.firstLoginCompleted', true]],
|
||||
$this->savedSettings,
|
||||
'firstLoginCompleted must be persisted exactly once, regardless of ticket creation'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_readonly_user_completes_onboarding_when_ticket_creation_is_denied(): void
|
||||
{
|
||||
$result = $this->makeStep(new AuthorizationException)->handle(['headline' => 'Water the plants']);
|
||||
|
||||
$this->assertTrue($result, 'handle() must report success even when the user cannot create tickets');
|
||||
$this->assertOnboardingCompleted();
|
||||
$this->assertSame([], $this->createdHeadlines);
|
||||
}
|
||||
|
||||
public function test_unexpected_ticket_failure_still_completes_onboarding(): void
|
||||
{
|
||||
$result = $this->makeStep(new RuntimeException('database went away'))->handle(['headline' => 'Water the plants']);
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertOnboardingCompleted();
|
||||
}
|
||||
|
||||
public function test_permitted_user_creates_the_first_task_and_completes_onboarding(): void
|
||||
{
|
||||
$result = $this->makeStep()->handle(['headline' => 'Water the plants']);
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertSame(['Water the plants'], $this->createdHeadlines);
|
||||
$this->assertOnboardingCompleted();
|
||||
}
|
||||
|
||||
public function test_headline_is_trimmed_before_the_task_is_created(): void
|
||||
{
|
||||
$this->makeStep()->handle(['headline' => ' Water the plants ']);
|
||||
|
||||
$this->assertSame(['Water the plants'], $this->createdHeadlines);
|
||||
}
|
||||
|
||||
/**
|
||||
* A blank, whitespace-only, missing or non-string headline must not create an empty
|
||||
* task — but must still complete onboarding.
|
||||
*
|
||||
* @dataProvider blankHeadlineProvider
|
||||
*/
|
||||
public function test_blank_headline_skips_task_creation_but_completes_onboarding(array $params): void
|
||||
{
|
||||
$result = $this->makeStep()->handle($params);
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertSame([], $this->createdHeadlines, 'No task should be created for a blank headline');
|
||||
$this->assertOnboardingCompleted();
|
||||
}
|
||||
|
||||
public static function blankHeadlineProvider(): array
|
||||
{
|
||||
return [
|
||||
'empty string' => [['headline' => '']],
|
||||
'whitespace only' => [['headline' => " \t "]],
|
||||
'missing key' => [[]],
|
||||
'null' => [['headline' => null]],
|
||||
'array (malformed POST)' => [['headline' => ['nope']]],
|
||||
];
|
||||
}
|
||||
}
|
||||
96
tests/Unit/app/Domain/Help/Services/HelperTest.php
Normal file
96
tests/Unit/app/Domain/Help/Services/HelperTest.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Help\Services;
|
||||
|
||||
use Leantime\Domain\Help\Services\Helper;
|
||||
use Leantime\Domain\Setting\Repositories\Setting;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the onboarding / modal orchestration extracted from the
|
||||
* Help FirstLogin and ShowOnboardingDialog controllers into the Helper service.
|
||||
*/
|
||||
class HelperTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/**
|
||||
* Builds a Helper service with a stubbed Setting repository.
|
||||
*/
|
||||
private function makeService(): Helper
|
||||
{
|
||||
return new Helper($this->make(Setting::class));
|
||||
}
|
||||
|
||||
public function test_resolve_first_login_step_returns_end_step(): void
|
||||
{
|
||||
$step = $this->makeService()->resolveFirstLoginStep('end');
|
||||
|
||||
$this->assertTrue($step['isEnd']);
|
||||
$this->assertSame('end', $step['key']);
|
||||
$this->assertNull($step['next']);
|
||||
$this->assertSame('help.firstLoginEnd', $step['template']);
|
||||
}
|
||||
|
||||
public function test_handle_first_login_step_rejects_missing_step(): void
|
||||
{
|
||||
$result = $this->makeService()->handleFirstLoginStep([]);
|
||||
|
||||
$this->assertFalse($result['valid']);
|
||||
$this->assertSame('', $result['next']);
|
||||
}
|
||||
|
||||
public function test_handle_first_login_step_rejects_non_numeric_step(): void
|
||||
{
|
||||
$result = $this->makeService()->handleFirstLoginStep(['currentStep' => 'foo']);
|
||||
|
||||
$this->assertFalse($result['valid']);
|
||||
$this->assertSame('', $result['next']);
|
||||
}
|
||||
|
||||
public function test_handle_first_login_step_rejects_unknown_numeric_step(): void
|
||||
{
|
||||
$result = $this->makeService()->handleFirstLoginStep(['currentStep' => '999']);
|
||||
|
||||
$this->assertFalse($result['valid']);
|
||||
$this->assertSame('', $result['next']);
|
||||
}
|
||||
|
||||
public function test_get_helper_modal_by_route_returns_notfound_for_unknown_route(): void
|
||||
{
|
||||
$modal = $this->makeService()->getHelperModalByRoute('does.notExist');
|
||||
|
||||
$this->assertSame('notfound', $modal['template']);
|
||||
}
|
||||
|
||||
public function test_mark_modal_seen_for_module_sanitizes_and_records_session(): void
|
||||
{
|
||||
session()->forget('usersettings.modals');
|
||||
|
||||
$template = $this->makeService()->markModalSeenForModule('<b>dashboard</b>');
|
||||
|
||||
$expected = htmlspecialchars('<b>dashboard</b>');
|
||||
$this->assertSame($expected, $template);
|
||||
$this->assertSame(1, session('usersettings.modals.'.$expected));
|
||||
}
|
||||
|
||||
public function test_mark_modal_seen_for_route_resolves_template_and_records_session(): void
|
||||
{
|
||||
session()->forget('usersettings.modals');
|
||||
|
||||
$template = $this->makeService()->markModalSeenForRoute('dashboard.show');
|
||||
|
||||
$this->assertSame('projectDashboard', $template);
|
||||
$this->assertSame(1, session('usersettings.modals.projectDashboard'));
|
||||
}
|
||||
|
||||
public function test_mark_modal_seen_for_route_unknown_route_marks_notfound(): void
|
||||
{
|
||||
session()->forget('usersettings.modals');
|
||||
|
||||
$template = $this->makeService()->markModalSeenForRoute('does.notExist');
|
||||
|
||||
$this->assertSame('notfound', $template);
|
||||
$this->assertSame(1, session('usersettings.modals.notfound'));
|
||||
}
|
||||
}
|
||||
538
tests/Unit/app/Domain/Ideas/Services/IdeasServiceTest.php
Normal file
538
tests/Unit/app/Domain/Ideas/Services/IdeasServiceTest.php
Normal file
@@ -0,0 +1,538 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Ideas\Services;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\PermissionService;
|
||||
use Leantime\Core\Exceptions\AuthorizationException;
|
||||
use Leantime\Core\Language as LanguageCore;
|
||||
use Leantime\Domain\Comments\Repositories\Comments as CommentRepository;
|
||||
use Leantime\Domain\Ideas\Repositories\Ideas as IdeasRepository;
|
||||
use Leantime\Domain\Ideas\Services\Ideas as IdeaService;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the Ideas service: board/item helpers plus the project-scoped authorization
|
||||
* fences. Idea boards (zp_canvas type 'idea') and items (zp_canvas_items) are project-scoped; reads
|
||||
* and mutations fence against the entity's REAL project (item -> board -> project), failing closed
|
||||
* on the shared canvas tables. The pre-existing userCanAccessCanvasItem checks are migrated onto the
|
||||
* permission engine.
|
||||
*/
|
||||
class IdeasServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
private const SESSION_USER = 5;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
session(['userdata.id' => self::SESSION_USER]);
|
||||
}
|
||||
|
||||
private function makeService(
|
||||
?IdeasRepository $ideasRepo = null,
|
||||
?CommentRepository $commentsRepo = null,
|
||||
?LanguageCore $language = null,
|
||||
?PermissionService $perms = null,
|
||||
): IdeaService {
|
||||
$service = new IdeaService(
|
||||
$ideasRepo ?? $this->make(IdeasRepository::class),
|
||||
$commentsRepo ?? $this->make(CommentRepository::class),
|
||||
$this->make(ProjectService::class),
|
||||
$this->make(TicketService::class),
|
||||
$language ?? $this->make(LanguageCore::class),
|
||||
);
|
||||
$service->setPermissionService($perms ?? $this->allowingPermissions());
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
/** A repo whose board #3 / item #5 both resolve to project 9. */
|
||||
private function ideaRepoInProject9(array $overrides = []): IdeasRepository
|
||||
{
|
||||
return $this->make(IdeasRepository::class, array_merge([
|
||||
'getSingleCanvas' => fn () => [['id' => 3, 'projectId' => 9, 'title' => 'Board']],
|
||||
'getSingleCanvasItem' => fn () => ['id' => 5, 'canvasId' => 3, 'box' => 'idea'],
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
private function allowingPermissions(): PermissionService
|
||||
{
|
||||
return $this->make(PermissionService::class, [
|
||||
'authorize' => fn () => null,
|
||||
'currentUserCan' => fn () => true,
|
||||
]);
|
||||
}
|
||||
|
||||
private function denyingPermissions(): PermissionService
|
||||
{
|
||||
return $this->make(PermissionService::class, [
|
||||
'authorize' => function (): void {
|
||||
throw new AuthorizationException;
|
||||
},
|
||||
'currentUserCan' => fn () => false,
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Item factory / normalization (behavioural).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public function test_get_idea_item_returns_empty_default_for_null_id(): void
|
||||
{
|
||||
$item = $this->makeService()->getIdeaItem(null, 'research');
|
||||
|
||||
$this->assertSame('', $item['id']);
|
||||
$this->assertSame('research', $item['box']);
|
||||
$this->assertSame('idea', $item['status']);
|
||||
$this->assertSame('', $item['milestoneId']);
|
||||
}
|
||||
|
||||
public function test_get_idea_item_defaults_type_to_idea(): void
|
||||
{
|
||||
$this->assertSame('idea', $this->makeService()->getIdeaItem(null)['box']);
|
||||
}
|
||||
|
||||
public function test_get_idea_item_normalizes_zero_box_to_idea(): void
|
||||
{
|
||||
$repo = $this->ideaRepoInProject9([
|
||||
'getSingleCanvasItem' => fn () => ['id' => 5, 'box' => '0', 'canvasId' => 3, 'description' => 'x'],
|
||||
]);
|
||||
|
||||
$item = $this->makeService(ideasRepo: $repo)->getIdeaItem(5);
|
||||
|
||||
$this->assertSame('idea', $item['box']);
|
||||
$this->assertSame(5, $item['id']);
|
||||
}
|
||||
|
||||
public function test_get_idea_item_keeps_non_zero_box(): void
|
||||
{
|
||||
$repo = $this->ideaRepoInProject9([
|
||||
'getSingleCanvasItem' => fn () => ['id' => 7, 'box' => 'prototype', 'canvasId' => 3],
|
||||
]);
|
||||
|
||||
$this->assertSame('prototype', $this->makeService(ideasRepo: $repo)->getIdeaItem(7)['box']);
|
||||
}
|
||||
|
||||
public function test_ensure_board_exists_returns_zero_when_boards_present(): void
|
||||
{
|
||||
$addCalls = 0;
|
||||
$repo = $this->make(IdeasRepository::class, [
|
||||
'addCanvas' => function () use (&$addCalls) {
|
||||
$addCalls++;
|
||||
|
||||
return '99';
|
||||
},
|
||||
]);
|
||||
|
||||
$this->assertSame(0, $this->makeService(ideasRepo: $repo)->ensureBoardExists(1, 2, [['id' => 10]]));
|
||||
$this->assertSame(0, $addCalls, 'No board should be created when one already exists');
|
||||
}
|
||||
|
||||
public function test_ensure_board_exists_creates_default_when_none(): void
|
||||
{
|
||||
$repo = $this->make(IdeasRepository::class, ['addCanvas' => fn () => '99']);
|
||||
$language = $this->make(LanguageCore::class, ['__' => fn () => 'Board']);
|
||||
|
||||
$this->assertSame(99, $this->makeService(ideasRepo: $repo, language: $language)->ensureBoardExists(1, 2, []));
|
||||
}
|
||||
|
||||
public function test_get_all_boards_normalizes_false_to_empty_array(): void
|
||||
{
|
||||
$repo = $this->make(IdeasRepository::class, ['getAllCanvas' => fn () => false]);
|
||||
|
||||
$this->assertSame([], $this->makeService(ideasRepo: $repo)->getAllBoards(1));
|
||||
}
|
||||
|
||||
public function test_get_board_items_normalizes_false_to_empty_array(): void
|
||||
{
|
||||
$repo = $this->ideaRepoInProject9(['getCanvasItemsById' => fn () => false]);
|
||||
|
||||
$this->assertSame([], $this->makeService(ideasRepo: $repo)->getBoardItems(3));
|
||||
}
|
||||
|
||||
public function test_get_board_title_returns_empty_when_not_found(): void
|
||||
{
|
||||
$repo = $this->make(IdeasRepository::class, ['getSingleCanvas' => fn () => false]);
|
||||
|
||||
$this->assertSame('', $this->makeService(ideasRepo: $repo)->getBoardTitle(123));
|
||||
}
|
||||
|
||||
public function test_get_board_title_returns_first_row_title(): void
|
||||
{
|
||||
$repo = $this->make(IdeasRepository::class, [
|
||||
'getSingleCanvas' => fn () => [['id' => 1, 'title' => 'My Board', 'projectId' => 9]],
|
||||
]);
|
||||
|
||||
$this->assertSame('My Board', $this->makeService(ideasRepo: $repo)->getBoardTitle(1));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Read fences (single-entity-by-id).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public function test_get_board_is_denied_for_a_foreign_project(): void
|
||||
{
|
||||
$service = $this->makeService(ideasRepo: $this->ideaRepoInProject9(), perms: $this->denyingPermissions());
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$service->getBoard(3);
|
||||
}
|
||||
|
||||
public function test_get_board_items_is_denied_for_a_foreign_project(): void
|
||||
{
|
||||
$service = $this->makeService(ideasRepo: $this->ideaRepoInProject9(), perms: $this->denyingPermissions());
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$service->getBoardItems(3);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Mutation fences (fail closed + project-scoped).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public function test_patch_idea_item_is_denied_without_edit(): void
|
||||
{
|
||||
$service = $this->makeService(ideasRepo: $this->ideaRepoInProject9(), perms: $this->denyingPermissions());
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$service->patchIdeaItem(5, ['box' => 'done']);
|
||||
}
|
||||
|
||||
public function test_patch_idea_item_allowed_with_edit(): void
|
||||
{
|
||||
$repo = $this->ideaRepoInProject9(['patchCanvasItem' => fn () => true]);
|
||||
|
||||
$this->assertTrue($this->makeService(ideasRepo: $repo)->patchIdeaItem(5, ['box' => 'done']));
|
||||
}
|
||||
|
||||
public function test_patch_idea_item_fails_closed_for_unknown_item(): void
|
||||
{
|
||||
$patched = false;
|
||||
$repo = $this->make(IdeasRepository::class, [
|
||||
'getSingleCanvasItem' => fn () => false,
|
||||
'patchCanvasItem' => function () use (&$patched): bool {
|
||||
$patched = true;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$this->assertFalse($this->makeService(ideasRepo: $repo)->patchIdeaItem(999, ['box' => 'done']));
|
||||
$this->assertFalse($patched, 'A non-idea/unknown id must never reach the repo patch');
|
||||
}
|
||||
|
||||
public function test_update_idea_item_fails_closed_for_unknown_item(): void
|
||||
{
|
||||
$edited = false;
|
||||
$repo = $this->make(IdeasRepository::class, [
|
||||
'getSingleCanvasItem' => fn () => false,
|
||||
'editCanvasItem' => function () use (&$edited): void {
|
||||
$edited = true;
|
||||
},
|
||||
]);
|
||||
|
||||
$input = ['itemId' => 999, 'box' => 'idea', 'description' => 'x', 'status' => 'idea', 'data' => '', 'tags' => '', 'canvasId' => 3, 'milestoneId' => ''];
|
||||
|
||||
$this->assertSame(0, $this->makeService(ideasRepo: $repo)->updateIdeaItem($input, 9, self::SESSION_USER));
|
||||
$this->assertFalse($edited, 'A non-idea/unknown id must never reach the repo edit');
|
||||
}
|
||||
|
||||
public function test_update_idea_item_is_denied_without_edit(): void
|
||||
{
|
||||
$service = $this->makeService(ideasRepo: $this->ideaRepoInProject9(), perms: $this->denyingPermissions());
|
||||
$input = ['itemId' => 5, 'box' => 'idea', 'description' => 'x', 'status' => 'idea', 'data' => '', 'tags' => '', 'canvasId' => 3, 'milestoneId' => ''];
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$service->updateIdeaItem($input, 9, self::SESSION_USER);
|
||||
}
|
||||
|
||||
public function test_create_idea_item_is_denied_without_create(): void
|
||||
{
|
||||
$service = $this->makeService(ideasRepo: $this->ideaRepoInProject9(), perms: $this->denyingPermissions());
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$service->createIdeaItem(['box' => 'idea', 'description' => 'x', 'status' => 'idea', 'data' => '', 'canvasId' => 3], 9, self::SESSION_USER);
|
||||
}
|
||||
|
||||
public function test_create_board_is_denied_without_create(): void
|
||||
{
|
||||
$service = $this->makeService(perms: $this->denyingPermissions());
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$service->createBoard('New board', 9, self::SESSION_USER);
|
||||
}
|
||||
|
||||
public function test_update_board_is_denied_without_edit(): void
|
||||
{
|
||||
$service = $this->makeService(ideasRepo: $this->ideaRepoInProject9(), perms: $this->denyingPermissions());
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$service->updateBoard(3, 'Renamed');
|
||||
}
|
||||
|
||||
public function test_update_board_fails_closed_for_unknown_board(): void
|
||||
{
|
||||
$updated = false;
|
||||
$repo = $this->make(IdeasRepository::class, [
|
||||
'getSingleCanvas' => fn () => [],
|
||||
'updateCanvas' => function () use (&$updated) {
|
||||
$updated = true;
|
||||
|
||||
return 1;
|
||||
},
|
||||
]);
|
||||
|
||||
$this->assertFalse($this->makeService(ideasRepo: $repo)->updateBoard(999, 'Renamed'));
|
||||
$this->assertFalse($updated, 'A non-idea/unknown board id must never reach the repo update');
|
||||
}
|
||||
|
||||
public function test_delete_canvas_is_denied_and_does_not_delete(): void
|
||||
{
|
||||
$repo = $this->ideaRepoInProject9([
|
||||
'deleteCanvas' => function (): void {
|
||||
throw new \RuntimeException('delete must not run when denied');
|
||||
},
|
||||
]);
|
||||
$service = $this->makeService(ideasRepo: $repo, perms: $this->denyingPermissions());
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$service->deleteCanvas(3);
|
||||
}
|
||||
|
||||
public function test_delete_canvas_item_is_denied_and_does_not_delete(): void
|
||||
{
|
||||
$repo = $this->ideaRepoInProject9([
|
||||
'delCanvasItem' => function (): void {
|
||||
throw new \RuntimeException('delete must not run when denied');
|
||||
},
|
||||
]);
|
||||
$service = $this->makeService(ideasRepo: $repo, perms: $this->denyingPermissions());
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$service->deleteCanvasItem(5);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Batch mutators reject (return false) without writing.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public function test_reorder_ideas_rejects_batch_when_cannot_edit(): void
|
||||
{
|
||||
$sorted = false;
|
||||
$repo = $this->ideaRepoInProject9([
|
||||
'updateIdeaSorting' => function () use (&$sorted): bool {
|
||||
$sorted = true;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
$service = $this->makeService(ideasRepo: $repo, perms: $this->denyingPermissions());
|
||||
|
||||
$this->assertFalse($service->reorderIdeas([['id' => 5, 'sortIndex' => 1]]));
|
||||
$this->assertFalse($sorted, 'A denied batch must never reach the repo sort');
|
||||
}
|
||||
|
||||
public function test_bulk_update_status_rejects_batch_when_cannot_edit(): void
|
||||
{
|
||||
$repo = $this->ideaRepoInProject9(['bulkUpdateIdeaStatus' => fn () => true]);
|
||||
$service = $this->makeService(ideasRepo: $repo, perms: $this->denyingPermissions());
|
||||
|
||||
$this->assertFalse($service->bulkUpdateStatus(['done' => 'item[]=5']));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Comment delete: author allowed; non-author requires moderation.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public function test_remove_idea_comment_allows_the_author_without_moderation(): void
|
||||
{
|
||||
$deleted = null;
|
||||
$commentsRepo = $this->make(CommentRepository::class, [
|
||||
'getComment' => fn () => ['id' => 1, 'userId' => self::SESSION_USER, 'moduleId' => 5],
|
||||
'deleteComment' => function ($id) use (&$deleted): bool {
|
||||
$deleted = $id;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
// Denying engine proves the author path does NOT require comments.moderate.
|
||||
$service = $this->makeService(ideasRepo: $this->ideaRepoInProject9(), commentsRepo: $commentsRepo, perms: $this->denyingPermissions());
|
||||
|
||||
$service->removeIdeaComment(1);
|
||||
|
||||
$this->assertSame(1, $deleted);
|
||||
}
|
||||
|
||||
public function test_remove_idea_comment_denies_non_author_without_moderation(): void
|
||||
{
|
||||
$commentsRepo = $this->make(CommentRepository::class, [
|
||||
'getComment' => fn () => ['id' => 1, 'userId' => 7, 'moduleId' => 5],
|
||||
'deleteComment' => function (): bool {
|
||||
throw new \RuntimeException('delete must not run when moderation is denied');
|
||||
},
|
||||
]);
|
||||
$service = $this->makeService(ideasRepo: $this->ideaRepoInProject9(), commentsRepo: $commentsRepo, perms: $this->denyingPermissions());
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$service->removeIdeaComment(1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Fail-closed on a non-resolving (non-idea) id: never authorize against a null project, never
|
||||
// fall back to the caller-supplied project. (A null project here means "not an idea entity".)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public function test_get_idea_comments_fails_closed_for_non_idea_entity(): void
|
||||
{
|
||||
$fetched = false;
|
||||
$repo = $this->make(IdeasRepository::class, ['getSingleCanvasItem' => fn () => false]);
|
||||
$commentsRepo = $this->make(CommentRepository::class, [
|
||||
'getComments' => function () use (&$fetched) {
|
||||
$fetched = true;
|
||||
|
||||
return [];
|
||||
},
|
||||
]);
|
||||
// Denying engine: if it reached authorize(VIEW, null) it would throw; fail-closed returns [] first.
|
||||
$service = $this->makeService(ideasRepo: $repo, commentsRepo: $commentsRepo, perms: $this->denyingPermissions());
|
||||
|
||||
$this->assertSame([], $service->getIdeaComments('ticket', 123));
|
||||
$this->assertFalse($fetched, 'A non-idea entity must short-circuit before the comment read');
|
||||
}
|
||||
|
||||
public function test_create_idea_item_fails_closed_for_non_idea_board(): void
|
||||
{
|
||||
$created = false;
|
||||
$repo = $this->make(IdeasRepository::class, [
|
||||
'getSingleCanvas' => fn () => [], // canvasId is not an idea board
|
||||
'addCanvasItem' => function () use (&$created) {
|
||||
$created = true;
|
||||
|
||||
return '1';
|
||||
},
|
||||
]);
|
||||
$service = $this->makeService(ideasRepo: $repo);
|
||||
|
||||
$this->assertSame(0, $service->createIdeaItem(['box' => 'idea', 'description' => 'x', 'status' => 'idea', 'data' => '', 'canvasId' => 999], 9, self::SESSION_USER));
|
||||
$this->assertFalse($created, 'A non-idea board canvasId must never create an item against the caller project');
|
||||
}
|
||||
|
||||
public function test_add_idea_comment_fails_closed_for_non_idea_item(): void
|
||||
{
|
||||
$added = false;
|
||||
$repo = $this->make(IdeasRepository::class, ['getSingleCanvasItem' => fn () => false]);
|
||||
$commentsRepo = $this->make(CommentRepository::class, [
|
||||
'addComment' => function () use (&$added) {
|
||||
$added = true;
|
||||
|
||||
return '1';
|
||||
},
|
||||
]);
|
||||
$service = $this->makeService(ideasRepo: $repo, commentsRepo: $commentsRepo);
|
||||
|
||||
$this->assertFalse($service->addIdeaComment('hi', 999, 0, 9, self::SESSION_USER));
|
||||
$this->assertFalse($added, 'A non-idea item must never receive a comment against the caller project');
|
||||
}
|
||||
|
||||
public function test_remove_idea_comment_fails_closed_for_non_author_non_idea_comment(): void
|
||||
{
|
||||
$deleted = false;
|
||||
$repo = $this->make(IdeasRepository::class, ['getSingleCanvasItem' => fn () => false]);
|
||||
$commentsRepo = $this->make(CommentRepository::class, [
|
||||
'getComment' => fn () => ['id' => 1, 'userId' => 7, 'moduleId' => 999],
|
||||
'deleteComment' => function () use (&$deleted): bool {
|
||||
$deleted = true;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
// Allowing engine: proves the refusal is the fail-closed null guard, not a denied authorize.
|
||||
$service = $this->makeService(ideasRepo: $repo, commentsRepo: $commentsRepo, perms: $this->allowingPermissions());
|
||||
|
||||
$service->removeIdeaComment(1);
|
||||
|
||||
$this->assertFalse($deleted, 'A non-author comment on a non-idea item must not be deleted');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Relocation / mass-assignment fences (Copilot review): the incoming canvasId/params can move
|
||||
// an item to another board, so the target board's project must also be authorized.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public function test_patch_idea_item_strips_relocation_and_identity_fields(): void
|
||||
{
|
||||
// patchCanvasItem updates any column it receives; canvasId/id/author must be stripped so a
|
||||
// caller can't relocate the item to another board/project or rewrite its identity.
|
||||
$patched = null;
|
||||
$repo = $this->ideaRepoInProject9([
|
||||
'patchCanvasItem' => function ($id, $params) use (&$patched): bool {
|
||||
$patched = $params;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
$service = $this->makeService(ideasRepo: $repo);
|
||||
|
||||
$service->patchIdeaItem(5, ['status' => 'done', 'canvasId' => 999, 'id' => 1, 'author' => 7]);
|
||||
|
||||
$this->assertArrayNotHasKey('canvasId', $patched);
|
||||
$this->assertArrayNotHasKey('id', $patched);
|
||||
$this->assertArrayNotHasKey('author', $patched);
|
||||
$this->assertSame('done', $patched['status'], 'Legitimate fields still pass through');
|
||||
}
|
||||
|
||||
public function test_update_idea_item_denies_relocation_to_a_foreign_board(): void
|
||||
{
|
||||
// Item lives in project 9 (board 3); the edit's canvasId points at board 99 in project 7.
|
||||
// The user may edit project 9 but NOT project 7 -> the relocation is denied before the write.
|
||||
$repo = $this->make(IdeasRepository::class, [
|
||||
'getSingleCanvasItem' => fn () => ['id' => 5, 'canvasId' => 3, 'box' => 'idea'],
|
||||
'getSingleCanvas' => fn ($id) => $id === 99 ? [['projectId' => 7]] : [['projectId' => 9]],
|
||||
'editCanvasItem' => function (): void {
|
||||
throw new \RuntimeException('relocation must be blocked before the write');
|
||||
},
|
||||
]);
|
||||
$perms = $this->make(PermissionService::class, [
|
||||
'authorize' => function (string $p, ?int $projectId = null): void {
|
||||
if ($projectId === 7) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
},
|
||||
'currentUserCan' => fn () => true,
|
||||
]);
|
||||
$service = $this->makeService(ideasRepo: $repo, perms: $perms);
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$service->updateIdeaItem(['itemId' => 5, 'box' => 'idea', 'description' => 'x', 'status' => 'idea', 'data' => '', 'tags' => '', 'canvasId' => 99, 'milestoneId' => ''], 9, self::SESSION_USER);
|
||||
}
|
||||
|
||||
public function test_update_idea_item_fails_closed_when_target_board_is_not_an_idea_board(): void
|
||||
{
|
||||
$edited = false;
|
||||
$repo = $this->make(IdeasRepository::class, [
|
||||
'getSingleCanvasItem' => fn () => ['id' => 5, 'canvasId' => 3, 'box' => 'idea'],
|
||||
'getSingleCanvas' => fn ($id) => $id === 3 ? [['projectId' => 9]] : [], // target 99 -> not an idea board
|
||||
'editCanvasItem' => function () use (&$edited): void {
|
||||
$edited = true;
|
||||
},
|
||||
]);
|
||||
$service = $this->makeService(ideasRepo: $repo);
|
||||
|
||||
$this->assertSame(0, $service->updateIdeaItem(['itemId' => 5, 'box' => 'idea', 'description' => 'x', 'status' => 'idea', 'data' => '', 'tags' => '', 'canvasId' => 99, 'milestoneId' => ''], 9, self::SESSION_USER));
|
||||
$this->assertFalse($edited, 'A non-idea target board must never receive the relocated item');
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
147
tests/Unit/app/Domain/Menu/Repositories/MenuRepositoryTest.php
Normal file
147
tests/Unit/app/Domain/Menu/Repositories/MenuRepositoryTest.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Menu\Repositories;
|
||||
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Domain\Menu\Repositories\Menu;
|
||||
use Leantime\Domain\Setting\Repositories\Setting;
|
||||
use Leantime\Domain\Tickets\Services\Tickets;
|
||||
use Unit\TestCase;
|
||||
|
||||
class MenuRepositoryTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/**
|
||||
* The test object
|
||||
*
|
||||
* @var Menu
|
||||
*/
|
||||
protected $menu;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
|
||||
parent::setUp();
|
||||
|
||||
if (! defined('BASE_URL')) {
|
||||
define('BASE_URL', 'http://localhost');
|
||||
}
|
||||
|
||||
// Mock classes
|
||||
$settingsRepo = $this->make(Setting::class);
|
||||
$language = $this->make(Language::class);
|
||||
$config = $this->make(Environment::class);
|
||||
$ticketService = $this->make(Tickets::class, [
|
||||
'getLastTicketViewUrl' => function () {
|
||||
return '';
|
||||
},
|
||||
'getLastTimelineViewUrl' => function () {
|
||||
return '';
|
||||
},
|
||||
]);
|
||||
|
||||
// Load class to be tested
|
||||
$this->menu = new Menu(
|
||||
settingsRepo: $settingsRepo,
|
||||
language: $language,
|
||||
config: $config,
|
||||
ticketsService: $ticketService
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
protected function _after()
|
||||
{
|
||||
$this->menu = null;
|
||||
}
|
||||
|
||||
// Write tests below
|
||||
|
||||
/**
|
||||
* Test GetMenuTypes method
|
||||
*/
|
||||
public function test_get_menu_types()
|
||||
{
|
||||
$result = $this->menu->getMenuTypes();
|
||||
|
||||
// Assert that the result is an array
|
||||
$this->assertIsArray($result);
|
||||
|
||||
// Assert that menu types have the expected keys
|
||||
$this->assertContains(Menu::DEFAULT_MENU, array_keys($result));
|
||||
|
||||
// Further assertions can be done depending on use case and requirements
|
||||
}
|
||||
|
||||
public function test_get_default_menu_structure()
|
||||
{
|
||||
$expected = $this->menu::DEFAULT_MENU;
|
||||
$defaultStructure = $this->menu->getMenuStructure();
|
||||
|
||||
// Menu structure checks if roles are set in a menu item and will disable a menu item if not allowed to see
|
||||
// User executing the test is not logged in, has no session so it being disabled is correct
|
||||
$this->menu->menuStructures[$expected][40]['submenu'][80]['type'] = 'disabled';
|
||||
$this->menu->menuStructures[$expected][30]['submenu'][30]['href'] = '/ideas/showBoards';
|
||||
|
||||
$this->assertEquals($this->menu->menuStructures[$expected], $defaultStructure, 'Default menu structure does not match the expected structure');
|
||||
}
|
||||
|
||||
public function test_get_full_menu_structure()
|
||||
{
|
||||
$expected = 'full_menu';
|
||||
$fullMenuStructure = $this->menu->getMenuStructure('full_menu');
|
||||
$this->menu->menuStructures[$expected][80]['submenu'][83]['type'] = 'disabled';
|
||||
|
||||
$this->assertEquals($this->menu->menuStructures[$expected], $fullMenuStructure, 'Full menu structure does not match the expected structure');
|
||||
}
|
||||
|
||||
public function test_get_invalid_menu_structure()
|
||||
{
|
||||
$expected = [];
|
||||
$invalidMenuStructure = $this->menu->getMenuStructure('invalid');
|
||||
$this->assertEquals($expected, $invalidMenuStructure, 'Invalid menu structure does not match the expected structure');
|
||||
}
|
||||
|
||||
public function test_get_filtered_menu_structure()
|
||||
{
|
||||
|
||||
\Leantime\Core\Events\EventDispatcher::add_filter_listener('leantime.domain.menu.repositories.menu.getMenuStructure.menuStructures.company', function ($menu) {
|
||||
|
||||
if (isset($menu[15]) && isset($menu[15]['submenu'])) {
|
||||
unset($menu[15]['submenu'][20]);
|
||||
}
|
||||
|
||||
return $menu;
|
||||
|
||||
}, 10);
|
||||
|
||||
$fullMenuStructure = $this->menu->getMenuStructure('company');
|
||||
$this->assertIsArray($fullMenuStructure[15]['submenu']);
|
||||
|
||||
$this->assertFalse(isset($fullMenuStructure[15]['submenu'][20]), 'menu item was not removed');
|
||||
|
||||
}
|
||||
|
||||
public function test_inject_new_project_menu_type()
|
||||
{
|
||||
|
||||
\Leantime\Core\Events\EventDispatcher::add_filter_listener('leantime.domain.menu.repositories.menu.getMenuStructure.menuStructures', function ($menuStructure) {
|
||||
|
||||
$testStructure = [
|
||||
10 => ['item' => 'myNewMenu', 'type' => 'item'],
|
||||
];
|
||||
|
||||
$menuStructure['testType'] = $testStructure;
|
||||
|
||||
return $menuStructure;
|
||||
|
||||
}, 10);
|
||||
|
||||
$fullMenuStructure = $this->menu->getMenuStructure('testType');
|
||||
$this->assertIsArray($fullMenuStructure);
|
||||
$this->assertEquals('myNewMenu', $fullMenuStructure[10]['item']);
|
||||
|
||||
}
|
||||
}
|
||||
92
tests/Unit/app/Domain/Menu/Services/MenuServiceTest.php
Normal file
92
tests/Unit/app/Domain/Menu/Services/MenuServiceTest.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Menu\Services;
|
||||
|
||||
use Leantime\Domain\Menu\Repositories\Menu as MenuRepository;
|
||||
use Leantime\Domain\Menu\Services\Menu;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Leantime\Domain\Setting\Services\Setting;
|
||||
use Leantime\Domain\Users\Services\Users;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the pure project-selector helper logic extracted from the
|
||||
* Menu ProjectSelector HxController into the Menu service.
|
||||
*/
|
||||
class MenuServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/**
|
||||
* Builds a Menu service with stubbed collaborators. The helpers under test
|
||||
* (settings link + redirect url) do not touch any collaborator, so the
|
||||
* dependencies just need to exist.
|
||||
*/
|
||||
private function makeService(): Menu
|
||||
{
|
||||
return new Menu(
|
||||
$this->make(ProjectService::class),
|
||||
$this->make(Users::class),
|
||||
$this->make(Setting::class),
|
||||
$this->make(MenuRepository::class),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_settings_link_is_populated_for_project_menu(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
$link = $service->getProjectSelectorSettingsLink('project');
|
||||
|
||||
$this->assertSame('projects', $link['module']);
|
||||
$this->assertSame('showProject', $link['action']);
|
||||
$this->assertArrayHasKey('label', $link);
|
||||
$this->assertArrayHasKey('settingsIcon', $link);
|
||||
$this->assertArrayHasKey('settingsTooltip', $link);
|
||||
}
|
||||
|
||||
public function test_settings_link_is_populated_for_default_menu(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
$link = $service->getProjectSelectorSettingsLink('default');
|
||||
|
||||
$this->assertSame('projects', $link['module']);
|
||||
$this->assertSame('showProject', $link['action']);
|
||||
}
|
||||
|
||||
public function test_settings_link_is_empty_for_other_menu_types(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
$link = $service->getProjectSelectorSettingsLink('personal');
|
||||
|
||||
$this->assertSame([
|
||||
'label' => '',
|
||||
'module' => '',
|
||||
'action' => '',
|
||||
'settingsIcon' => '',
|
||||
'settingsTooltip' => '',
|
||||
], $link);
|
||||
}
|
||||
|
||||
public function test_redirect_url_rewrites_show_project_to_dashboard(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
$this->assertSame(
|
||||
'/dashboard/show',
|
||||
$service->getProjectSelectorRedirectUrl('/projects/showProject/5')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_redirect_url_is_unchanged_for_other_uris(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
$this->assertSame(
|
||||
'/tickets/showKanban',
|
||||
$service->getProjectSelectorRedirectUrl('/tickets/showKanban')
|
||||
);
|
||||
}
|
||||
}
|
||||
116
tests/Unit/app/Domain/Notifications/NotificationCategoryTest.php
Normal file
116
tests/Unit/app/Domain/Notifications/NotificationCategoryTest.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Notifications;
|
||||
|
||||
use Leantime\Domain\Notifications\Models\Notification;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class NotificationCategoryTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @dataProvider moduleToCategoryProvider
|
||||
*/
|
||||
public function test_get_category_for_module_return_correct_category(string $module, ?string $expectedCategory): void
|
||||
{
|
||||
$this->assertSame($expectedCategory, Notification::getCategoryForModule($module));
|
||||
}
|
||||
|
||||
public static function moduleToCategoryProvider(): array
|
||||
{
|
||||
return [
|
||||
'tickets maps to tasks' => ['tickets', 'tasks'],
|
||||
'comments maps to comments' => ['comments', 'comments'],
|
||||
'goalcanvas maps to goals' => ['goalcanvas', 'goals'],
|
||||
'ideas maps to ideas' => ['ideas', 'ideas'],
|
||||
'projects maps to projects' => ['projects', 'projects'],
|
||||
'leancanvas maps to boards' => ['leancanvas', 'boards'],
|
||||
'swotcanvas maps to boards' => ['swotcanvas', 'boards'],
|
||||
'retroscanvas maps to boards' => ['retroscanvas', 'boards'],
|
||||
'cpcanvas maps to boards' => ['cpcanvas', 'boards'],
|
||||
'unknown module returns null' => ['someOtherModule', null],
|
||||
];
|
||||
}
|
||||
|
||||
public function test_all_categories_have_required_structure(): void
|
||||
{
|
||||
$categories = Notification::NOTIFICATION_CATEGORIES;
|
||||
|
||||
$this->assertArrayHasKey('tasks', $categories);
|
||||
$this->assertArrayHasKey('comments', $categories);
|
||||
$this->assertArrayHasKey('goals', $categories);
|
||||
$this->assertArrayHasKey('ideas', $categories);
|
||||
$this->assertArrayHasKey('projects', $categories);
|
||||
$this->assertArrayHasKey('boards', $categories);
|
||||
$this->assertCount(6, $categories);
|
||||
|
||||
// Each category must have 'modules' and 'description' keys
|
||||
foreach ($categories as $key => $config) {
|
||||
$this->assertArrayHasKey('modules', $config, "Category '$key' missing 'modules' key");
|
||||
$this->assertArrayHasKey('description', $config, "Category '$key' missing 'description' key");
|
||||
$this->assertIsArray($config['modules'], "Category '$key' modules must be an array");
|
||||
$this->assertIsString($config['description'], "Category '$key' description must be a string");
|
||||
$this->assertNotEmpty($config['description'], "Category '$key' description must not be empty");
|
||||
}
|
||||
}
|
||||
|
||||
public function test_goalcanvas_is_not_boards(): void
|
||||
{
|
||||
// goalcanvas specifically maps to 'goals', NOT 'boards'
|
||||
$this->assertSame('goals', Notification::getCategoryForModule('goalcanvas'));
|
||||
$this->assertNotSame('boards', Notification::getCategoryForModule('goalcanvas'));
|
||||
}
|
||||
|
||||
public function test_get_category_keys_returns_all_keys(): void
|
||||
{
|
||||
$keys = Notification::getCategoryKeys();
|
||||
$this->assertCount(6, $keys);
|
||||
$this->assertContains('tasks', $keys);
|
||||
$this->assertContains('comments', $keys);
|
||||
$this->assertContains('goals', $keys);
|
||||
$this->assertContains('ideas', $keys);
|
||||
$this->assertContains('projects', $keys);
|
||||
$this->assertContains('boards', $keys);
|
||||
}
|
||||
|
||||
public function test_relevance_levels_are_defined(): void
|
||||
{
|
||||
$this->assertSame('all', Notification::RELEVANCE_ALL);
|
||||
$this->assertSame('my_work', Notification::RELEVANCE_MY_WORK);
|
||||
$this->assertSame('muted', Notification::RELEVANCE_MUTED);
|
||||
|
||||
$levels = Notification::RELEVANCE_LEVELS;
|
||||
$this->assertCount(3, $levels);
|
||||
$this->assertArrayHasKey('all', $levels);
|
||||
$this->assertArrayHasKey('my_work', $levels);
|
||||
$this->assertArrayHasKey('muted', $levels);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider relevanceLevelValidationProvider
|
||||
*/
|
||||
public function test_is_valid_relevance_level(string $level, bool $expected): void
|
||||
{
|
||||
$this->assertSame($expected, Notification::isValidRelevanceLevel($level));
|
||||
}
|
||||
|
||||
public static function relevanceLevelValidationProvider(): array
|
||||
{
|
||||
return [
|
||||
'all is valid' => ['all', true],
|
||||
'my_work is valid' => ['my_work', true],
|
||||
'muted is valid' => ['muted', true],
|
||||
'empty is invalid' => ['', false],
|
||||
'random string is invalid' => ['something_else', false],
|
||||
'ALL uppercase is invalid' => ['ALL', false],
|
||||
];
|
||||
}
|
||||
|
||||
public function test_notification_model_has_action_property(): void
|
||||
{
|
||||
$notification = new Notification;
|
||||
$this->assertSame('', $notification->action);
|
||||
|
||||
$notification->action = 'created';
|
||||
$this->assertSame('created', $notification->action);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Notifications\Services;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\RequestException;
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Leantime\Core\Language as LanguageCore;
|
||||
use Leantime\Domain\Notifications\Models\Notification as NotificationModel;
|
||||
use Leantime\Domain\Notifications\Services\Messengers;
|
||||
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
|
||||
use Unit\TestCase;
|
||||
|
||||
class MessengersServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
private function makeNotification(int $projectId = 1, string $message = 'Test notification'): NotificationModel
|
||||
{
|
||||
$notification = new NotificationModel;
|
||||
$notification->projectId = $projectId;
|
||||
$notification->message = $message;
|
||||
$notification->url = ['url' => 'https://example.com/ticket/123'];
|
||||
|
||||
return $notification;
|
||||
}
|
||||
|
||||
public function test_send_notification_to_messengers_skips_telegram_when_unconfigured(): void
|
||||
{
|
||||
$posted = false;
|
||||
$client = $this->make(Client::class, [
|
||||
'post' => function () use (&$posted) {
|
||||
$posted = true;
|
||||
|
||||
return new Response(200);
|
||||
},
|
||||
]);
|
||||
|
||||
$settingRepo = $this->make(SettingRepository::class, [
|
||||
'getSetting' => fn () => false,
|
||||
]);
|
||||
|
||||
$language = $this->make(LanguageCore::class);
|
||||
|
||||
$messengers = new Messengers($client, $settingRepo, $language);
|
||||
$messengers->sendNotificationToMessengers($this->makeNotification(), 'Test Project', ['telegram']);
|
||||
|
||||
$this->assertFalse($posted);
|
||||
}
|
||||
|
||||
public function test_telegram_webhook_returns_false_when_hook_missing_required_fields(): void
|
||||
{
|
||||
$posted = false;
|
||||
$client = $this->make(Client::class, [
|
||||
'post' => function () use (&$posted) {
|
||||
$posted = true;
|
||||
|
||||
return new Response(200);
|
||||
},
|
||||
]);
|
||||
|
||||
$settingRepo = $this->make(SettingRepository::class, [
|
||||
'getSetting' => fn () => serialize([
|
||||
'telegramBotToken' => '12345:ABC',
|
||||
'telegramChatId' => '',
|
||||
'telegramTopicId' => '',
|
||||
]),
|
||||
]);
|
||||
|
||||
$language = $this->make(LanguageCore::class);
|
||||
|
||||
$messengers = new Messengers($client, $settingRepo, $language);
|
||||
$messengers->sendNotificationToMessengers($this->makeNotification(), 'Test Project', ['telegram']);
|
||||
|
||||
$this->assertFalse($posted);
|
||||
}
|
||||
|
||||
public function test_telegram_webhook_posts_to_api_and_succeeds(): void
|
||||
{
|
||||
$capturedUrl = null;
|
||||
$capturedOptions = null;
|
||||
|
||||
$client = $this->make(Client::class, [
|
||||
'post' => function ($url, $options) use (&$capturedUrl, &$capturedOptions) {
|
||||
$capturedUrl = $url;
|
||||
$capturedOptions = $options;
|
||||
|
||||
return new Response(200, [], json_encode(['ok' => true]));
|
||||
},
|
||||
]);
|
||||
|
||||
$settingRepo = $this->make(SettingRepository::class, [
|
||||
'getSetting' => fn () => serialize([
|
||||
'telegramBotToken' => '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11',
|
||||
'telegramChatId' => '987654321',
|
||||
'telegramTopicId' => '',
|
||||
]),
|
||||
]);
|
||||
|
||||
$language = $this->make(LanguageCore::class);
|
||||
|
||||
$messengers = new Messengers($client, $settingRepo, $language);
|
||||
$messengers->sendNotificationToMessengers($this->makeNotification(1, 'Task created'), 'Acme Project', ['telegram']);
|
||||
|
||||
$this->assertSame('https://api.telegram.org/bot123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11/sendMessage', $capturedUrl);
|
||||
$this->assertArrayHasKey('json', $capturedOptions);
|
||||
$this->assertSame('987654321', $capturedOptions['json']['chat_id']);
|
||||
$this->assertStringContainsString('<b>Acme Project</b>', $capturedOptions['json']['text']);
|
||||
$this->assertStringContainsString('Task created', $capturedOptions['json']['text']);
|
||||
$this->assertStringContainsString('https://example.com/ticket/123', $capturedOptions['json']['text']);
|
||||
$this->assertSame('HTML', $capturedOptions['json']['parse_mode']);
|
||||
$this->assertArrayNotHasKey('message_thread_id', $capturedOptions['json']);
|
||||
}
|
||||
|
||||
public function test_telegram_webhook_includes_message_thread_id_when_topic_id_provided(): void
|
||||
{
|
||||
$capturedOptions = null;
|
||||
|
||||
$client = $this->make(Client::class, [
|
||||
'post' => function ($url, $options) use (&$capturedOptions) {
|
||||
$capturedOptions = $options;
|
||||
|
||||
return new Response(200, [], json_encode(['ok' => true]));
|
||||
},
|
||||
]);
|
||||
|
||||
$settingRepo = $this->make(SettingRepository::class, [
|
||||
'getSetting' => fn () => serialize([
|
||||
'telegramBotToken' => '123456:ABC-DEF',
|
||||
'telegramChatId' => '-1001234567890',
|
||||
'telegramTopicId' => '42',
|
||||
]),
|
||||
]);
|
||||
|
||||
$language = $this->make(LanguageCore::class);
|
||||
|
||||
$messengers = new Messengers($client, $settingRepo, $language);
|
||||
$messengers->sendNotificationToMessengers($this->makeNotification(), 'Test Project', ['telegram']);
|
||||
|
||||
$this->assertArrayHasKey('json', $capturedOptions);
|
||||
$this->assertSame('-1001234567890', $capturedOptions['json']['chat_id']);
|
||||
$this->assertSame(42, $capturedOptions['json']['message_thread_id']);
|
||||
}
|
||||
|
||||
public function test_telegram_webhook_catches_guzzle_exception_and_returns_false(): void
|
||||
{
|
||||
$client = $this->make(Client::class, [
|
||||
'post' => function () {
|
||||
throw new RequestException('API connection error', new Request('POST', 'test'));
|
||||
},
|
||||
]);
|
||||
|
||||
$settingRepo = $this->make(SettingRepository::class, [
|
||||
'getSetting' => fn () => serialize([
|
||||
'telegramBotToken' => '123456:ABC-DEF',
|
||||
'telegramChatId' => '987654321',
|
||||
'telegramTopicId' => '',
|
||||
]),
|
||||
]);
|
||||
|
||||
$language = $this->make(LanguageCore::class);
|
||||
|
||||
$messengers = new Messengers($client, $settingRepo, $language);
|
||||
|
||||
$reflectedMethod = new \ReflectionMethod($messengers, 'telegramWebhook');
|
||||
$reflectedMethod->setAccessible(true);
|
||||
$result = $reflectedMethod->invoke($messengers, $this->makeNotification());
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Notifications\Services;
|
||||
|
||||
use Leantime\Core\Language as LanguageCore;
|
||||
use Leantime\Domain\Notifications\Repositories\Notifications as NotificationRepository;
|
||||
use Leantime\Domain\Notifications\Services\Notifications;
|
||||
use Leantime\Domain\Users\Repositories\Users as UserRepository;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the flash-notification orchestration extracted from the
|
||||
* Notifications GetLatestGrowl controller into the Notifications service.
|
||||
*/
|
||||
class NotificationsServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/**
|
||||
* Builds a Notifications service with stubbed dependencies. The
|
||||
* consumeFlashNotification logic only touches the session, so the
|
||||
* collaborators just need to exist.
|
||||
*/
|
||||
private function makeService(): Notifications
|
||||
{
|
||||
return new Notifications(
|
||||
$this->make(NotificationRepository::class),
|
||||
$this->make(UserRepository::class),
|
||||
$this->make(LanguageCore::class),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_consume_flash_notification_returns_null_when_empty(): void
|
||||
{
|
||||
session(['notification' => '']);
|
||||
|
||||
$service = $this->makeService();
|
||||
|
||||
$this->assertNull($service->consumeFlashNotification());
|
||||
}
|
||||
|
||||
public function test_consume_flash_notification_returns_payload_and_clears_session(): void
|
||||
{
|
||||
session(['notification' => 'Saved!']);
|
||||
session(['notificationType' => 'success']);
|
||||
session(['eventId' => 'ticket-42']);
|
||||
|
||||
$service = $this->makeService();
|
||||
|
||||
$payload = $service->consumeFlashNotification();
|
||||
|
||||
$this->assertSame([
|
||||
'notification' => 'Saved!',
|
||||
'type' => 'success',
|
||||
'eventId' => 'ticket-42',
|
||||
], $payload);
|
||||
|
||||
// Read-once: session keys are cleared after consumption.
|
||||
$this->assertSame('', session('notification'));
|
||||
$this->assertSame('', session('notificationType'));
|
||||
$this->assertSame('', session('eventId'));
|
||||
}
|
||||
|
||||
public function test_consume_flash_notification_defaults_missing_type_and_event(): void
|
||||
{
|
||||
session()->forget('notificationType');
|
||||
session()->forget('eventId');
|
||||
session(['notification' => 'Hello']);
|
||||
|
||||
$service = $this->makeService();
|
||||
|
||||
$payload = $service->consumeFlashNotification();
|
||||
|
||||
$this->assertSame([
|
||||
'notification' => 'Hello',
|
||||
'type' => '',
|
||||
'eventId' => '',
|
||||
], $payload);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// markRead() — session-based JSON-RPC wrapper (must target the session user)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public function test_mark_read_all_targets_the_session_user(): void
|
||||
{
|
||||
session(['userdata' => ['id' => 42]]);
|
||||
|
||||
$capturedUserId = null;
|
||||
$repo = $this->make(NotificationRepository::class, [
|
||||
'markAllNotificationRead' => function ($userId, ...$rest) use (&$capturedUserId) {
|
||||
$capturedUserId = $userId;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$service = new Notifications(
|
||||
$repo,
|
||||
$this->make(UserRepository::class),
|
||||
$this->make(LanguageCore::class),
|
||||
);
|
||||
|
||||
$this->assertTrue($service->markRead('all'));
|
||||
$this->assertSame(42, $capturedUserId, "markRead('all') must use the session user");
|
||||
}
|
||||
|
||||
public function test_mark_read_specific_id_delegates_to_repo(): void
|
||||
{
|
||||
session(['userdata' => ['id' => 7]]);
|
||||
|
||||
$calledWith = null;
|
||||
$repo = $this->make(NotificationRepository::class, [
|
||||
'markNotificationRead' => function ($id, ...$rest) use (&$calledWith) {
|
||||
$calledWith = $id;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$service = new Notifications(
|
||||
$repo,
|
||||
$this->make(UserRepository::class),
|
||||
$this->make(LanguageCore::class),
|
||||
);
|
||||
|
||||
$this->assertTrue($service->markRead(5));
|
||||
$this->assertSame(5, $calledWith);
|
||||
}
|
||||
}
|
||||
244
tests/Unit/app/Domain/Oidc/Controllers/MobileTest.php
Normal file
244
tests/Unit/app/Domain/Oidc/Controllers/MobileTest.php
Normal file
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\app\Domain\Oidc\Controllers;
|
||||
|
||||
use Leantime\Core\Application;
|
||||
use Leantime\Core\Auth\Permissions\PermissionEnforcer;
|
||||
use Leantime\Core\Bootstrap\LoadConfig;
|
||||
use Leantime\Core\Bootstrap\SetRequestForConsole;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\UI\Template;
|
||||
use Leantime\Domain\Auth\Repositories\AccessTokenRepository;
|
||||
use Leantime\Domain\Oidc\Controllers\Mobile;
|
||||
use Leantime\Domain\Oidc\Services\OidcMobileCode;
|
||||
use Leantime\Domain\Plugins\Services\Plugins;
|
||||
use Leantime\Domain\Users\Repositories\Users as UserRepository;
|
||||
|
||||
/**
|
||||
* Unit tests for the mobile SSO exchange endpoint.
|
||||
*
|
||||
* These pin the security-critical contract: POST-only, PKCE-before-consume
|
||||
* (a bad verifier must NOT burn the code), and orphan-token prevention
|
||||
* (mint only after user existence is confirmed).
|
||||
*/
|
||||
class MobileTest extends \Unit\TestCase
|
||||
{
|
||||
private OidcMobileCode $codes;
|
||||
|
||||
private AccessTokenRepository $tokens;
|
||||
|
||||
private UserRepository $userRepo;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->app = new Application(APP_ROOT);
|
||||
$this->app->bootstrapWith([LoadConfig::class, SetRequestForConsole::class]);
|
||||
$this->app->boot();
|
||||
$this->app['view'] = $this->createMock(\Illuminate\View\Factory::class);
|
||||
$this->app['session'] = $this->createMock(\Illuminate\Session\SessionManager::class);
|
||||
$this->app->instance(PermissionEnforcer::class, $this->createMock(PermissionEnforcer::class));
|
||||
|
||||
$this->codes = $this->createMock(OidcMobileCode::class);
|
||||
$this->tokens = $this->createMock(AccessTokenRepository::class);
|
||||
$this->userRepo = $this->createMock(UserRepository::class);
|
||||
$this->app->instance(OidcMobileCode::class, $this->codes);
|
||||
$this->app->instance(AccessTokenRepository::class, $this->tokens);
|
||||
$this->app->instance(UserRepository::class, $this->userRepo);
|
||||
|
||||
// Mobile SSO is gated on AdvancedAuth (see Mobile::exchange). Mock the
|
||||
// plugin as installed so these exchange-contract tests run past the gate;
|
||||
// the gate itself is verified live e2e (AdvancedAuth off -> 404).
|
||||
$plugins = $this->createMock(Plugins::class);
|
||||
$plugins->method('isEnabled')->willReturn(true);
|
||||
$this->app->instance(Plugins::class, $plugins);
|
||||
|
||||
// Back the RateLimiter facade with a fresh in-memory store so throttle
|
||||
// state is deterministic and isolated per test.
|
||||
\Illuminate\Support\Facades\Facade::setFacadeApplication($this->app);
|
||||
$this->app->instance(
|
||||
\Illuminate\Cache\RateLimiter::class,
|
||||
new \Illuminate\Cache\RateLimiter(
|
||||
new \Illuminate\Cache\Repository(new \Illuminate\Cache\ArrayStore)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private function makeController(string $method = 'POST', array $body = []): Mobile
|
||||
{
|
||||
// IncomingRequest inherits getMethod() from Symfony's Request, where it
|
||||
// reads from the request's internal server bag. Building a real instance
|
||||
// is simpler and more accurate than mocking through the inheritance chain.
|
||||
// $body populates the POST (request) bag — what the controller reads via
|
||||
// ->post(); a query string on the URL is deliberately NOT read.
|
||||
$request = IncomingRequest::create('/oidc/mobile/exchange', $method, $body);
|
||||
$this->app->instance(IncomingRequest::class, $request);
|
||||
|
||||
return new Mobile($request, $this->createMock(Template::class), $this->createMock(Language::class));
|
||||
}
|
||||
|
||||
private function bodyOf($response): array
|
||||
{
|
||||
return json_decode($response->getContent(), true);
|
||||
}
|
||||
|
||||
public function test_get_is_rejected_with_405(): void
|
||||
{
|
||||
// Peek must never be called — the request is rejected before the code store is touched.
|
||||
$this->codes->expects($this->never())->method('peekCode');
|
||||
|
||||
$controller = $this->makeController('GET', ['code' => 'x', 'code_verifier' => 'y']);
|
||||
$response = $controller->exchange([]);
|
||||
|
||||
$this->assertSame(405, $response->getStatusCode());
|
||||
$this->assertSame('POST', $response->headers->get('Allow'));
|
||||
$this->assertSame('method_not_allowed', $this->bodyOf($response)['error']);
|
||||
}
|
||||
|
||||
public function test_missing_code_returns_400(): void
|
||||
{
|
||||
$this->codes->expects($this->never())->method('peekCode');
|
||||
|
||||
$controller = $this->makeController();
|
||||
$response = $controller->exchange([]);
|
||||
|
||||
$this->assertSame(400, $response->getStatusCode());
|
||||
$this->assertSame('missing_code', $this->bodyOf($response)['error']);
|
||||
}
|
||||
|
||||
public function test_unknown_code_returns_401_and_does_not_consume(): void
|
||||
{
|
||||
$this->codes->method('peekCode')->with('bad')->willReturn(null);
|
||||
// Nothing to consume for an unknown code — but assert it explicitly.
|
||||
$this->codes->expects($this->never())->method('consumeCode');
|
||||
|
||||
$controller = $this->makeController('POST', ['code' => 'bad', 'code_verifier' => 'v']);
|
||||
$response = $controller->exchange([]);
|
||||
|
||||
$this->assertSame(401, $response->getStatusCode());
|
||||
$this->assertSame('invalid_code', $this->bodyOf($response)['error']);
|
||||
}
|
||||
|
||||
public function test_invalid_verifier_does_not_burn_the_code(): void
|
||||
{
|
||||
// The core DoS-protection contract: a wrong verifier from a scheme-
|
||||
// hijacker must not consume the code, so the legitimate app can still
|
||||
// exchange it.
|
||||
$this->codes->method('peekCode')->willReturn([
|
||||
'userId' => 42,
|
||||
'challenge' => 'somechallenge',
|
||||
]);
|
||||
$this->codes->expects($this->never())->method('consumeCode');
|
||||
$this->tokens->expects($this->never())->method('createToken');
|
||||
|
||||
$controller = $this->makeController('POST', ['code' => 'good', 'code_verifier' => 'wrong-verifier']);
|
||||
$response = $controller->exchange([]);
|
||||
|
||||
$this->assertSame(401, $response->getStatusCode());
|
||||
$this->assertSame('invalid_verifier', $this->bodyOf($response)['error']);
|
||||
}
|
||||
|
||||
public function test_missing_user_returns_401_without_minting(): void
|
||||
{
|
||||
// PKCE(S256) of the verifier 'testverifier' — used below to pass PKCE.
|
||||
$verifier = 'testverifier';
|
||||
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
|
||||
|
||||
$this->codes->method('peekCode')->willReturn(['userId' => 99, 'challenge' => $challenge]);
|
||||
$this->userRepo->method('getUser')->with(99)->willReturn([]);
|
||||
$this->codes->expects($this->never())->method('consumeCode');
|
||||
$this->tokens->expects($this->never())->method('createToken');
|
||||
|
||||
$controller = $this->makeController('POST', ['code' => 'good', 'code_verifier' => $verifier]);
|
||||
$response = $controller->exchange([]);
|
||||
|
||||
$this->assertSame(401, $response->getStatusCode());
|
||||
$this->assertSame('invalid_user', $this->bodyOf($response)['error']);
|
||||
}
|
||||
|
||||
public function test_valid_exchange_consumes_code_and_mints_token(): void
|
||||
{
|
||||
$verifier = 'testverifier';
|
||||
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
|
||||
|
||||
$this->codes->method('peekCode')->with('good')->willReturn(['userId' => 7, 'challenge' => $challenge]);
|
||||
$this->userRepo->method('getUser')->with(7)->willReturn([
|
||||
'id' => 7, 'firstname' => 'A', 'lastname' => 'B', 'username' => 'a@b',
|
||||
'password' => 'SHOULD_NOT_APPEAR', 'twoFAEnabled' => 1,
|
||||
]);
|
||||
// Code consumed exactly once, AFTER all validation; returns true (this
|
||||
// caller won the single-use race), so minting proceeds.
|
||||
$this->codes->expects($this->once())->method('consumeCode')->with('good')->willReturn(true);
|
||||
// Minted with full scope AND an explicit expiry (not non-expiring).
|
||||
$this->tokens->expects($this->once())->method('createToken')
|
||||
->with(7, 'mobile-sso', ['*'], $this->isInstanceOf(\DateTimeInterface::class))
|
||||
->willReturn(['token' => 'the-token']);
|
||||
|
||||
$controller = $this->makeController('POST', ['code' => 'good', 'code_verifier' => $verifier]);
|
||||
$response = $controller->exchange([]);
|
||||
|
||||
$this->assertSame(200, $response->getStatusCode());
|
||||
$body = $this->bodyOf($response);
|
||||
$this->assertSame('the-token', $body['token']);
|
||||
// Only safe identity fields — never password / 2FA state.
|
||||
$this->assertSame(['id', 'firstname', 'lastname', 'username'], array_keys($body['user']));
|
||||
}
|
||||
|
||||
public function test_secrets_in_query_string_are_ignored(): void
|
||||
{
|
||||
// The code + verifier must come from the POST body, never the URL query
|
||||
// (URLs land in access logs). A ?code=... is not read, so this is a
|
||||
// missing_code — and the code store is never touched.
|
||||
$this->codes->expects($this->never())->method('peekCode');
|
||||
|
||||
$request = IncomingRequest::create('/oidc/mobile/exchange?code=fromquery&code_verifier=v', 'POST');
|
||||
$this->app->instance(IncomingRequest::class, $request);
|
||||
$controller = new Mobile($request, $this->createMock(Template::class), $this->createMock(Language::class));
|
||||
$response = $controller->exchange([]);
|
||||
|
||||
$this->assertSame(400, $response->getStatusCode());
|
||||
$this->assertSame('missing_code', $this->bodyOf($response)['error']);
|
||||
}
|
||||
|
||||
public function test_exchange_is_rate_limited_per_ip(): void
|
||||
{
|
||||
// Once the per-IP cap is hit, further attempts are refused with 429
|
||||
// BEFORE the code store is consulted — throttling code/verifier probing.
|
||||
$this->codes->expects($this->never())->method('peekCode');
|
||||
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
\Illuminate\Support\Facades\RateLimiter::hit('oidc.mobile.exchange:127.0.0.1', 60);
|
||||
}
|
||||
|
||||
$controller = $this->makeController('POST', ['code' => 'x', 'code_verifier' => 'y']);
|
||||
$response = $controller->exchange([]);
|
||||
|
||||
$this->assertSame(429, $response->getStatusCode());
|
||||
$this->assertSame('too_many_requests', $this->bodyOf($response)['error']);
|
||||
$this->assertNotNull($response->headers->get('Retry-After'));
|
||||
}
|
||||
|
||||
public function test_lost_consume_race_does_not_mint(): void
|
||||
{
|
||||
// Two concurrent exchanges both peek the same valid code; the one whose
|
||||
// atomic consumeCode() returns false (the other burned it first) must
|
||||
// NOT mint a second token from a single-use code.
|
||||
$verifier = 'testverifier';
|
||||
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
|
||||
|
||||
$this->codes->method('peekCode')->willReturn(['userId' => 7, 'challenge' => $challenge]);
|
||||
$this->userRepo->method('getUser')->with(7)->willReturn([
|
||||
'id' => 7, 'firstname' => 'A', 'lastname' => 'B', 'username' => 'a@b',
|
||||
]);
|
||||
$this->codes->method('consumeCode')->with('good')->willReturn(false);
|
||||
$this->tokens->expects($this->never())->method('createToken');
|
||||
|
||||
$controller = $this->makeController('POST', ['code' => 'good', 'code_verifier' => $verifier]);
|
||||
$response = $controller->exchange([]);
|
||||
|
||||
$this->assertSame(401, $response->getStatusCode());
|
||||
$this->assertSame('invalid_code', $this->bodyOf($response)['error']);
|
||||
}
|
||||
}
|
||||
62
tests/Unit/app/Domain/Oidc/Services/OidcMobileCodeTest.php
Normal file
62
tests/Unit/app/Domain/Oidc/Services/OidcMobileCodeTest.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\app\Domain\Oidc\Services;
|
||||
|
||||
use Leantime\Domain\Oidc\Services\OidcMobileCode;
|
||||
|
||||
/**
|
||||
* Unit tests for the mobile SSO one-time-code store.
|
||||
*
|
||||
* Pins the single-use contract: peekCode() is non-destructive, and consumeCode()
|
||||
* burns the code exactly once (returns true for the caller that burns it, false
|
||||
* for a second/unknown code). Runs against the array cache store from
|
||||
* \Unit\TestCase, which supports the atomic lock consumeCode() takes.
|
||||
*/
|
||||
class OidcMobileCodeTest extends \Unit\TestCase
|
||||
{
|
||||
private OidcMobileCode $codes;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->codes = new OidcMobileCode;
|
||||
}
|
||||
|
||||
public function test_peek_is_non_destructive_and_returns_the_payload(): void
|
||||
{
|
||||
$code = $this->codes->createCode(42, 'challenge-abc');
|
||||
|
||||
$first = $this->codes->peekCode($code);
|
||||
$second = $this->codes->peekCode($code);
|
||||
|
||||
$this->assertSame(['userId' => 42, 'challenge' => 'challenge-abc'], $first);
|
||||
$this->assertSame($first, $second, 'peekCode() must not consume the code');
|
||||
}
|
||||
|
||||
public function test_consume_returns_true_once_then_false(): void
|
||||
{
|
||||
$code = $this->codes->createCode(7, 'ch');
|
||||
|
||||
$this->assertTrue($this->codes->consumeCode($code), 'first consume burns the code');
|
||||
$this->assertFalse($this->codes->consumeCode($code), 'a single-use code cannot be consumed twice');
|
||||
}
|
||||
|
||||
public function test_consumed_code_no_longer_peeks(): void
|
||||
{
|
||||
$code = $this->codes->createCode(5, 'ch');
|
||||
$this->codes->consumeCode($code);
|
||||
|
||||
$this->assertNull($this->codes->peekCode($code), 'a burned code is gone');
|
||||
}
|
||||
|
||||
public function test_consume_unknown_code_returns_false(): void
|
||||
{
|
||||
$this->assertFalse($this->codes->consumeCode('never-minted'));
|
||||
}
|
||||
|
||||
public function test_peek_unknown_code_returns_null(): void
|
||||
{
|
||||
$this->assertNull($this->codes->peekCode('never-minted'));
|
||||
}
|
||||
}
|
||||
155
tests/Unit/app/Domain/Plugins/Services/PluginsServiceTest.php
Normal file
155
tests/Unit/app/Domain/Plugins/Services/PluginsServiceTest.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Plugins\Services;
|
||||
|
||||
use GuzzleHttp\Psr7\Response as PsrResponse;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Http\Client\Response as HttpResponse;
|
||||
use Leantime\Domain\Plugins\Models\MarketplacePlugin;
|
||||
use Leantime\Domain\Plugins\Services\Plugins as PluginService;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the Plugins service helpers extracted during the
|
||||
* thin-controller refactor (buildMarketplacePluginFromRequest, isBundle,
|
||||
* parseMarketplaceError, performPluginAction).
|
||||
*/
|
||||
class PluginsServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
public function test_build_marketplace_plugin_from_request_decodes_json_fields(): void
|
||||
{
|
||||
/** @var PluginService $service */
|
||||
$service = $this->make(PluginService::class);
|
||||
|
||||
$plugin = $service->buildMarketplacePluginFromRequest([
|
||||
'identifier' => 'acme-plugin',
|
||||
'name' => 'Acme Plugin',
|
||||
'categories' => json_encode([['slug' => 'reporting', 'name' => 'Reporting']]),
|
||||
]);
|
||||
|
||||
$this->assertInstanceOf(MarketplacePlugin::class, $plugin);
|
||||
$this->assertSame('acme-plugin', $plugin->identifier);
|
||||
$this->assertSame('Acme Plugin', $plugin->name);
|
||||
$this->assertSame([['slug' => 'reporting', 'name' => 'Reporting']], $plugin->categories);
|
||||
}
|
||||
|
||||
public function test_build_marketplace_plugin_from_request_keeps_non_json_strings(): void
|
||||
{
|
||||
/** @var PluginService $service */
|
||||
$service = $this->make(PluginService::class);
|
||||
|
||||
$plugin = $service->buildMarketplacePluginFromRequest([
|
||||
'name' => 'Just a string',
|
||||
'excerpt' => 'Not json',
|
||||
]);
|
||||
|
||||
$this->assertSame('Just a string', $plugin->name);
|
||||
$this->assertSame('Not json', $plugin->excerpt);
|
||||
}
|
||||
|
||||
public function test_build_marketplace_plugin_from_request_ignores_disallowed_control_fields(): void
|
||||
{
|
||||
/** @var PluginService $service */
|
||||
$service = $this->make(PluginService::class);
|
||||
|
||||
$plugin = $service->buildMarketplacePluginFromRequest([
|
||||
'identifier' => 'acme-plugin',
|
||||
'type' => 'system',
|
||||
'marketplaceUrl' => 'https://attacker.example.com',
|
||||
]);
|
||||
|
||||
// Allowlisted field is set; sensitive control fields are ignored and keep their defaults.
|
||||
$this->assertSame('acme-plugin', $plugin->identifier);
|
||||
$this->assertSame('marketplace', $plugin->type);
|
||||
$this->assertSame('', $plugin->marketplaceUrl);
|
||||
}
|
||||
|
||||
public function test_is_bundle_true_when_bundles_category_present(): void
|
||||
{
|
||||
/** @var PluginService $service */
|
||||
$service = $this->make(PluginService::class);
|
||||
|
||||
$plugin = new MarketplacePlugin;
|
||||
$plugin->categories = [
|
||||
['slug' => 'reporting'],
|
||||
['slug' => 'bundles'],
|
||||
];
|
||||
|
||||
$this->assertTrue($service->isBundle($plugin));
|
||||
}
|
||||
|
||||
public function test_is_bundle_false_when_no_bundles_category(): void
|
||||
{
|
||||
/** @var PluginService $service */
|
||||
$service = $this->make(PluginService::class);
|
||||
|
||||
$plugin = new MarketplacePlugin;
|
||||
$plugin->categories = [
|
||||
['slug' => 'reporting'],
|
||||
];
|
||||
|
||||
$this->assertFalse($service->isBundle($plugin));
|
||||
}
|
||||
|
||||
public function test_parse_marketplace_error_extracts_clean_message(): void
|
||||
{
|
||||
/** @var PluginService $service */
|
||||
$service = $this->make(PluginService::class);
|
||||
|
||||
$exception = new RequestException(
|
||||
new HttpResponse(new PsrResponse(500, [], '{"error":"License invalid"}'))
|
||||
);
|
||||
|
||||
$this->assertSame('License invalid', $service->parseMarketplaceError($exception));
|
||||
}
|
||||
|
||||
public function test_parse_marketplace_error_falls_back_to_generic_message(): void
|
||||
{
|
||||
/** @var PluginService $service */
|
||||
$service = $this->make(PluginService::class);
|
||||
|
||||
$exception = new RequestException(
|
||||
new HttpResponse(new PsrResponse(200, [], 'not-json'))
|
||||
);
|
||||
|
||||
$this->assertSame('There was an error installing the plugin', $service->parseMarketplaceError($exception));
|
||||
}
|
||||
|
||||
public function test_perform_plugin_action_returns_success_descriptor(): void
|
||||
{
|
||||
/** @var PluginService $service */
|
||||
$service = $this->make(PluginService::class, [
|
||||
'enablePlugin' => fn () => true,
|
||||
]);
|
||||
|
||||
$this->assertSame(
|
||||
['notification.plugin_enable_success', 'success'],
|
||||
$service->performPluginAction('enable', 5)
|
||||
);
|
||||
}
|
||||
|
||||
public function test_perform_plugin_action_returns_error_descriptor_on_failure(): void
|
||||
{
|
||||
/** @var PluginService $service */
|
||||
$service = $this->make(PluginService::class, [
|
||||
'disablePlugin' => fn () => false,
|
||||
]);
|
||||
|
||||
$this->assertSame(
|
||||
['notification.plugin_disable_error', 'error'],
|
||||
$service->performPluginAction('disable', 5)
|
||||
);
|
||||
}
|
||||
|
||||
public function test_perform_plugin_action_rejects_unknown_action(): void
|
||||
{
|
||||
/** @var PluginService $service */
|
||||
$service = $this->make(PluginService::class);
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
$service->performPluginAction('explode', 5);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
995
tests/Unit/app/Domain/Projects/Services/ProjectsServiceTest.php
Normal file
995
tests/Unit/app/Domain/Projects/Services/ProjectsServiceTest.php
Normal 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]]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Reactions\Services;
|
||||
|
||||
use Leantime\Domain\Reactions\Repositories\Reactions as ReactionsRepository;
|
||||
use Leantime\Domain\Reactions\Services\Reactions;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the session-based JSON-RPC wrappers added to the Reactions
|
||||
* service (react/unreact): they must derive the user from the session so a
|
||||
* caller cannot react as another user.
|
||||
*/
|
||||
class ReactionsServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
public function test_react_uses_the_session_user(): void
|
||||
{
|
||||
session(['userdata' => ['id' => 42]]);
|
||||
|
||||
$capturedUserId = null;
|
||||
$repo = $this->make(ReactionsRepository::class, [
|
||||
'getUserReactions' => fn (...$args) => [],
|
||||
'addReaction' => function ($userId, ...$rest) use (&$capturedUserId) {
|
||||
$capturedUserId = $userId;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = (new Reactions($repo))->react('tickets', 5, 'thumbsup');
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertSame(42, $capturedUserId, 'react() must persist the session user, not a passed id');
|
||||
}
|
||||
|
||||
public function test_unreact_uses_the_session_user(): void
|
||||
{
|
||||
session(['userdata' => ['id' => 7]]);
|
||||
|
||||
$capturedUserId = null;
|
||||
$repo = $this->make(ReactionsRepository::class, [
|
||||
'removeUserReaction' => function ($userId, ...$rest) use (&$capturedUserId) {
|
||||
$capturedUserId = $userId;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = (new Reactions($repo))->unreact('tickets', 5, 'thumbsup');
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertSame(7, $capturedUserId, 'unreact() must remove for the session user, not a passed id');
|
||||
}
|
||||
}
|
||||
175
tests/Unit/app/Domain/Reports/Models/ReportPeriodTest.php
Normal file
175
tests/Unit/app/Domain/Reports/Models/ReportPeriodTest.php
Normal file
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\App\Domain\Reports\Models;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\Support\CarbonMacros;
|
||||
use Leantime\Core\Support\DateTimeHelper;
|
||||
use Leantime\Domain\Reports\Models\ReportPeriod;
|
||||
use Unit\TestCase;
|
||||
|
||||
class ReportPeriodTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$environmentMock = $this->make(Environment::class, [
|
||||
'defaultTimezone' => 'America/Los_Angeles',
|
||||
'language' => 'en-US',
|
||||
]);
|
||||
app()->instance(Environment::class, $environmentMock);
|
||||
|
||||
$languageMock = $this->createMock(Language::class);
|
||||
$languageMock->method('__')->willReturnCallback(function ($index) {
|
||||
$map = [
|
||||
'language.dateformat' => 'm/d/Y',
|
||||
'language.timeformat' => 'h:i A',
|
||||
];
|
||||
|
||||
return $map[$index] ?? null;
|
||||
});
|
||||
app()->instance(Language::class, $languageMock);
|
||||
|
||||
// User calendar in LA (UTC-7 in summer) so quarter boundaries shift against UTC.
|
||||
CarbonImmutable::mixin(new CarbonMacros(
|
||||
'America/Los_Angeles',
|
||||
'en_US',
|
||||
'm/d/Y',
|
||||
'h:i A'
|
||||
));
|
||||
|
||||
app()->instance(DateTimeHelper::class, new DateTimeHelper);
|
||||
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-07-08 12:00:00', 'UTC'));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
CarbonImmutable::setTestNow();
|
||||
app()->forgetInstance(DateTimeHelper::class);
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_this_quarter_resolves_user_calendar_quarter_in_utc(): void
|
||||
{
|
||||
$period = ReportPeriod::thisQuarter();
|
||||
|
||||
// Q3 2026 in LA: Jul 1 00:00 PDT = Jul 1 07:00 UTC, Sep 30 23:59:59 PDT = Oct 1 06:59:59 UTC.
|
||||
$this->assertSame('2026-07-01 07:00:00', $period->fromDbString());
|
||||
$this->assertSame('2026-10-01 06:59:59', $period->toDbString());
|
||||
$this->assertSame(ReportPeriod::PRESET_THIS_QUARTER, $period->preset);
|
||||
}
|
||||
|
||||
public function test_last_and_next_quarter_presets(): void
|
||||
{
|
||||
$lastQuarter = ReportPeriod::lastQuarter();
|
||||
// Q2 2026 in LA starts Apr 1 00:00 PDT = Apr 1 07:00 UTC.
|
||||
$this->assertSame('2026-04-01 07:00:00', $lastQuarter->fromDbString());
|
||||
$this->assertSame('2026-07-01 06:59:59', $lastQuarter->toDbString());
|
||||
|
||||
$nextQuarter = ReportPeriod::nextQuarter();
|
||||
// Q4 2026 in LA starts Oct 1 00:00 PDT = Oct 1 07:00 UTC.
|
||||
$this->assertSame('2026-10-01 07:00:00', $nextQuarter->fromDbString());
|
||||
// Dec 31 23:59:59 PST (UTC-8) = Jan 1 07:59:59 UTC.
|
||||
$this->assertSame('2027-01-01 07:59:59', $nextQuarter->toDbString());
|
||||
}
|
||||
|
||||
public function test_quarter_follows_user_timezone_across_utc_quarter_boundary(): void
|
||||
{
|
||||
// Jul 1 03:00 UTC is still Jun 30 in LA — the user's "this quarter" is Q2, not Q3.
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-07-01 03:00:00', 'UTC'));
|
||||
|
||||
$period = ReportPeriod::thisQuarter();
|
||||
|
||||
$this->assertSame('2026-04-01 07:00:00', $period->fromDbString());
|
||||
$this->assertSame('2026-07-01 06:59:59', $period->toDbString());
|
||||
}
|
||||
|
||||
public function test_from_request_parses_presets_and_custom_ranges(): void
|
||||
{
|
||||
$preset = ReportPeriod::fromRequest(['preset' => 'lastQuarter']);
|
||||
$this->assertSame(ReportPeriod::PRESET_LAST_QUARTER, $preset->preset);
|
||||
|
||||
$custom = ReportPeriod::fromRequest(['preset' => 'custom', 'from' => '04/01/2026', 'to' => '06/30/2026']);
|
||||
$this->assertSame(ReportPeriod::PRESET_CUSTOM, $custom->preset);
|
||||
$this->assertSame('2026-04-01 07:00:00', $custom->fromDbString());
|
||||
// End of day Jun 30 PDT.
|
||||
$this->assertSame('2026-07-01 06:59:59', $custom->toDbString());
|
||||
}
|
||||
|
||||
public function test_from_request_falls_back_to_this_quarter(): void
|
||||
{
|
||||
$this->assertSame(ReportPeriod::PRESET_THIS_QUARTER, ReportPeriod::fromRequest([])->preset);
|
||||
$this->assertSame(
|
||||
ReportPeriod::PRESET_THIS_QUARTER,
|
||||
ReportPeriod::fromRequest(['preset' => 'custom', 'from' => 'not-a-date', 'to' => '06/30/2026'])->preset
|
||||
);
|
||||
// Inverted range is rejected.
|
||||
$this->assertSame(
|
||||
ReportPeriod::PRESET_THIS_QUARTER,
|
||||
ReportPeriod::fromRequest(['preset' => 'custom', 'from' => '06/30/2026', 'to' => '04/01/2026'])->preset
|
||||
);
|
||||
}
|
||||
|
||||
public function test_prior_period_of_quarter_preset_is_previous_quarter(): void
|
||||
{
|
||||
$prior = ReportPeriod::thisQuarter()->priorPeriod();
|
||||
|
||||
$this->assertSame('2026-04-01 07:00:00', $prior->fromDbString());
|
||||
$this->assertSame('2026-07-01 06:59:59', $prior->toDbString());
|
||||
}
|
||||
|
||||
public function test_prior_period_of_custom_range_is_same_length_before(): void
|
||||
{
|
||||
$custom = ReportPeriod::fromRequest(['preset' => 'custom', 'from' => '06/21/2026', 'to' => '06/30/2026']);
|
||||
$prior = $custom->priorPeriod();
|
||||
|
||||
// Ten-day window directly preceding Jun 21–Jun 30.
|
||||
$this->assertSame('2026-06-11 07:00:00', $prior->fromDbString());
|
||||
$this->assertSame('2026-06-21 06:59:59', $prior->toDbString());
|
||||
}
|
||||
|
||||
public function test_upcoming_horizon_extends_two_quarters_past_period_end(): void
|
||||
{
|
||||
$horizon = ReportPeriod::thisQuarter()->upcomingHorizon();
|
||||
|
||||
// Two quarters past Q3 2026 = end of Q1 2027 (Mar 31 23:59:59 PDT = Apr 1 06:59:59 UTC).
|
||||
$this->assertSame('2027-04-01 06:59:59', $horizon->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
public function test_contains_is_inclusive_of_bounds(): void
|
||||
{
|
||||
$period = ReportPeriod::thisQuarter();
|
||||
|
||||
$this->assertTrue($period->contains($period->from));
|
||||
$this->assertTrue($period->contains($period->to));
|
||||
$this->assertFalse($period->contains($period->from->subSecond()));
|
||||
$this->assertFalse($period->contains($period->to->addSecond()));
|
||||
}
|
||||
|
||||
public function test_query_string_round_trips(): void
|
||||
{
|
||||
$this->assertSame('preset=thisQuarter', ReportPeriod::thisQuarter()->toQueryString());
|
||||
|
||||
$custom = ReportPeriod::fromRequest(['preset' => 'custom', 'from' => '04/01/2026', 'to' => '06/30/2026']);
|
||||
parse_str($custom->toQueryString(), $params);
|
||||
$roundTripped = ReportPeriod::fromRequest($params);
|
||||
|
||||
$this->assertSame($custom->fromDbString(), $roundTripped->fromDbString());
|
||||
$this->assertSame($custom->toDbString(), $roundTripped->toDbString());
|
||||
}
|
||||
|
||||
public function test_label_carries_quarter_shorthand_for_full_quarters(): void
|
||||
{
|
||||
$this->assertStringStartsWith('Q3 2026 · ', ReportPeriod::thisQuarter()->label());
|
||||
|
||||
$custom = ReportPeriod::fromRequest(['preset' => 'custom', 'from' => '06/21/2026', 'to' => '06/30/2026']);
|
||||
$this->assertStringNotContainsString('Q2', $custom->label());
|
||||
}
|
||||
}
|
||||
410
tests/Unit/app/Domain/Reports/Services/CapacityAnalyzerTest.php
Normal file
410
tests/Unit/app/Domain/Reports/Services/CapacityAnalyzerTest.php
Normal file
@@ -0,0 +1,410 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\App\Domain\Reports\Services;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\Resources\Models\PersonAllocation;
|
||||
use Leantime\Core\Resources\Models\ResourceSummary;
|
||||
use Leantime\Core\Support\CarbonMacros;
|
||||
use Leantime\Core\Support\DateTimeHelper;
|
||||
use Leantime\Domain\Reports\Models\ReportPeriod;
|
||||
use Leantime\Domain\Reports\Services\CapacityAnalyzer;
|
||||
use Leantime\Domain\Tickets\Repositories\Tickets as TicketsRepo;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Unit\TestCase;
|
||||
|
||||
class CapacityAnalyzerTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
private TicketsRepo&MockObject $ticketsRepo;
|
||||
|
||||
private CapacityAnalyzer $analyzer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$environmentMock = $this->make(Environment::class, [
|
||||
'defaultTimezone' => 'UTC',
|
||||
'language' => 'en-US',
|
||||
]);
|
||||
app()->instance(Environment::class, $environmentMock);
|
||||
|
||||
$languageMock = $this->createMock(Language::class);
|
||||
$languageMock->method('__')->willReturnCallback(fn ($index) => [
|
||||
'language.dateformat' => 'm/d/Y',
|
||||
'language.timeformat' => 'h:i A',
|
||||
][$index] ?? null);
|
||||
app()->instance(Language::class, $languageMock);
|
||||
|
||||
CarbonImmutable::mixin(new CarbonMacros('UTC', 'en_US', 'm/d/Y', 'h:i A'));
|
||||
app()->instance(DateTimeHelper::class, new DateTimeHelper);
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-07-08 12:00:00', 'UTC'));
|
||||
|
||||
$this->ticketsRepo = $this->createMock(TicketsRepo::class);
|
||||
$this->analyzer = new CapacityAnalyzer($this->ticketsRepo);
|
||||
|
||||
// Default status vocabulary: 3=NEW, 4=INPROGRESS, 0=DONE, -1=DONE(archived),
|
||||
// 7 = a CUSTOM done status with a positive id.
|
||||
$this->ticketsRepo->method('getStateLabels')->willReturn([
|
||||
3 => ['statusType' => 'NEW', 'name' => 'New'],
|
||||
4 => ['statusType' => 'INPROGRESS', 'name' => 'In Progress'],
|
||||
0 => ['statusType' => 'DONE', 'name' => 'Done'],
|
||||
-1 => ['statusType' => 'DONE', 'name' => 'Archived'],
|
||||
7 => ['statusType' => 'DONE', 'name' => 'Shipped'],
|
||||
]);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
CarbonImmutable::setTestNow();
|
||||
app()->forgetInstance(DateTimeHelper::class);
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* One-week period so availableHours equals the weekly allocation exactly.
|
||||
*/
|
||||
private function oneWeekPeriod(): ReportPeriod
|
||||
{
|
||||
return ReportPeriod::fromRequest(['preset' => 'custom', 'from' => '06/01/2026', 'to' => '06/07/2026']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $tickets
|
||||
*/
|
||||
private function withTickets(array $tickets, int $projectId = 10): void
|
||||
{
|
||||
// The analyzer batches its ticket reads into one getAllByProjectIds()
|
||||
// call keyed by projectId; the single-project cases here all use id 10.
|
||||
$this->ticketsRepo->method('getAllByProjectIds')->willReturn([$projectId => $tickets]);
|
||||
}
|
||||
|
||||
private function summaryWithWeeklyAllocation(int $projectId, float $weeklyHours, int $people = 1): ResourceSummary
|
||||
{
|
||||
$persons = [];
|
||||
for ($i = 0; $i < $people; $i++) {
|
||||
$persons[] = new PersonAllocation(
|
||||
itemId: $i + 1,
|
||||
userId: $i + 1,
|
||||
displayName: 'Person '.($i + 1),
|
||||
capacity: 40.0,
|
||||
allocations: [$projectId => $weeklyHours / $people],
|
||||
);
|
||||
}
|
||||
|
||||
return new ResourceSummary([$projectId], $persons, [], [], 40.0 * $people, $weeklyHours, 0.0, 0.0);
|
||||
}
|
||||
|
||||
public function test_custom_done_statuses_are_excluded_from_demand(): void
|
||||
{
|
||||
$this->withTickets([
|
||||
// Custom DONE status (positive id) — must NOT count as open demand.
|
||||
['id' => 1, 'status' => 7, 'planHours' => 100.0, 'storypoints' => 0],
|
||||
// Default done + archived — excluded.
|
||||
['id' => 2, 'status' => 0, 'planHours' => 50.0, 'storypoints' => 0],
|
||||
['id' => 3, 'status' => -1, 'planHours' => 25.0, 'storypoints' => 0],
|
||||
// Open work — the only demand.
|
||||
['id' => 4, 'status' => 3, 'planHours' => 10.0, 'storypoints' => 0],
|
||||
]);
|
||||
|
||||
$rows = $this->analyzer->analyzeProjects([10], $this->oneWeekPeriod(), $this->summaryWithWeeklyAllocation(10, 20.0), [10 => 'P']);
|
||||
|
||||
$this->assertSame(1, $rows[10]['openTicketCount']);
|
||||
$this->assertEqualsWithDelta(10.0, $rows[10]['budgetedHours'], 0.001);
|
||||
}
|
||||
|
||||
public function test_trust_signal_bands_drive_reference_demand(): void
|
||||
{
|
||||
// Low coverage (1 of 4 open tickets budgeted = 0.25 < 0.30) with effort present
|
||||
// -> trust 'effort', referenceDemand = storypoints × hoursPerPoint.
|
||||
$this->withTickets([
|
||||
['id' => 1, 'status' => 3, 'planHours' => 8.0, 'storypoints' => 5],
|
||||
['id' => 2, 'status' => 3, 'planHours' => 0, 'storypoints' => 5],
|
||||
['id' => 3, 'status' => 3, 'planHours' => 0, 'storypoints' => 5],
|
||||
['id' => 4, 'status' => 4, 'planHours' => 0, 'storypoints' => 5],
|
||||
]);
|
||||
|
||||
$rows = $this->analyzer->analyzeProjects([10], $this->oneWeekPeriod(), $this->summaryWithWeeklyAllocation(10, 20.0), [10 => 'P']);
|
||||
|
||||
$this->assertSame('effort', $rows[10]['trustSignal']);
|
||||
$this->assertEqualsWithDelta(20 * CapacityAnalyzer::DEFAULT_HOURS_PER_POINT, $rows[10]['referenceDemand'], 0.001);
|
||||
}
|
||||
|
||||
public function test_high_coverage_agreeing_estimates_trust_budgeted(): void
|
||||
{
|
||||
// Full coverage, divergence below threshold (40 budgeted vs 40 effort hours).
|
||||
$this->withTickets([
|
||||
['id' => 1, 'status' => 3, 'planHours' => 20.0, 'storypoints' => 5],
|
||||
['id' => 2, 'status' => 3, 'planHours' => 20.0, 'storypoints' => 5],
|
||||
]);
|
||||
|
||||
$rows = $this->analyzer->analyzeProjects([10], $this->oneWeekPeriod(), $this->summaryWithWeeklyAllocation(10, 20.0), [10 => 'P']);
|
||||
|
||||
$this->assertSame('budgeted', $rows[10]['trustSignal']);
|
||||
$this->assertEqualsWithDelta(40.0, $rows[10]['referenceDemand'], 0.001);
|
||||
}
|
||||
|
||||
public function test_diverging_estimates_flag_mixed_and_take_conservative_max(): void
|
||||
{
|
||||
// Full coverage but effort (10sp × 4h = 40h) vs budgeted (100h) diverge > 0.4 -> mixed, max() wins.
|
||||
$this->withTickets([
|
||||
['id' => 1, 'status' => 3, 'planHours' => 50.0, 'storypoints' => 5],
|
||||
['id' => 2, 'status' => 3, 'planHours' => 50.0, 'storypoints' => 5],
|
||||
]);
|
||||
|
||||
$rows = $this->analyzer->analyzeProjects([10], $this->oneWeekPeriod(), $this->summaryWithWeeklyAllocation(10, 20.0), [10 => 'P']);
|
||||
|
||||
$this->assertSame('mixed', $rows[10]['trustSignal']);
|
||||
$this->assertEqualsWithDelta(100.0, $rows[10]['referenceDemand'], 0.001);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider verdictBoundaryProvider
|
||||
*/
|
||||
public function test_verdict_boundaries(float $demandHours, float $weeklyAvailable, string $expectedVerdict): void
|
||||
{
|
||||
// Single fully-budgeted open ticket -> trust 'budgeted' -> referenceDemand = planHours.
|
||||
$this->withTickets([
|
||||
['id' => 1, 'status' => 3, 'planHours' => $demandHours, 'storypoints' => 0],
|
||||
]);
|
||||
|
||||
$rows = $this->analyzer->analyzeProjects([10], $this->oneWeekPeriod(), $this->summaryWithWeeklyAllocation(10, $weeklyAvailable), [10 => 'P']);
|
||||
|
||||
$this->assertSame($expectedVerdict, $rows[10]['verdict']);
|
||||
}
|
||||
|
||||
public static function verdictBoundaryProvider(): array
|
||||
{
|
||||
return [
|
||||
'no capacity' => [100.0, 0.0, 'no_capacity'],
|
||||
'critical: gap ratio > 0.25' => [130.0, 100.0, 'critical'],
|
||||
'tight: 0 < ratio <= 0.25' => [110.0, 100.0, 'tight'],
|
||||
'balanced: -0.5 <= ratio <= 0' => [90.0, 100.0, 'balanced'],
|
||||
'buffer: ratio < -0.5' => [40.0, 100.0, 'buffer'],
|
||||
];
|
||||
}
|
||||
|
||||
public function test_projects_without_work_or_people_are_skipped_as_noise(): void
|
||||
{
|
||||
$this->withTickets([]);
|
||||
|
||||
$rows = $this->analyzer->analyzeProjects([10], $this->oneWeekPeriod(), ResourceSummary::empty([10]), [10 => 'P']);
|
||||
|
||||
$this->assertSame([], $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* The N+1 guard: tickets for every analyzed project are pulled in a SINGLE
|
||||
* getAllByProjectIds() call, not one getAllByProjectId() per project. This
|
||||
* pins the batching so a future refactor can't quietly reintroduce the
|
||||
* per-project round-trip.
|
||||
*/
|
||||
public function test_tickets_are_fetched_in_one_batched_call_for_all_projects(): void
|
||||
{
|
||||
$this->ticketsRepo->expects($this->once())
|
||||
->method('getAllByProjectIds')
|
||||
->with($this->equalTo([10, 20, 30]))
|
||||
->willReturn([
|
||||
10 => [['id' => 1, 'status' => 3, 'planHours' => 10.0, 'storypoints' => 0]],
|
||||
20 => [['id' => 2, 'status' => 3, 'planHours' => 20.0, 'storypoints' => 0]],
|
||||
30 => [['id' => 3, 'status' => 3, 'planHours' => 30.0, 'storypoints' => 0]],
|
||||
]);
|
||||
|
||||
$summary = new ResourceSummary(
|
||||
[10, 20, 30],
|
||||
[new PersonAllocation(
|
||||
itemId: 1, userId: 1, displayName: 'P', capacity: 40.0,
|
||||
allocations: [10 => 10.0, 20 => 10.0, 30 => 10.0],
|
||||
)],
|
||||
[], [], 40.0, 30.0, 0.0, 0.0,
|
||||
);
|
||||
|
||||
$rows = $this->analyzer->analyzeProjects(
|
||||
[10, 20, 30],
|
||||
$this->oneWeekPeriod(),
|
||||
$summary,
|
||||
[10 => 'A', 20 => 'B', 30 => 'C'],
|
||||
);
|
||||
|
||||
$this->assertSame([10, 20, 30], array_keys($rows));
|
||||
$this->assertEqualsWithDelta(10.0, $rows[10]['budgetedHours'], 0.001);
|
||||
$this->assertEqualsWithDelta(30.0, $rows[30]['budgetedHours'], 0.001);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the reportable projects (those present in $projectNames) reach the
|
||||
* batched fetch — the skip that used to sit inside the per-project loop now
|
||||
* shapes the single WHERE IN, so we don't over-fetch container projects.
|
||||
*/
|
||||
public function test_only_reportable_projects_are_batched(): void
|
||||
{
|
||||
$this->ticketsRepo->expects($this->once())
|
||||
->method('getAllByProjectIds')
|
||||
->with($this->equalTo([10])) // 20 is not in $projectNames → excluded
|
||||
->willReturn([10 => [['id' => 1, 'status' => 3, 'planHours' => 10.0, 'storypoints' => 0]]]);
|
||||
|
||||
$this->analyzer->analyzeProjects(
|
||||
[10, 20],
|
||||
$this->oneWeekPeriod(),
|
||||
$this->summaryWithWeeklyAllocation(10, 20.0),
|
||||
[10 => 'A'], // 20 omitted on purpose
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Program rollup: supply is capacity, not booked hours ────────
|
||||
//
|
||||
// aggregateByProgram measures how much a program COULD do (capacity),
|
||||
// not how much is already booked against it. This keeps the rollup
|
||||
// consistent with the report's headline utilization tile, which reads
|
||||
// allocated/capacity — before this, a program with real headroom and
|
||||
// little booked reported no_capacity and could escalate as a false gap
|
||||
// on the strategy report. verdict()/trustSignal() themselves are
|
||||
// covered above; these pin the supply figure feeding them.
|
||||
|
||||
public function test_program_supply_uses_capacity_not_allocated_hours(): void
|
||||
{
|
||||
// 40h capacity, only 5h booked against the program → supply is 40.
|
||||
$row = $this->rollup(
|
||||
[$this->personCap(1, capacity: 40.0, allocations: [7 => 5.0])],
|
||||
childIds: [7],
|
||||
);
|
||||
|
||||
$this->assertEqualsWithDelta(40.0, $row['availableHours'], 0.001);
|
||||
$this->assertSame(1, $row['peopleCount']);
|
||||
}
|
||||
|
||||
public function test_program_supply_counts_a_person_on_two_child_projects_once(): void
|
||||
{
|
||||
$row = $this->rollup(
|
||||
[$this->personCap(1, capacity: 40.0, allocations: [7 => 5.0, 8 => 5.0])],
|
||||
childIds: [7, 8],
|
||||
);
|
||||
|
||||
$this->assertEqualsWithDelta(40.0, $row['availableHours'], 0.001);
|
||||
$this->assertSame(1, $row['peopleCount']);
|
||||
}
|
||||
|
||||
public function test_program_supply_deducts_commitments_outside_the_program(): void
|
||||
{
|
||||
// 40h capacity, 10h here, 15h on a project outside this program →
|
||||
// 25h is what this program could still claim.
|
||||
$row = $this->rollup(
|
||||
[$this->personCap(1, capacity: 40.0, allocations: [7 => 10.0, 99 => 15.0])],
|
||||
childIds: [7],
|
||||
);
|
||||
|
||||
$this->assertEqualsWithDelta(25.0, $row['availableHours'], 0.001);
|
||||
}
|
||||
|
||||
public function test_outside_commitments_never_push_a_person_below_zero(): void
|
||||
{
|
||||
// Person 1 is over-committed elsewhere beyond their capacity: they
|
||||
// bring nothing here, but must not subtract from person 2.
|
||||
$row = $this->rollup(
|
||||
[
|
||||
$this->personCap(1, capacity: 40.0, allocations: [7 => 1.0, 99 => 100.0]),
|
||||
$this->personCap(2, capacity: 20.0, allocations: [7 => 5.0]),
|
||||
],
|
||||
childIds: [7],
|
||||
);
|
||||
|
||||
$this->assertEqualsWithDelta(20.0, $row['availableHours'], 0.001, 'clamped at 0, not -60');
|
||||
}
|
||||
|
||||
public function test_people_not_allocated_to_the_program_contribute_nothing(): void
|
||||
{
|
||||
$row = $this->rollup(
|
||||
[$this->personCap(1, capacity: 40.0, allocations: [99 => 10.0])],
|
||||
childIds: [7],
|
||||
);
|
||||
|
||||
$this->assertEqualsWithDelta(0.0, $row['availableHours'], 0.001);
|
||||
$this->assertSame(0, $row['peopleCount']);
|
||||
}
|
||||
|
||||
/**
|
||||
* The regression this change exists for: a program with real headroom
|
||||
* and little booked used to read no_capacity because supply was measured
|
||||
* as hours-already-allocated. With capacity as supply it reads as buffer
|
||||
* — there IS room. (supply 40 vs demand 10 → ratio -0.75 → buffer.)
|
||||
*/
|
||||
public function test_program_with_headroom_and_light_booking_reads_as_buffer(): void
|
||||
{
|
||||
$row = $this->rollup(
|
||||
[$this->personCap(1, capacity: 40.0, allocations: [7 => 0.5])],
|
||||
childIds: [7],
|
||||
budgetedHours: 10.0,
|
||||
);
|
||||
|
||||
$this->assertNotSame('no_capacity', $row['verdict']);
|
||||
$this->assertSame('buffer', $row['verdict']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Work planned with nobody assigned stays no_capacity, distinct from
|
||||
* critical: it is an authoring gap ("assign people"), not a capacity
|
||||
* crisis, and painting it red trains readers to ignore red.
|
||||
*/
|
||||
public function test_program_with_no_people_is_no_capacity_not_critical(): void
|
||||
{
|
||||
$row = $this->rollup([], childIds: [7], budgetedHours: 400.0);
|
||||
|
||||
$this->assertSame('no_capacity', $row['verdict']);
|
||||
}
|
||||
|
||||
private function personCap(int $itemId, float $capacity, array $allocations): PersonAllocation
|
||||
{
|
||||
return new PersonAllocation(
|
||||
itemId: $itemId,
|
||||
userId: $itemId,
|
||||
displayName: 'Person '.$itemId,
|
||||
capacity: $capacity,
|
||||
allocations: $allocations,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs aggregateByProgram for one program over the given children,
|
||||
* feeding pre-built project rows so the ticket/demand side is fixed and
|
||||
* the assertions are about the capacity side. Demand sits on the first
|
||||
* child so totals are predictable regardless of child count.
|
||||
*
|
||||
* @param array<int, PersonAllocation> $people
|
||||
* @param int[] $childIds
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function rollup(array $people, array $childIds, float $budgetedHours = 50.0): array
|
||||
{
|
||||
$projectRows = [];
|
||||
foreach ($childIds as $cid) {
|
||||
$isFirst = $cid === $childIds[0];
|
||||
$projectRows[$cid] = [
|
||||
'projectId' => $cid,
|
||||
'name' => 'Child '.$cid,
|
||||
'openTicketCount' => $isFirst ? 10 : 0,
|
||||
'ticketsWithBudget' => $isFirst ? 10 : 0,
|
||||
'ticketsWithEffort' => 0,
|
||||
'budgetedHours' => $isFirst ? $budgetedHours : 0.0,
|
||||
'effortPoints' => 0.0,
|
||||
'verdict' => 'balanced', // aggregateByProgram sorts children by verdict rank
|
||||
];
|
||||
}
|
||||
|
||||
$resources = new ResourceSummary([7, 8], $people, [], [], 0.0, 0.0, 0.0, 0.0);
|
||||
|
||||
$rows = $this->analyzer->aggregateByProgram(
|
||||
$projectRows,
|
||||
[2 => $childIds],
|
||||
[2 => ['id' => 2, 'name' => 'Program 2']],
|
||||
$resources,
|
||||
$this->oneWeekPeriod(),
|
||||
);
|
||||
|
||||
return $rows[2];
|
||||
}
|
||||
}
|
||||
338
tests/Unit/app/Domain/Reports/Services/ReportEngineTest.php
Normal file
338
tests/Unit/app/Domain/Reports/Services/ReportEngineTest.php
Normal file
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\App\Domain\Reports\Services;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Leantime\Core\Auth\Permissions\PermissionService;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\Support\CarbonMacros;
|
||||
use Leantime\Core\Support\DateTimeHelper;
|
||||
use Leantime\Domain\Goalcanvas\Services\Goalcanvas as GoalcanvasService;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Leantime\Domain\Reports\Models\ReportPeriod;
|
||||
use Leantime\Domain\Reports\Repositories\ReportEngine as ReportEngineRepository;
|
||||
use Leantime\Domain\Reports\Services\ReportEngine;
|
||||
use Leantime\Domain\Tickets\Repositories\Tickets as TicketRepository;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Unit\TestCase;
|
||||
|
||||
class ReportEngineTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
private ReportEngine $service;
|
||||
|
||||
private ReportEngineRepository&MockObject $repository;
|
||||
|
||||
private TicketRepository&MockObject $ticketRepository;
|
||||
|
||||
private ProjectService&MockObject $projectService;
|
||||
|
||||
private GoalcanvasService&MockObject $goalService;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$environmentMock = $this->make(Environment::class, [
|
||||
'defaultTimezone' => 'UTC',
|
||||
'language' => 'en-US',
|
||||
]);
|
||||
app()->instance(Environment::class, $environmentMock);
|
||||
|
||||
$languageMock = $this->createMock(Language::class);
|
||||
$languageMock->method('__')->willReturnCallback(function ($index) {
|
||||
$map = [
|
||||
'language.dateformat' => 'm/d/Y',
|
||||
'language.timeformat' => 'h:i A',
|
||||
];
|
||||
|
||||
return $map[$index] ?? null;
|
||||
});
|
||||
app()->instance(Language::class, $languageMock);
|
||||
|
||||
CarbonImmutable::mixin(new CarbonMacros('UTC', 'en_US', 'm/d/Y', 'h:i A'));
|
||||
app()->instance(DateTimeHelper::class, new DateTimeHelper);
|
||||
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-07-08 12:00:00', 'UTC'));
|
||||
|
||||
$this->repository = $this->createMock(ReportEngineRepository::class);
|
||||
$this->ticketRepository = $this->createMock(TicketRepository::class);
|
||||
$this->projectService = $this->createMock(ProjectService::class);
|
||||
$this->goalService = $this->createMock(GoalcanvasService::class);
|
||||
|
||||
$this->service = new ReportEngine(
|
||||
$this->repository,
|
||||
$this->ticketRepository,
|
||||
$this->projectService,
|
||||
$this->goalService,
|
||||
);
|
||||
|
||||
$permissionService = $this->createMock(PermissionService::class);
|
||||
$permissionService->method('currentUserCan')->willReturn(true);
|
||||
$this->service->setPermissionService($permissionService);
|
||||
|
||||
// Status vocabulary for project 10: 1 = NEW, 2 = INPROGRESS, 3 = DONE.
|
||||
$this->ticketRepository->method('getStateLabels')->willReturn([
|
||||
1 => ['statusType' => 'NEW', 'name' => 'New'],
|
||||
2 => ['statusType' => 'INPROGRESS', 'name' => 'In Progress'],
|
||||
3 => ['statusType' => 'DONE', 'name' => 'Done'],
|
||||
]);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
CarbonImmutable::setTestNow();
|
||||
app()->forgetInstance(DateTimeHelper::class);
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
private function milestone(int $id, array $overrides = []): object
|
||||
{
|
||||
return (object) array_merge([
|
||||
'id' => $id,
|
||||
'headline' => 'Milestone '.$id,
|
||||
'description' => '',
|
||||
'outcomeImpact' => null,
|
||||
'date' => '2026-01-01 00:00:00',
|
||||
'projectId' => 10,
|
||||
'status' => 1,
|
||||
'editFrom' => null,
|
||||
'editTo' => null,
|
||||
'modified' => null,
|
||||
'projectName' => 'Project 10',
|
||||
'type' => 'milestone',
|
||||
'tags' => 'var(--grey)',
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
private function lastQuarter(): ReportPeriod
|
||||
{
|
||||
// With test-now 2026-07-08 UTC this is Apr 1 – Jun 30 2026 (UTC).
|
||||
return ReportPeriod::lastQuarter();
|
||||
}
|
||||
|
||||
public function test_milestones_bucket_into_completed_overdue_in_progress_and_upcoming(): void
|
||||
{
|
||||
$this->repository->method('getMilestonesForProjects')->willReturn([
|
||||
// Done, history transition inside the period -> completed.
|
||||
$this->milestone(1, ['status' => 3]),
|
||||
// Done, history transition long before the period -> allDone only.
|
||||
$this->milestone(2, ['status' => 3]),
|
||||
// Open with a past due date -> overdue.
|
||||
$this->milestone(3, ['editFrom' => '2026-05-01 00:00:00', 'editTo' => '2026-06-01 00:00:00']),
|
||||
// Open, starting two months after the period -> upcoming (Q3 2026).
|
||||
$this->milestone(4, ['editFrom' => '2026-08-15 00:00:00', 'editTo' => '2026-09-15 00:00:00']),
|
||||
// Open and completely unscheduled -> in progress.
|
||||
$this->milestone(5),
|
||||
// Open, starting after the two-quarter horizon -> dropped from upcoming.
|
||||
$this->milestone(6, ['editFrom' => '2027-06-01 00:00:00', 'editTo' => '2027-07-01 00:00:00']),
|
||||
]);
|
||||
$this->repository->method('getStatusHistoryForTickets')->willReturn([
|
||||
(object) ['ticketId' => 1, 'changeValue' => '2', 'dateModified' => '2026-05-01 09:00:00'],
|
||||
(object) ['ticketId' => 1, 'changeValue' => '3', 'dateModified' => '2026-05-10 09:00:00'],
|
||||
(object) ['ticketId' => 2, 'changeValue' => '3', 'dateModified' => '2026-01-15 09:00:00'],
|
||||
]);
|
||||
$this->repository->method('getTasksForMilestones')->willReturn([]);
|
||||
$this->repository->method('getDueDateChangesForTickets')->willReturn([]);
|
||||
|
||||
$report = $this->service->getMilestoneReportForProjects([10], $this->lastQuarter());
|
||||
|
||||
$this->assertSame([1], array_map(fn ($m) => $m->id, $report['completed']));
|
||||
$this->assertSame('2026-05-10 09:00:00', $report['completed'][0]->completedOn->format('Y-m-d H:i:s'));
|
||||
$this->assertSame([3], array_map(fn ($m) => $m->id, $report['overdue']));
|
||||
$this->assertSame([5], array_map(fn ($m) => $m->id, $report['inProgress']));
|
||||
$this->assertSame([4], array_map(fn ($m) => $m->id, $report['upcoming']));
|
||||
$this->assertArrayHasKey('Q3 2026', $report['upcomingByQuarter']);
|
||||
$this->assertCount(2, $report['allDone']);
|
||||
}
|
||||
|
||||
public function test_completion_date_falls_back_to_due_date_then_modified_without_history(): void
|
||||
{
|
||||
$this->repository->method('getMilestonesForProjects')->willReturn([
|
||||
// No history, has a due date inside the period.
|
||||
$this->milestone(1, ['status' => 3, 'editTo' => '2026-05-20 00:00:00']),
|
||||
// No history, no due date, modified inside the period.
|
||||
$this->milestone(2, ['status' => 3, 'modified' => '2026-06-15 08:00:00']),
|
||||
// History exists but never transitions into DONE -> falls back to modified.
|
||||
$this->milestone(3, ['status' => 3, 'modified' => '2026-06-20 08:00:00']),
|
||||
]);
|
||||
$this->repository->method('getStatusHistoryForTickets')->willReturn([
|
||||
(object) ['ticketId' => 3, 'changeValue' => '2', 'dateModified' => '2026-06-01 09:00:00'],
|
||||
]);
|
||||
$this->repository->method('getTasksForMilestones')->willReturn([]);
|
||||
$this->repository->method('getDueDateChangesForTickets')->willReturn([]);
|
||||
|
||||
$report = $this->service->getMilestoneReportForProjects([10], $this->lastQuarter());
|
||||
|
||||
$completedOn = [];
|
||||
foreach ($report['completed'] as $milestone) {
|
||||
$completedOn[$milestone->id] = $milestone->completedOn->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$this->assertSame('2026-05-20 00:00:00', $completedOn[1]);
|
||||
$this->assertSame('2026-06-15 08:00:00', $completedOn[2]);
|
||||
$this->assertSame('2026-06-20 08:00:00', $completedOn[3]);
|
||||
}
|
||||
|
||||
public function test_milestone_progress_uses_weighted_task_scores(): void
|
||||
{
|
||||
$this->repository->method('getMilestonesForProjects')->willReturn([
|
||||
$this->milestone(1, ['editFrom' => '2026-06-01 00:00:00', 'editTo' => '2026-09-01 00:00:00']),
|
||||
]);
|
||||
$this->repository->method('getStatusHistoryForTickets')->willReturn([]);
|
||||
$this->repository->method('getDueDateChangesForTickets')->willReturn([]);
|
||||
$this->repository->method('getTasksForMilestones')->willReturn([
|
||||
// Done: 5 points × priority-1 factor 2 = 10. Open: 5 × factor 1 (priority 5) = 5.
|
||||
(object) ['id' => 100, 'headline' => 'Done task', 'status' => 3, 'projectId' => 10, 'milestoneid' => 1, 'storypoints' => 5, 'priority' => 1, 'editTo' => null, 'dateToFinish' => null],
|
||||
(object) ['id' => 101, 'headline' => 'Open task', 'status' => 1, 'projectId' => 10, 'milestoneid' => 1, 'storypoints' => 5, 'priority' => 5, 'editTo' => null, 'dateToFinish' => null],
|
||||
]);
|
||||
|
||||
$report = $this->service->getMilestoneReportForProjects([10], $this->lastQuarter());
|
||||
|
||||
$milestone = $report['inProgress'][0];
|
||||
$this->assertEqualsWithDelta(66.67, $milestone->percentDone, 0.01);
|
||||
$this->assertSame(['done' => 1, 'total' => 2], $milestone->taskStats);
|
||||
// Key tasks list leads with completed work.
|
||||
$this->assertSame(100, $milestone->keyTasks[0]->id);
|
||||
$this->assertTrue($milestone->keyTasks[0]->isDone);
|
||||
}
|
||||
|
||||
public function test_slippage_reports_milestones_pushed_out_and_added_mid_period(): void
|
||||
{
|
||||
$this->repository->method('getMilestonesForProjects')->willReturn([
|
||||
// Open, now due after the period, with an in-period due-date change -> pushed out.
|
||||
$this->milestone(1, ['editFrom' => '2026-04-10 00:00:00', 'editTo' => '2026-09-15 00:00:00']),
|
||||
// Created mid-period -> added.
|
||||
$this->milestone(2, ['date' => '2026-05-05 00:00:00', 'editFrom' => '2026-05-05 00:00:00', 'editTo' => '2026-12-01 00:00:00']),
|
||||
]);
|
||||
$this->repository->method('getStatusHistoryForTickets')->willReturn([]);
|
||||
$this->repository->method('getTasksForMilestones')->willReturn([]);
|
||||
$this->repository->method('getDueDateChangesForTickets')->willReturn([
|
||||
(object) ['ticketId' => 1, 'changeValue' => '2026-09-15 00:00:00', 'dateModified' => '2026-06-10 09:00:00'],
|
||||
(object) ['ticketId' => 1, 'changeValue' => '2026-09-15 00:00:00', 'dateModified' => '2026-06-20 09:00:00'],
|
||||
]);
|
||||
|
||||
$report = $this->service->getMilestoneReportForProjects([10], $this->lastQuarter());
|
||||
|
||||
$this->assertCount(1, $report['slippage']['pushedOut']);
|
||||
$this->assertSame(1, $report['slippage']['pushedOut'][0]->id);
|
||||
$this->assertSame(2, $report['slippage']['pushedOut'][0]->dueDateMoves);
|
||||
$this->assertSame([2], array_map(fn ($m) => $m->id, $report['slippage']['addedMidPeriod']));
|
||||
}
|
||||
|
||||
public function test_goal_report_resolves_rollups_and_progress(): void
|
||||
{
|
||||
$this->repository->method('getGoalsForProjects')->willReturn([
|
||||
(object) ['id' => 1, 'title' => 'Graduates', 'description' => '', 'status' => 'status_ontrack', 'metricType' => 'count', 'startValue' => 0.0, 'currentValue' => 42.0, 'endValue' => 60.0, 'setting' => '', 'milestoneId' => '', 'kpi' => '', 'startDate' => null, 'endDate' => null, 'canvasId' => 5, 'projectId' => 10, 'boardTitle' => 'Goals', 'milestoneHeadline' => null],
|
||||
(object) ['id' => 2, 'title' => 'Rollup KPI', 'description' => '', 'status' => 'status_atrisk', 'metricType' => 'count', 'startValue' => 0.0, 'currentValue' => 0.0, 'endValue' => 100.0, 'setting' => 'linkAndReport', 'milestoneId' => '', 'kpi' => '', 'startDate' => null, 'endDate' => null, 'canvasId' => 5, 'projectId' => 10, 'boardTitle' => 'Goals', 'milestoneHeadline' => null],
|
||||
]);
|
||||
$this->goalService->method('getChildGoalsForReporting')->with(2)->willReturn(25.0);
|
||||
// The engine batches milestone-chip hydration up front; stub it so the
|
||||
// test exercises the rollup/progress path without relying on a mock's
|
||||
// default null return.
|
||||
$this->goalService->method('getMilestonesForGoals')->willReturn([1 => [], 2 => []]);
|
||||
|
||||
$report = $this->service->getGoalReportForProjects([10]);
|
||||
|
||||
$this->assertEqualsWithDelta(70.0, $report['goals'][0]->goalProgress, 0.01);
|
||||
$this->assertEqualsWithDelta(25.0, $report['goals'][1]->currentValue, 0.01);
|
||||
$this->assertEqualsWithDelta(25.0, $report['goals'][1]->goalProgress, 0.01);
|
||||
$this->assertSame(['ontrack' => 1, 'atrisk' => 1, 'miss' => 0], $report['counts']);
|
||||
}
|
||||
|
||||
public function test_status_updates_group_by_project_and_respect_limit(): void
|
||||
{
|
||||
$this->repository->method('getStatusUpdatesForProjects')->willReturn([
|
||||
(object) ['id' => 1, 'projectId' => 10, 'text' => 'newest', 'date' => '2026-06-20 10:00:00', 'status' => 'green', 'authorFirstname' => 'A', 'authorLastname' => 'B', 'authorProfileId' => null],
|
||||
(object) ['id' => 2, 'projectId' => 10, 'text' => 'older', 'date' => '2026-05-01 10:00:00', 'status' => 'yellow', 'authorFirstname' => 'A', 'authorLastname' => 'B', 'authorProfileId' => null],
|
||||
(object) ['id' => 3, 'projectId' => 11, 'text' => 'other project', 'date' => '2026-05-02 10:00:00', 'status' => 'green', 'authorFirstname' => 'C', 'authorLastname' => 'D', 'authorProfileId' => null],
|
||||
]);
|
||||
|
||||
$updates = $this->service->getStatusUpdatesForProjects([10, 11], $this->lastQuarter(), 1);
|
||||
|
||||
$this->assertCount(1, $updates[10]);
|
||||
$this->assertSame('newest', $updates[10][0]->text);
|
||||
$this->assertCount(1, $updates[11]);
|
||||
}
|
||||
|
||||
public function test_project_summaries_flag_stale_and_alerting_projects(): void
|
||||
{
|
||||
$this->repository->method('getProjectsMeta')->willReturn([
|
||||
10 => (object) ['id' => 10, 'name' => 'Fresh red project', 'details' => '<p>Some <b>html</b> description</p>', 'clientId' => 1, 'state' => 0, 'start' => null, 'end' => null, 'type' => 'project', 'parent' => null, 'clientName' => 'Client'],
|
||||
11 => (object) ['id' => 11, 'name' => 'Silent project', 'details' => '', 'clientId' => 1, 'state' => 0, 'start' => null, 'end' => null, 'type' => 'project', 'parent' => null, 'clientName' => 'Client'],
|
||||
]);
|
||||
$this->repository->method('getLatestStatusUpdateForProjects')->willReturn([
|
||||
10 => (object) ['projectId' => 10, 'text' => 'Behind on hiring', 'date' => '2026-07-01 10:00:00', 'status' => 'red', 'authorFirstname' => 'A', 'authorLastname' => 'B'],
|
||||
// Project 11 has no status update at all.
|
||||
]);
|
||||
$this->projectService->method('getProjectProgress')->willReturn(['percent' => 40.0, 'estimatedCompletionDate' => false, 'plannedCompletionDate' => '']);
|
||||
|
||||
$summaries = $this->service->getProjectSummaries([10, 11]);
|
||||
|
||||
$this->assertSame('red', $summaries[10]->latestStatus);
|
||||
$this->assertFalse($summaries[10]->isStale);
|
||||
$this->assertSame('Some html description', $summaries[10]->descriptionExcerpt);
|
||||
$this->assertNull($summaries[11]->latestStatus);
|
||||
$this->assertTrue($summaries[11]->isStale);
|
||||
}
|
||||
|
||||
public function test_effort_totals_by_project_and_milestone(): void
|
||||
{
|
||||
$this->repository->method('getHoursLoggedForProjects')->willReturn([
|
||||
(object) ['projectId' => 10, 'milestoneId' => 1, 'loggedHours' => 12.5],
|
||||
(object) ['projectId' => 10, 'milestoneId' => 0, 'loggedHours' => 3.0],
|
||||
(object) ['projectId' => 11, 'milestoneId' => 2, 'loggedHours' => 4.25],
|
||||
]);
|
||||
|
||||
$effort = $this->service->getEffortForProjects([10, 11], $this->lastQuarter());
|
||||
|
||||
$this->assertEqualsWithDelta(19.75, $effort['total'], 0.001);
|
||||
$this->assertEqualsWithDelta(15.5, $effort['byProject'][10], 0.001);
|
||||
$this->assertEqualsWithDelta(12.5, $effort['byMilestone'][1], 0.001);
|
||||
$this->assertArrayNotHasKey(0, $effort['byMilestone']);
|
||||
}
|
||||
|
||||
public function test_build_report_composes_needs_attention_and_deltas(): void
|
||||
{
|
||||
$this->repository->method('getMilestonesForProjects')->willReturn([
|
||||
// Completed this period.
|
||||
$this->milestone(1, ['status' => 3]),
|
||||
// Completed in the prior period (feeds the delta).
|
||||
$this->milestone(2, ['status' => 3]),
|
||||
// Overdue -> needs attention.
|
||||
$this->milestone(3, ['editTo' => '2026-06-01 00:00:00']),
|
||||
]);
|
||||
$this->repository->method('getStatusHistoryForTickets')->willReturn([
|
||||
(object) ['ticketId' => 1, 'changeValue' => '3', 'dateModified' => '2026-05-10 09:00:00'],
|
||||
(object) ['ticketId' => 2, 'changeValue' => '3', 'dateModified' => '2026-02-10 09:00:00'],
|
||||
]);
|
||||
$this->repository->method('getTasksForMilestones')->willReturn([]);
|
||||
$this->repository->method('getDueDateChangesForTickets')->willReturn([]);
|
||||
$this->repository->method('getGoalsForProjects')->willReturn([
|
||||
(object) ['id' => 1, 'title' => 'At-risk goal', 'description' => '', 'status' => 'status_atrisk', 'metricType' => '', 'startValue' => 0.0, 'currentValue' => 1.0, 'endValue' => 10.0, 'setting' => '', 'milestoneId' => '', 'kpi' => '', 'startDate' => null, 'endDate' => null, 'canvasId' => 5, 'projectId' => 10, 'boardTitle' => 'Goals', 'milestoneHeadline' => null],
|
||||
]);
|
||||
$this->repository->method('getStatusUpdatesForProjects')->willReturn([]);
|
||||
$this->repository->method('getHoursLoggedForProjects')->willReturn([]);
|
||||
$this->repository->method('getProjectsMeta')->willReturn([
|
||||
10 => (object) ['id' => 10, 'name' => 'Project', 'details' => '', 'clientId' => 1, 'state' => 0, 'start' => null, 'end' => null, 'type' => 'project', 'parent' => null, 'clientName' => 'Client'],
|
||||
]);
|
||||
$this->repository->method('getLatestStatusUpdateForProjects')->willReturn([]);
|
||||
$this->projectService->method('getProjectProgress')->willReturn(['percent' => 10.0, 'estimatedCompletionDate' => false, 'plannedCompletionDate' => '']);
|
||||
|
||||
$report = $this->service->buildReport([10], $this->lastQuarter());
|
||||
|
||||
$this->assertSame(1, $report['stats']['completed']);
|
||||
$this->assertSame(1, $report['deltas']['completedPrior']);
|
||||
$this->assertSame(0, $report['deltas']['completedDelta']);
|
||||
$this->assertCount(1, $report['needsAttention']['overdueMilestones']);
|
||||
$this->assertCount(1, $report['needsAttention']['goalsAtRisk']);
|
||||
// No status update ever -> the project is flagged as silent.
|
||||
$this->assertCount(1, $report['needsAttention']['staleProjects']);
|
||||
}
|
||||
}
|
||||
172
tests/Unit/app/Domain/Reports/Services/ReportsServiceTest.php
Normal file
172
tests/Unit/app/Domain/Reports/Services/ReportsServiceTest.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Reports\Services;
|
||||
|
||||
use Leantime\Core\Configuration\AppSettings as AppSettingCore;
|
||||
use Leantime\Core\Configuration\Environment as EnvironmentCore;
|
||||
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
|
||||
use Leantime\Domain\Reports\Repositories\Reports as ReportRepository;
|
||||
use Leantime\Domain\Reports\Services\Reports;
|
||||
use Leantime\Domain\Setting\Services\Setting as SettingsService;
|
||||
use Leantime\Domain\Sprints\Models\Sprints as SprintModel;
|
||||
use Leantime\Domain\Sprints\Repositories\Sprints as SprintRepository;
|
||||
use Leantime\Domain\Sprints\Services\Sprints as SprintService;
|
||||
use Leantime\Domain\Tickets\Repositories\Tickets as TicketRepository;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the sprint-burndown selection logic extracted from the
|
||||
* Reports\Controllers\Show controller into the Reports service, plus the permission-engine
|
||||
* security surface: the three by-projectId @api reads must stay gated against the REQUESTED
|
||||
* project, and the system/telemetry methods must never become RPC-reachable again.
|
||||
*/
|
||||
class ReportsServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/** Matches Jsonrpc::isApiMethod(): @api only at the start of a docblock line. */
|
||||
private function isApiExposed(string $method): bool
|
||||
{
|
||||
$doc = (new \ReflectionMethod(Reports::class, $method))->getDocComment();
|
||||
|
||||
return $doc !== false && preg_match('/^\s*\*\s*@api\b/m', $doc) === 1;
|
||||
}
|
||||
|
||||
/** The #[RequiresPermission] attribute instance on a method, or null. */
|
||||
private function permissionAttribute(string $method): ?\Leantime\Core\Auth\Permissions\RequiresPermission
|
||||
{
|
||||
$attributes = (new \ReflectionMethod(Reports::class, $method))
|
||||
->getAttributes(\Leantime\Core\Auth\Permissions\RequiresPermission::class);
|
||||
|
||||
return $attributes === [] ? null : $attributes[0]->newInstance();
|
||||
}
|
||||
|
||||
public function test_by_project_reads_are_rpc_exposed_and_gated_against_the_requested_project(): void
|
||||
{
|
||||
foreach (['getSprintBurndownForReport', 'getFullReport', 'getRealtimeReport'] as $method) {
|
||||
$this->assertTrue($this->isApiExposed($method), "$method should stay RPC-callable");
|
||||
|
||||
$attribute = $this->permissionAttribute($method);
|
||||
$this->assertNotNull($attribute, "$method must carry a #[RequiresPermission] dispatch gate");
|
||||
$this->assertSame('reports.view', $attribute->permission, $method);
|
||||
// projectIdParam binds the gate to the REQUESTED project — without it the enforcer
|
||||
// falls back to the session project and the cross-project RPC IDOR reopens.
|
||||
$this->assertSame('projectId', $attribute->projectIdParam, $method);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_system_and_telemetry_methods_are_not_rpc_reachable(): void
|
||||
{
|
||||
// dailyIngestion binds to session state; the others leak instance-wide aggregates or
|
||||
// mutate company-wide settings. None may carry a line-starting @api tag.
|
||||
foreach ([
|
||||
'dailyIngestion',
|
||||
'cronDailyIngestion',
|
||||
'getAnonymousTelemetry',
|
||||
'sendAnonymousTelemetry',
|
||||
'optOutTelemetry',
|
||||
'getProjectStatusReport',
|
||||
'generateTicketReactionsReport',
|
||||
] as $method) {
|
||||
$this->assertFalse($this->isApiExposed($method), "$method must NOT be RPC-callable");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the Reports service with every constructor dependency stubbed,
|
||||
* injecting the provided Sprints service (the only dependency the method
|
||||
* under test actually exercises).
|
||||
*/
|
||||
private function makeService(SprintService $sprintService): Reports
|
||||
{
|
||||
return new Reports(
|
||||
$this->make(AppSettingCore::class),
|
||||
$this->make(EnvironmentCore::class),
|
||||
$this->make(ProjectRepository::class),
|
||||
$this->make(SprintRepository::class),
|
||||
$this->make(ReportRepository::class),
|
||||
$this->make(SettingsService::class),
|
||||
$this->make(TicketRepository::class),
|
||||
$sprintService,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Sprints model with the given id.
|
||||
*/
|
||||
private function sprint(int $id): SprintModel
|
||||
{
|
||||
$sprint = new SprintModel;
|
||||
$sprint->id = $id;
|
||||
|
||||
return $sprint;
|
||||
}
|
||||
|
||||
public function test_returns_false_when_project_has_no_sprints(): void
|
||||
{
|
||||
$sprintService = $this->make(SprintService::class, [
|
||||
'getAllSprints' => fn () => [],
|
||||
]);
|
||||
|
||||
$result = $this->makeService($sprintService)->getSprintBurndownForReport(7, null);
|
||||
|
||||
$this->assertFalse($result['chart']);
|
||||
$this->assertFalse($result['currentSprintId']);
|
||||
}
|
||||
|
||||
public function test_uses_requested_sprint_id_and_echoes_it_back(): void
|
||||
{
|
||||
$sprintService = $this->make(SprintService::class, [
|
||||
'getAllSprints' => fn () => [$this->sprint(1), $this->sprint(2)],
|
||||
'getSprint' => fn ($id) => $this->sprint($id),
|
||||
'getSprintBurndown' => fn () => [['date' => '2024-01-01']],
|
||||
]);
|
||||
|
||||
$result = $this->makeService($sprintService)->getSprintBurndownForReport(7, 2);
|
||||
|
||||
$this->assertSame([['date' => '2024-01-01']], $result['chart']);
|
||||
$this->assertSame(2, $result['currentSprintId']);
|
||||
}
|
||||
|
||||
public function test_requested_sprint_id_is_echoed_even_when_sprint_missing(): void
|
||||
{
|
||||
$sprintService = $this->make(SprintService::class, [
|
||||
'getAllSprints' => fn () => [$this->sprint(1)],
|
||||
'getSprint' => fn () => false,
|
||||
]);
|
||||
|
||||
$result = $this->makeService($sprintService)->getSprintBurndownForReport(7, 99);
|
||||
|
||||
$this->assertFalse($result['chart']);
|
||||
$this->assertSame(99, $result['currentSprintId']);
|
||||
}
|
||||
|
||||
public function test_falls_back_to_current_sprint_when_none_requested(): void
|
||||
{
|
||||
$sprintService = $this->make(SprintService::class, [
|
||||
'getAllSprints' => fn () => [$this->sprint(1), $this->sprint(5)],
|
||||
'getCurrentSprintId' => fn () => 5,
|
||||
'getSprint' => fn ($id) => $this->sprint($id),
|
||||
'getSprintBurndown' => fn () => [['date' => '2024-02-02']],
|
||||
]);
|
||||
|
||||
$result = $this->makeService($sprintService)->getSprintBurndownForReport(7, null);
|
||||
|
||||
$this->assertSame([['date' => '2024-02-02']], $result['chart']);
|
||||
$this->assertSame(5, $result['currentSprintId']);
|
||||
}
|
||||
|
||||
public function test_falls_back_to_first_sprint_when_no_current_sprint(): void
|
||||
{
|
||||
$sprintService = $this->make(SprintService::class, [
|
||||
'getAllSprints' => fn () => [$this->sprint(11), $this->sprint(12)],
|
||||
'getCurrentSprintId' => fn () => false,
|
||||
'getSprintBurndown' => fn () => [['date' => '2024-03-03']],
|
||||
]);
|
||||
|
||||
$result = $this->makeService($sprintService)->getSprintBurndownForReport(7, null);
|
||||
|
||||
$this->assertSame([['date' => '2024-03-03']], $result['chart']);
|
||||
$this->assertSame(11, $result['currentSprintId']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Setting\Services;
|
||||
|
||||
use Leantime\Core\Files\Contracts\FileManagerInterface;
|
||||
use Leantime\Domain\Ideas\Repositories\Ideas as IdeaRepository;
|
||||
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
|
||||
use Leantime\Domain\Setting\Services\Setting as SettingService;
|
||||
use Leantime\Domain\Tickets\Repositories\Tickets as TicketRepository;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the Setting service helpers extracted during the
|
||||
* thin-controller refactor (getProjectLabel).
|
||||
*/
|
||||
class SettingServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/**
|
||||
* Builds a real Setting service, allowing each dependency to be
|
||||
* overridden with a stub so we can observe the label resolution.
|
||||
*/
|
||||
private function makeService(
|
||||
?SettingRepository $settingsRepo = null,
|
||||
?TicketRepository $ticketsRepo = null,
|
||||
?IdeaRepository $ideaRepo = null,
|
||||
): SettingService {
|
||||
return new SettingService(
|
||||
$settingsRepo ?? $this->make(SettingRepository::class),
|
||||
$this->makeEmpty(FileManagerInterface::class),
|
||||
$ticketsRepo ?? $this->make(TicketRepository::class),
|
||||
$ideaRepo ?? $this->make(IdeaRepository::class),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_get_project_label_reads_ticket_state_label_name(): void
|
||||
{
|
||||
$ticketsRepo = $this->make(TicketRepository::class, [
|
||||
'getStateLabels' => fn () => [
|
||||
3 => ['name' => 'In Progress'],
|
||||
],
|
||||
]);
|
||||
|
||||
$label = $this->makeService(ticketsRepo: $ticketsRepo)->getProjectLabel('ticketlabels', 3, 1);
|
||||
|
||||
$this->assertSame('In Progress', $label);
|
||||
}
|
||||
|
||||
public function test_get_project_label_returns_empty_for_missing_ticket_label(): void
|
||||
{
|
||||
$ticketsRepo = $this->make(TicketRepository::class, [
|
||||
'getStateLabels' => fn () => [
|
||||
3 => ['name' => 'In Progress'],
|
||||
],
|
||||
]);
|
||||
|
||||
$label = $this->makeService(ticketsRepo: $ticketsRepo)->getProjectLabel('ticketlabels', 99, 1);
|
||||
|
||||
$this->assertSame('', $label);
|
||||
}
|
||||
|
||||
public function test_get_project_label_reads_idea_label_name(): void
|
||||
{
|
||||
$ideaRepo = $this->make(IdeaRepository::class, [
|
||||
'getCanvasLabels' => fn () => [
|
||||
1 => ['name' => 'Backlog', 'class' => 'label-default'],
|
||||
],
|
||||
]);
|
||||
|
||||
$label = $this->makeService(ideaRepo: $ideaRepo)->getProjectLabel('idealabels', 1, 1);
|
||||
|
||||
$this->assertSame('Backlog', $label);
|
||||
}
|
||||
|
||||
public function test_get_project_label_returns_empty_for_unknown_module(): void
|
||||
{
|
||||
$label = $this->makeService()->getProjectLabel('doesnotexist', 1, 1);
|
||||
|
||||
$this->assertSame('', $label);
|
||||
}
|
||||
}
|
||||
212
tests/Unit/app/Domain/Sprints/Services/SprintsServiceTest.php
Normal file
212
tests/Unit/app/Domain/Sprints/Services/SprintsServiceTest.php
Normal file
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Sprints\Services;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\PermissionService;
|
||||
use Leantime\Core\Exceptions\AuthorizationException;
|
||||
use Leantime\Core\Exceptions\MissingParameterException;
|
||||
use Leantime\Domain\Reports\Repositories\Reports as ReportRepository;
|
||||
use Leantime\Domain\Sprints\Models\Sprints as SprintModel;
|
||||
use Leantime\Domain\Sprints\Repositories\Sprints as SprintRepository;
|
||||
use Leantime\Domain\Sprints\Services\Sprints as SprintService;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the Sprints service helpers extracted during the
|
||||
* thin-controller refactor (getNewSprint, deleteSprint, and the
|
||||
* required-date validation now living in addSprint/editSprint).
|
||||
*/
|
||||
class SprintsServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/**
|
||||
* Builds a real Sprints service, allowing each dependency to be
|
||||
* overridden with a stub so we can observe the persistence calls.
|
||||
*/
|
||||
private function makeService(
|
||||
?SprintRepository $sprintRepo = null,
|
||||
?ReportRepository $reportRepo = null,
|
||||
): SprintService {
|
||||
return new SprintService(
|
||||
$sprintRepo ?? $this->make(SprintRepository::class),
|
||||
$reportRepo ?? $this->make(ReportRepository::class),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_get_new_sprint_uses_default_thirteen_day_window(): void
|
||||
{
|
||||
$sprint = $this->makeService()->getNewSprint();
|
||||
|
||||
$this->assertInstanceOf(SprintModel::class, $sprint);
|
||||
$this->assertNull($sprint->id);
|
||||
|
||||
// The end date should be exactly 13 days after the start date.
|
||||
$this->assertSame(13, (int) $sprint->startDate->diffInDays($sprint->endDate));
|
||||
}
|
||||
|
||||
public function test_delete_sprint_delegates_to_repository_and_clears_session(): void
|
||||
{
|
||||
session(['currentSprint' => '99']);
|
||||
|
||||
$deletedId = null;
|
||||
$repo = $this->make(SprintRepository::class, [
|
||||
// deleteSprint now loads the sprint to authorize delete against its project.
|
||||
'getSprint' => fn () => $this->make(SprintModel::class, ['id' => 42, 'projectId' => 9]),
|
||||
'delSprint' => function ($id) use (&$deletedId) {
|
||||
$deletedId = $id;
|
||||
},
|
||||
]);
|
||||
|
||||
$service = $this->makeService(sprintRepo: $repo);
|
||||
$service->setPermissionService($this->make(PermissionService::class, [
|
||||
'authorize' => fn () => null,
|
||||
]));
|
||||
|
||||
$service->deleteSprint(42);
|
||||
|
||||
$this->assertSame(42, $deletedId);
|
||||
$this->assertSame('', session('currentSprint'));
|
||||
}
|
||||
|
||||
public function test_add_sprint_throws_when_dates_missing(): void
|
||||
{
|
||||
$addCalls = 0;
|
||||
$repo = $this->make(SprintRepository::class, [
|
||||
'addSprint' => function () use (&$addCalls) {
|
||||
$addCalls++;
|
||||
|
||||
return 1;
|
||||
},
|
||||
]);
|
||||
|
||||
// Authorization now runs before date validation (authorize-first), so allow it and assert
|
||||
// the validation still rejects the missing dates before any write reaches the repository.
|
||||
$service = $this->makeService(sprintRepo: $repo);
|
||||
$service->setPermissionService($this->make(PermissionService::class, ['authorize' => fn () => null]));
|
||||
|
||||
$this->expectException(MissingParameterException::class);
|
||||
|
||||
try {
|
||||
$service->addSprint(['startDate' => '', 'endDate' => '']);
|
||||
} finally {
|
||||
$this->assertSame(0, $addCalls, 'An invalid sprint must never reach the repository');
|
||||
}
|
||||
}
|
||||
|
||||
public function test_edit_sprint_throws_when_end_date_missing(): void
|
||||
{
|
||||
$editCalls = 0;
|
||||
$repo = $this->make(SprintRepository::class, [
|
||||
// editSprint loads the existing sprint to authorize against its project before it
|
||||
// validates the dates, so the load must be stubbed even on the validation-failure path.
|
||||
'getSprint' => fn () => $this->make(SprintModel::class, ['id' => 5, 'projectId' => 9]),
|
||||
'editSprint' => function () use (&$editCalls) {
|
||||
$editCalls++;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$service = $this->makeService(sprintRepo: $repo);
|
||||
$service->setPermissionService($this->make(PermissionService::class, ['authorize' => fn () => null]));
|
||||
|
||||
$this->expectException(MissingParameterException::class);
|
||||
|
||||
try {
|
||||
$service->editSprint(['id' => 5, 'startDate' => '2026-01-01', 'endDate' => '']);
|
||||
} finally {
|
||||
$this->assertSame(0, $editCalls, 'An invalid update must never reach the repository');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Authorization: sprints are project-scoped; mutators authorize against the SPRINT'S
|
||||
// project (entityScoped), closing the IDOR where the id alone identified the row.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private function denyingPermissions(): PermissionService
|
||||
{
|
||||
return $this->make(PermissionService::class, [
|
||||
'authorize' => function (): void {
|
||||
throw new AuthorizationException;
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_delete_sprint_is_denied_and_does_not_delete_without_permission(): void
|
||||
{
|
||||
// deleteSprint loads the sprint and authorizes sprints.delete against ITS project before
|
||||
// deleting — a denying engine must throw BEFORE the repository delete runs.
|
||||
$service = $this->makeService(sprintRepo: $this->make(SprintRepository::class, [
|
||||
'getSprint' => fn () => $this->make(SprintModel::class, ['id' => 5, 'projectId' => 9]),
|
||||
'delSprint' => function (): void {
|
||||
throw new \RuntimeException('delete must not be reached when denied');
|
||||
},
|
||||
]));
|
||||
$service->setPermissionService($this->denyingPermissions());
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$service->deleteSprint(5);
|
||||
}
|
||||
|
||||
public function test_add_sprint_is_denied_without_create_permission(): void
|
||||
{
|
||||
session(['currentProject' => 9]);
|
||||
|
||||
$service = $this->makeService(sprintRepo: $this->make(SprintRepository::class, [
|
||||
'addSprint' => function () {
|
||||
throw new \RuntimeException('add must not be reached when denied');
|
||||
},
|
||||
]));
|
||||
$service->setPermissionService($this->denyingPermissions());
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$service->addSprint(['startDate' => '2026-01-01', 'endDate' => '2026-01-14', 'projectId' => 9]);
|
||||
}
|
||||
|
||||
public function test_get_sprint_is_denied_when_user_cannot_view_its_project(): void
|
||||
{
|
||||
// Read-side IDOR fence: getSprint loads the sprint, then authorizes VIEW against ITS project
|
||||
// (not the session project). A denying engine must throw before any cross-project sprint
|
||||
// metadata (name/dates/projectId) is returned.
|
||||
$service = $this->makeService(sprintRepo: $this->make(SprintRepository::class, [
|
||||
'getSprint' => fn () => $this->make(SprintModel::class, ['id' => 7, 'projectId' => 9]),
|
||||
]));
|
||||
$service->setPermissionService($this->denyingPermissions());
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$service->getSprint(7);
|
||||
}
|
||||
|
||||
public function test_get_sprint_returns_the_sprint_when_view_is_allowed(): void
|
||||
{
|
||||
$service = $this->makeService(sprintRepo: $this->make(SprintRepository::class, [
|
||||
'getSprint' => fn () => $this->make(SprintModel::class, ['id' => 7, 'projectId' => 9]),
|
||||
]));
|
||||
$service->setPermissionService($this->make(PermissionService::class, ['authorize' => fn () => null]));
|
||||
|
||||
$this->assertSame(7, $service->getSprint(7)->id);
|
||||
}
|
||||
|
||||
public function test_get_sprint_returns_false_for_unknown_id_without_authorizing(): void
|
||||
{
|
||||
// A missing sprint short-circuits to false BEFORE authorize, so there is no enumeration
|
||||
// oracle (allowed vs denied looks identical for a non-existent id) and no false lockout.
|
||||
$authorizeCalls = 0;
|
||||
$service = $this->makeService(sprintRepo: $this->make(SprintRepository::class, [
|
||||
'getSprint' => fn () => false, // repo returns false (not null) for a missing row
|
||||
]));
|
||||
$service->setPermissionService($this->make(PermissionService::class, [
|
||||
'authorize' => function () use (&$authorizeCalls): void {
|
||||
$authorizeCalls++;
|
||||
},
|
||||
]));
|
||||
|
||||
$this->assertFalse($service->getSprint(999));
|
||||
$this->assertSame(0, $authorizeCalls, 'A non-existent sprint must short-circuit before authorize');
|
||||
}
|
||||
}
|
||||
98
tests/Unit/app/Domain/Status/Controllers/IndexTest.php
Normal file
98
tests/Unit/app/Domain/Status/Controllers/IndexTest.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\app\Domain\Status\Controllers;
|
||||
|
||||
use Leantime\Core\Application;
|
||||
use Leantime\Core\Auth\Permissions\PermissionEnforcer;
|
||||
use Leantime\Core\Bootstrap\LoadConfig;
|
||||
use Leantime\Core\Bootstrap\SetRequestForConsole;
|
||||
use Leantime\Core\Configuration\AppSettings;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\UI\Template;
|
||||
use Leantime\Domain\Plugins\Services\Plugins;
|
||||
use Leantime\Domain\Status\Controllers\Index;
|
||||
|
||||
/**
|
||||
* Unit tests for the public /status discovery endpoint.
|
||||
*
|
||||
* Pins the contract the mobile app relies on (authMethods + oidcLoginUrl drive
|
||||
* whether the SSO button appears) AND the security tier: the unauthenticated
|
||||
* response must NEVER leak a plugin/version inventory.
|
||||
*/
|
||||
class IndexTest extends \Unit\TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->app = new Application(APP_ROOT);
|
||||
$this->app->bootstrapWith([LoadConfig::class, SetRequestForConsole::class]);
|
||||
$this->app->boot();
|
||||
$this->app['view'] = $this->createMock(\Illuminate\View\Factory::class);
|
||||
$this->app['session'] = $this->createMock(\Illuminate\Session\SessionManager::class);
|
||||
$this->app->instance(PermissionEnforcer::class, $this->createMock(PermissionEnforcer::class));
|
||||
}
|
||||
|
||||
private function makeController(array $overrides): Index
|
||||
{
|
||||
// Environment's constructor overwrites known config keys with
|
||||
// env-resolved defaults, so set the values AFTER construction.
|
||||
$env = new Environment;
|
||||
$env->set('oidcEnable', $overrides['oidcEnable'] ?? false);
|
||||
$env->set('useLdap', $overrides['useLdap'] ?? false);
|
||||
$env->set('sitename', $overrides['sitename'] ?? 'Leantime');
|
||||
|
||||
$request = IncomingRequest::create('https://demo.leantime.io/status', 'GET');
|
||||
$this->app->instance(IncomingRequest::class, $request);
|
||||
$this->app->instance(Environment::class, $env);
|
||||
$this->app->instance(AppSettings::class, new AppSettings);
|
||||
|
||||
// Mobile-auth advertising is gated on AdvancedAuth; mock it installed so
|
||||
// these contract tests cover a mobile-capable instance. The gate itself
|
||||
// is verified live e2e (AdvancedAuth off -> mobile OIDC not advertised).
|
||||
$plugins = $this->createMock(Plugins::class);
|
||||
$plugins->method('isEnabled')->willReturn(true);
|
||||
$this->app->instance(Plugins::class, $plugins);
|
||||
|
||||
return new Index($request, $this->createMock(Template::class), $this->createMock(Language::class));
|
||||
}
|
||||
|
||||
private function bodyOf($response): array
|
||||
{
|
||||
return json_decode($response->getContent(), true);
|
||||
}
|
||||
|
||||
public function test_password_only_when_no_sso_configured(): void
|
||||
{
|
||||
$response = $this->makeController(['oidcEnable' => false, 'useLdap' => false, 'sitename' => 'Acme'])->get([]);
|
||||
$body = $this->bodyOf($response);
|
||||
|
||||
$this->assertSame(200, $response->getStatusCode());
|
||||
$this->assertSame(['password'], $body['authMethods']);
|
||||
$this->assertArrayNotHasKey('oidcLoginUrl', $body);
|
||||
$this->assertSame('Acme', $body['instanceName']);
|
||||
$this->assertTrue($body['mobileAuthEnabled']);
|
||||
}
|
||||
|
||||
public function test_oidc_enabled_advertises_oidc_and_login_url(): void
|
||||
{
|
||||
$response = $this->makeController(['oidcEnable' => true, 'useLdap' => false, 'sitename' => 'Acme'])->get([]);
|
||||
$body = $this->bodyOf($response);
|
||||
|
||||
$this->assertContains('oidc', $body['authMethods']);
|
||||
$this->assertSame('https://demo.leantime.io/oidc/login', $body['oidcLoginUrl']);
|
||||
}
|
||||
|
||||
public function test_response_never_leaks_a_plugin_or_version_inventory(): void
|
||||
{
|
||||
// The unauthenticated tier must not become a recon gift.
|
||||
$response = $this->makeController(['oidcEnable' => true, 'useLdap' => false])->get([]);
|
||||
$body = $this->bodyOf($response);
|
||||
|
||||
$this->assertArrayNotHasKey('plugins', $body);
|
||||
$this->assertArrayNotHasKey('dbVersion', $body);
|
||||
$this->assertArrayHasKey('version', $body);
|
||||
}
|
||||
}
|
||||
84
tests/Unit/app/Domain/Tags/Services/TagsServiceTest.php
Normal file
84
tests/Unit/app/Domain/Tags/Services/TagsServiceTest.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Tags\Services;
|
||||
|
||||
use Leantime\Core\Exceptions\AuthorizationException;
|
||||
use Leantime\Domain\Blueprints\Repositories\Blueprints as CanvaRepository;
|
||||
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
|
||||
use Leantime\Domain\Tags\Services\Tags as TagService;
|
||||
use Leantime\Domain\Tickets\Repositories\Tickets as TicketRepository;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the project-access authorization added to Tags::getTags when the
|
||||
* /api/tags REST controller (which forced session('currentProject')) was retired in
|
||||
* favour of the JSON-RPC entry point Tags.Tags.getTags.
|
||||
*/
|
||||
class TagsServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
session(['userdata.id' => 1]);
|
||||
}
|
||||
|
||||
private function makeService(
|
||||
ProjectRepository $projectRepo,
|
||||
?TicketRepository $ticketRepo = null,
|
||||
?CanvaRepository $canvasRepo = null,
|
||||
): TagService {
|
||||
return new TagService(
|
||||
$projectRepo,
|
||||
$canvasRepo ?? $this->make(CanvaRepository::class, ['getTags' => fn () => []]),
|
||||
$ticketRepo ?? $this->make(TicketRepository::class, ['getTags' => fn () => []]),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_get_tags_throws_and_does_not_query_when_user_cannot_access_project(): void
|
||||
{
|
||||
$queryCalls = 0;
|
||||
$projectRepo = $this->make(ProjectRepository::class, [
|
||||
'isUserAssignedToProject' => fn () => false,
|
||||
]);
|
||||
$ticketRepo = $this->make(TicketRepository::class, [
|
||||
'getTags' => function () use (&$queryCalls) {
|
||||
$queryCalls++;
|
||||
|
||||
return [];
|
||||
},
|
||||
]);
|
||||
|
||||
$thrown = null;
|
||||
try {
|
||||
$this->makeService($projectRepo, $ticketRepo)->getTags(99, '');
|
||||
} catch (AuthorizationException $e) {
|
||||
$thrown = $e;
|
||||
}
|
||||
|
||||
// No access must be a distinct, thrown signal -- NOT an empty array (which means "no matching tags").
|
||||
$this->assertInstanceOf(AuthorizationException::class, $thrown, 'A user must not read tags for a project they cannot access');
|
||||
$this->assertSame(0, $queryCalls, 'Unauthorized request must not even query the tag tables');
|
||||
}
|
||||
|
||||
public function test_get_tags_returns_filtered_tags_for_accessible_project(): void
|
||||
{
|
||||
$projectRepo = $this->make(ProjectRepository::class, [
|
||||
'isUserAssignedToProject' => fn () => true,
|
||||
]);
|
||||
$ticketRepo = $this->make(TicketRepository::class, [
|
||||
'getTags' => fn () => [['tags' => 'backend,frontend']],
|
||||
]);
|
||||
$canvasRepo = $this->make(CanvaRepository::class, [
|
||||
'getTags' => fn () => [['tags' => 'design']],
|
||||
]);
|
||||
|
||||
$result = $this->makeService($projectRepo, $ticketRepo, $canvasRepo)->getTags(5, 'end');
|
||||
sort($result);
|
||||
|
||||
// "backend" and "frontend" both contain "end"; "design" does not.
|
||||
$this->assertSame(['backend', 'frontend'], $result);
|
||||
}
|
||||
}
|
||||
95
tests/Unit/app/Domain/Tickets/Events/TicketsEventsBcTest.php
Normal file
95
tests/Unit/app/Domain/Tickets/Events/TicketsEventsBcTest.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Tickets\Events;
|
||||
|
||||
use Codeception\Test\Unit;
|
||||
use Leantime\Domain\Tickets\Events\MilestoneCreated;
|
||||
use Leantime\Domain\Tickets\Events\MilestoneDeleted;
|
||||
use Leantime\Domain\Tickets\Events\MilestoneUpdated;
|
||||
use Leantime\Domain\Tickets\Events\StatusLabelsUpdated;
|
||||
use Leantime\Domain\Tickets\Events\TicketCreated;
|
||||
use Leantime\Domain\Tickets\Events\TicketDeleted;
|
||||
use Leantime\Domain\Tickets\Events\TicketListFilter;
|
||||
use Leantime\Domain\Tickets\Events\TicketStatusUpdated;
|
||||
use Leantime\Domain\Tickets\Events\TicketUpdated;
|
||||
use Leantime\Domain\Tickets\Events\TodoWidgetTasksFilter;
|
||||
|
||||
/**
|
||||
* Backwards-compatibility contract for the Tickets pilot: every migrated emit site must
|
||||
* keep producing the EXACT historical string name it fired under before the class-based
|
||||
* migration (audited 2026-06). Plugins (Copilot, Llamadorian, Reactions, RecurringTasks,
|
||||
* observability wildcards) subscribe to these strings — a mismatch silently orphans them.
|
||||
*
|
||||
* The expected names are frozen from the pre-migration audit. If this test fails, fix the
|
||||
* event class or call site — do NOT update the expected name unless the corresponding
|
||||
* legacy hook is being intentionally retired at the end of the migration window.
|
||||
*/
|
||||
class TicketsEventsBcTest extends Unit
|
||||
{
|
||||
public function test_every_migrated_emit_site_produces_its_audited_historical_name(): void
|
||||
{
|
||||
$prefix = 'leantime.domain.tickets.services.tickets.';
|
||||
$repoPrefix = 'leantime.domain.tickets.repositories.tickets.';
|
||||
|
||||
$expectations = [
|
||||
// events — services
|
||||
[new TicketCreated(ticketId: 1, legacyHook: 'quickAddTicket'), $prefix.'quickAddTicket.ticket_created'],
|
||||
[new TicketCreated(ticketId: 1, legacyHook: 'addTicket'), $prefix.'addTicket.ticket_created'],
|
||||
[new TicketCreated(legacyHook: 'upsertSubtask'), $prefix.'upsertSubtask.ticket_created'],
|
||||
[new TicketUpdated(ticketId: 1, legacyHook: 'updateTicket'), $prefix.'updateTicket.ticket_updated'],
|
||||
[new TicketUpdated(ticketId: 1, legacyHook: 'patch'), $prefix.'patch.ticket_updated'],
|
||||
[new TicketUpdated(ticketId: 1, legacyHook: 'upsertSubtask'), $prefix.'upsertSubtask.ticket_updated'],
|
||||
[new TicketUpdated(legacyHook: 'updateTicketSorting'), $prefix.'updateTicketSorting.ticket_updated'],
|
||||
[new TicketUpdated(legacyHook: 'updateTicketStatusAndSorting'), $prefix.'updateTicketStatusAndSorting.ticket_updated'],
|
||||
[new TicketDeleted(ticketId: 1, legacyHook: 'delete'), $prefix.'delete.ticket_deleted'],
|
||||
[new MilestoneCreated(legacyHook: 'quickAddMilestone'), $prefix.'quickAddMilestone.milestone_created'],
|
||||
[new MilestoneUpdated(milestoneId: 1, legacyHook: 'quickUpdateMilestone'), $prefix.'quickUpdateMilestone.milestone_updated'],
|
||||
[new MilestoneDeleted(milestoneId: 1, legacyHook: 'deleteMilestone'), $prefix.'deleteMilestone.milestone_deleted'],
|
||||
[new StatusLabelsUpdated(projectId: 1, legacyHook: 'saveStatusLabels'), $prefix.'saveStatusLabels.statusLabels_updated'],
|
||||
// events — repository
|
||||
[new TicketStatusUpdated(ticketId: 1, status: 3, legacyHook: 'patchTicket'), $repoPrefix.'patchTicket.ticketStatusUpdate'],
|
||||
[new TicketStatusUpdated(ticketId: 1, status: 3, legacyHook: 'updateTicketStatus'), $repoPrefix.'updateTicketStatus.ticketStatusUpdate'],
|
||||
// filters
|
||||
[new TicketListFilter(tickets: [], legacyHook: 'getTicketTemplateAssignments'), $prefix.'getTicketTemplateAssignments.filterTickets'],
|
||||
[new TodoWidgetTasksFilter(tickets: [], legacyHook: 'getToDoWidgetAssignments'), $prefix.'getToDoWidgetAssignments.myTodoWidgetTasks'],
|
||||
[new TodoWidgetTasksFilter(tickets: [], hierarchical: true, legacyHook: 'getToDoWidgetHierarchicalAssignments'), $prefix.'getToDoWidgetHierarchicalAssignments.myTodoWidgetTasks'],
|
||||
];
|
||||
|
||||
foreach ($expectations as [$event, $expectedName]) {
|
||||
$this->assertSame(
|
||||
[$expectedName],
|
||||
$event->legacyHooks(),
|
||||
get_class($event).' must keep firing its audited historical name'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Each emit site passes __FUNCTION__ as the legacy hook, so the method names baked
|
||||
* into the expectations above must actually exist on the emitting classes — guards
|
||||
* against renames silently orphaning the legacy names.
|
||||
*/
|
||||
public function test_legacy_hook_method_names_still_exist_on_emitters(): void
|
||||
{
|
||||
$serviceMethods = [
|
||||
'saveStatusLabels', 'quickAddTicket', 'quickAddMilestone', 'addTicket',
|
||||
'updateTicket', 'patch', 'quickUpdateMilestone', 'upsertSubtask',
|
||||
'updateTicketSorting', 'updateTicketStatusAndSorting', 'delete', 'deleteMilestone',
|
||||
'getTicketTemplateAssignments', 'getToDoWidgetAssignments', 'getToDoWidgetHierarchicalAssignments',
|
||||
];
|
||||
|
||||
foreach ($serviceMethods as $method) {
|
||||
$this->assertTrue(
|
||||
method_exists(\Leantime\Domain\Tickets\Services\Tickets::class, $method),
|
||||
"Tickets service method {$method} was renamed — its legacy event name is now orphaned"
|
||||
);
|
||||
}
|
||||
|
||||
foreach (['patchTicket', 'updateTicketStatus'] as $method) {
|
||||
$this->assertTrue(
|
||||
method_exists(\Leantime\Domain\Tickets\Repositories\Tickets::class, $method),
|
||||
"Tickets repository method {$method} was renamed — its legacy event name is now orphaned"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Tickets\Repositories;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Leantime\Domain\Tickets\Repositories\Tickets;
|
||||
use Mockery;
|
||||
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Regression tests for patchTicket() field-name matching (#3692).
|
||||
*
|
||||
* PATCHABLE_COLUMNS is mostly camelCase, but 'milestoneid' matches the real column name.
|
||||
* The lookup was a case-sensitive isset(), so the documented API/MCP field 'milestoneId'
|
||||
* never matched and was dropped — while patchTicket() still reported success, which is the
|
||||
* part that makes it dangerous for automation.
|
||||
*
|
||||
* These call patchTicket() for real against a faked connection and assert on the payload
|
||||
* it would have written — no DB.
|
||||
*/
|
||||
class PatchTicketColumnsTest extends TestCase
|
||||
{
|
||||
use MockeryPHPUnitIntegration;
|
||||
|
||||
/**
|
||||
* Run patchTicket() and capture the column => value payload handed to update().
|
||||
*
|
||||
* @param array<string, mixed> $params
|
||||
* @return array{result: bool, updates: array<string, mixed>}
|
||||
*/
|
||||
private function runPatch(array $params): array
|
||||
{
|
||||
$updates = [];
|
||||
|
||||
$builder = Mockery::mock();
|
||||
$builder->shouldReceive('where')->andReturnSelf();
|
||||
$builder->shouldReceive('update')->andReturnUsing(function ($payload) use (&$updates) {
|
||||
$updates = $payload;
|
||||
|
||||
return 1;
|
||||
});
|
||||
// addTicketChange() reads the previous row before logging the change; an empty
|
||||
// result short-circuits it without touching anything under test.
|
||||
$builder->shouldReceive('select')->andReturnSelf();
|
||||
$builder->shouldReceive('limit')->andReturnSelf();
|
||||
$builder->shouldReceive('first')->andReturn(null);
|
||||
$builder->shouldReceive('insert')->andReturn(true);
|
||||
$builder->shouldReceive('get')->andReturn(collect());
|
||||
|
||||
$conn = Mockery::mock(ConnectionInterface::class);
|
||||
$conn->shouldReceive('table')->andReturn($builder);
|
||||
|
||||
$repo = (new \ReflectionClass(Tickets::class))->newInstanceWithoutConstructor();
|
||||
$prop = new \ReflectionProperty(Tickets::class, 'connection');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue($repo, $conn);
|
||||
|
||||
$result = $repo->patchTicket(42, $params);
|
||||
|
||||
return ['result' => $result, 'updates' => $updates];
|
||||
}
|
||||
|
||||
public function test_documented_milestone_id_casing_actually_patches(): void
|
||||
{
|
||||
$run = $this->runPatch(['milestoneId' => 7]);
|
||||
|
||||
$this->assertArrayHasKey(
|
||||
'milestoneid',
|
||||
$run['updates'],
|
||||
'The documented milestoneId field must reach the update as the real column (#3692)'
|
||||
);
|
||||
$this->assertSame(7, $run['updates']['milestoneid']);
|
||||
$this->assertTrue($run['result']);
|
||||
}
|
||||
|
||||
public function test_lowercase_milestoneid_still_works(): void
|
||||
{
|
||||
$run = $this->runPatch(['milestoneid' => 9]);
|
||||
|
||||
$this->assertSame(9, $run['updates']['milestoneid'] ?? null);
|
||||
}
|
||||
|
||||
public function test_unknown_fields_are_still_ignored(): void
|
||||
{
|
||||
$run = $this->runPatch(['bogusColumn' => 'x', 'headline' => 'kept']);
|
||||
|
||||
$this->assertArrayNotHasKey('bogusColumn', $run['updates']);
|
||||
$this->assertArrayNotHasKey('boguscolumn', $run['updates']);
|
||||
$this->assertSame('kept', $run['updates']['headline'] ?? null);
|
||||
}
|
||||
|
||||
public function test_a_patch_of_only_unknown_fields_reports_failure(): void
|
||||
{
|
||||
$run = $this->runPatch(['bogusColumn' => 'x']);
|
||||
|
||||
$this->assertFalse(
|
||||
$run['result'],
|
||||
'Nothing patchable means nothing was written, and the caller must be told'
|
||||
);
|
||||
}
|
||||
}
|
||||
901
tests/Unit/app/Domain/Tickets/Services/TicketsServiceTest.php
Normal file
901
tests/Unit/app/Domain/Tickets/Services/TicketsServiceTest.php
Normal file
@@ -0,0 +1,901 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Tickets\Services;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Leantime\Core\Auth\Permissions\PermissionService;
|
||||
use Leantime\Core\Configuration\Environment as EnvironmentCore;
|
||||
use Leantime\Core\Exceptions\AuthorizationException;
|
||||
use Leantime\Core\Language as LanguageCore;
|
||||
use Leantime\Core\Support\CarbonMacros;
|
||||
use Leantime\Core\Support\DateTimeHelper;
|
||||
use Leantime\Core\UI\Template as TemplateCore;
|
||||
use Leantime\Domain\Clients\Services\Clients as ClientService;
|
||||
use Leantime\Domain\Comments\Services\Comments as CommentService;
|
||||
use Leantime\Domain\Goalcanvas\Services\Goalcanvas;
|
||||
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
|
||||
use Leantime\Domain\Sprints\Services\Sprints as SprintService;
|
||||
use Leantime\Domain\Tickets\Models\Tickets as TicketModel;
|
||||
use Leantime\Domain\Tickets\Repositories\TicketHistory;
|
||||
use Leantime\Domain\Tickets\Repositories\Tickets as TicketRepository;
|
||||
use Leantime\Domain\Tickets\Services\Tickets as TicketsService;
|
||||
use Leantime\Domain\Timesheets\Repositories\Timesheets as TimesheetRepository;
|
||||
use Leantime\Domain\Timesheets\Services\Timesheets as TimesheetService;
|
||||
use Unit\TestCase;
|
||||
|
||||
class TicketsServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
protected TicketsService $ticketsService;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Set up session values needed for DateTimeHelper
|
||||
session(['usersettings.timezone' => 'UTC']);
|
||||
session(['usersettings.language' => 'en-US']);
|
||||
session(['usersettings.date_format' => 'Y-m-d']);
|
||||
session(['usersettings.time_format' => 'H:i']);
|
||||
|
||||
// Mock Environment and bind to container for dtHelper()
|
||||
$envMock = $this->make(EnvironmentCore::class, [
|
||||
'defaultTimezone' => 'UTC',
|
||||
'language' => 'en-US',
|
||||
]);
|
||||
app()->instance(EnvironmentCore::class, $envMock);
|
||||
|
||||
// Mock Language and bind to container
|
||||
$langMock = $this->createMock(LanguageCore::class);
|
||||
$langMock->method('__')->willReturnCallback(function ($index) {
|
||||
$map = [
|
||||
'language.dateformat' => 'Y-m-d',
|
||||
'language.timeformat' => 'H:i',
|
||||
];
|
||||
|
||||
return $map[$index] ?? $index;
|
||||
});
|
||||
app()->instance(LanguageCore::class, $langMock);
|
||||
|
||||
// Register CarbonMacros for date parsing
|
||||
CarbonImmutable::mixin(new CarbonMacros('UTC', 'en-US', 'Y-m-d', 'H:i'));
|
||||
|
||||
// Create mocks for all dependencies
|
||||
$tpl = $this->make(TemplateCore::class);
|
||||
$language = $this->make(LanguageCore::class);
|
||||
$config = $this->make(EnvironmentCore::class);
|
||||
$projectRepository = $this->make(ProjectRepository::class);
|
||||
$ticketRepository = $this->make(TicketRepository::class);
|
||||
$timesheetsRepo = $this->make(TimesheetRepository::class);
|
||||
$settingsRepo = $this->make(SettingRepository::class);
|
||||
$projectService = $this->make(ProjectService::class);
|
||||
$timesheetService = $this->make(TimesheetService::class);
|
||||
$sprintService = $this->make(SprintService::class);
|
||||
$ticketHistoryRepo = $this->make(TicketHistory::class);
|
||||
$goalcanvasService = $this->make(Goalcanvas::class);
|
||||
$dateTimeHelper = $this->make(DateTimeHelper::class);
|
||||
$commentService = $this->make(CommentService::class);
|
||||
$clientService = $this->make(ClientService::class);
|
||||
|
||||
// Instantiate the service with mocked dependencies
|
||||
$this->ticketsService = new TicketsService(
|
||||
language: $language,
|
||||
ticketRepository: $ticketRepository,
|
||||
timesheetsRepo: $timesheetsRepo,
|
||||
settingsRepo: $settingsRepo,
|
||||
projectService: $projectService,
|
||||
timesheetService: $timesheetService,
|
||||
sprintService: $sprintService,
|
||||
ticketHistoryRepo: $ticketHistoryRepo,
|
||||
goalcanvasService: $goalcanvasService,
|
||||
dateTimeHelper: $dateTimeHelper,
|
||||
commentService: $commentService,
|
||||
clientService: $clientService
|
||||
);
|
||||
}
|
||||
|
||||
protected function _after()
|
||||
{
|
||||
// Clear any frozen Carbon "now" so a test that freezes it (e.g. the
|
||||
// board-summary due-this-week test) can't leak into later tests.
|
||||
CarbonImmutable::setTestNow();
|
||||
$this->ticketsService = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that timeFrom is unset when editFrom parsing fails
|
||||
*/
|
||||
public function test_prepare_ticket_dates_removes_time_from_on_parse_error()
|
||||
{
|
||||
$values = [
|
||||
'editFrom' => 'Invalid DateTime',
|
||||
'timeFrom' => '12:00',
|
||||
];
|
||||
|
||||
$result = $this->ticketsService->prepareTicketDates($values);
|
||||
|
||||
// Date should be cleared
|
||||
$this->assertEquals('', $result['editFrom']);
|
||||
|
||||
// Time field must be removed to prevent SQL error
|
||||
$this->assertArrayNotHasKey('timeFrom', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that timeTo is unset when editTo parsing fails
|
||||
* This is the primary bug from issue #3139
|
||||
*/
|
||||
public function test_prepare_ticket_dates_removes_time_to_on_parse_error()
|
||||
{
|
||||
$values = [
|
||||
'editTo' => 'Invalid DateTime',
|
||||
'timeTo' => '17:00',
|
||||
];
|
||||
|
||||
$result = $this->ticketsService->prepareTicketDates($values);
|
||||
|
||||
$this->assertEquals('', $result['editTo']);
|
||||
$this->assertArrayNotHasKey('timeTo', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* getBoardSummary should count total/unassigned/due-this-week and surface the
|
||||
* most recent modified date, working off the grouped ticket set as-is.
|
||||
*/
|
||||
public function test_get_board_summary_computes_counts_and_last_updated()
|
||||
{
|
||||
// Freeze "now" to a fixed instant (noon, well clear of a midnight/week
|
||||
// boundary) so $dueToday and getBoardSummary's weekStart/weekEnd are
|
||||
// computed from the same clock — otherwise a run straddling midnight
|
||||
// could make the due-this-week assertion flaky. Cleared in _after().
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-07-15 12:00:00', 'UTC'));
|
||||
|
||||
// getBoardSummary parses dateToFinish via parseDbDateTime() (DB tz) and
|
||||
// converts to the user tz before the "this week" compare, so the stored
|
||||
// strings must be DB-tz. Derive them from userNow()->setToDbTimezone()
|
||||
// so they round-trip user→db→user and "due today" stays stable even if
|
||||
// this test's user timezone is later moved off UTC.
|
||||
$nowUser = dtHelper()->userNow();
|
||||
$dueToday = $nowUser->setToDbTimezone()->format('Y-m-d H:i:s');
|
||||
$dueTwoMonthsAgo = $nowUser->subMonths(2)->setToDbTimezone()->format('Y-m-d H:i:s');
|
||||
$dueTwoMonthsOut = $nowUser->addMonths(2)->setToDbTimezone()->format('Y-m-d H:i:s');
|
||||
|
||||
$mk = function (mixed $editorId, ?string $due, ?string $modified) {
|
||||
$ticket = new \stdClass;
|
||||
$ticket->editorId = $editorId;
|
||||
$ticket->dateToFinish = $due;
|
||||
$ticket->modified = $modified;
|
||||
|
||||
return $ticket;
|
||||
};
|
||||
|
||||
$grouped = [
|
||||
'all' => [
|
||||
'label' => 'all',
|
||||
'items' => [
|
||||
// assigned, due today (this week), older change
|
||||
$mk(5, $dueToday, '2026-07-01 10:00:00'),
|
||||
// unassigned (empty editor), due 2 months ago (not this week), newest change
|
||||
$mk('', $dueTwoMonthsAgo, '2026-07-15 09:00:00'),
|
||||
// unassigned (zero editor), no due date set
|
||||
$mk(0, '0000-00-00 00:00:00', '2026-06-01 08:00:00'),
|
||||
// assigned, due 2 months out (beyond this week), no modified stamp
|
||||
$mk(7, $dueTwoMonthsOut, null),
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$summary = $this->ticketsService->getBoardSummary($grouped);
|
||||
|
||||
$this->assertSame(4, $summary->total);
|
||||
$this->assertSame(2, $summary->unassigned);
|
||||
$this->assertSame(1, $summary->dueThisWeek);
|
||||
$this->assertNotNull($summary->lastUpdated);
|
||||
$this->assertSame('2026-07-15 09:00:00', $summary->lastUpdated->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
/**
|
||||
* An empty board yields zeroed counts and a null last-updated.
|
||||
*/
|
||||
public function test_get_board_summary_handles_empty_board()
|
||||
{
|
||||
$summary = $this->ticketsService->getBoardSummary(['all' => ['items' => []]]);
|
||||
|
||||
$this->assertSame(0, $summary->total);
|
||||
$this->assertSame(0, $summary->unassigned);
|
||||
$this->assertSame(0, $summary->dueThisWeek);
|
||||
$this->assertNull($summary->lastUpdated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentinel date strings (0000-00-00 and 1969-12-31 — both rejected by
|
||||
* parseDbDateTime) must be skipped, not blow up the whole board summary.
|
||||
* Regression: the guard originally only filtered 0000-00-00, so a
|
||||
* 1969-12-31 stamp threw InvalidDateException and broke the header.
|
||||
*/
|
||||
public function test_get_board_summary_skips_sentinel_dates_without_throwing()
|
||||
{
|
||||
$mk = function (?string $due, ?string $modified) {
|
||||
$ticket = new \stdClass;
|
||||
$ticket->editorId = 5;
|
||||
$ticket->dateToFinish = $due;
|
||||
$ticket->modified = $modified;
|
||||
|
||||
return $ticket;
|
||||
};
|
||||
|
||||
$grouped = [
|
||||
'all' => [
|
||||
'items' => [
|
||||
$mk('1969-12-31 00:00:00', '1969-12-31 00:00:00'),
|
||||
$mk('0000-00-00 00:00:00', '0000-00-00 00:00:00'),
|
||||
// Malformed but NON-sentinel — passes isValidDateString yet
|
||||
// parseDbDateTime throws. The try/catch must swallow it.
|
||||
$mk('not a date', 'garbage-value'),
|
||||
$mk(null, null),
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$summary = $this->ticketsService->getBoardSummary($grouped);
|
||||
|
||||
$this->assertSame(4, $summary->total);
|
||||
// No valid due dates → none counted this week; no valid modified → null.
|
||||
$this->assertSame(0, $summary->dueThisWeek);
|
||||
$this->assertNull($summary->lastUpdated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that timeToFinish is unset when dateToFinish parsing fails
|
||||
*/
|
||||
public function test_prepare_ticket_dates_removes_time_to_finish_on_parse_error()
|
||||
{
|
||||
$values = [
|
||||
'dateToFinish' => 'Invalid DateTime',
|
||||
'timeToFinish' => '23:59',
|
||||
];
|
||||
|
||||
$result = $this->ticketsService->prepareTicketDates($values);
|
||||
|
||||
$this->assertEquals('', $result['dateToFinish']);
|
||||
$this->assertArrayNotHasKey('timeToFinish', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that valid dates work correctly and time fields are removed
|
||||
*/
|
||||
public function test_prepare_ticket_dates_successfully_parses_valid_dates()
|
||||
{
|
||||
$values = [
|
||||
'editFrom' => '2025-11-30',
|
||||
'timeFrom' => '09:00',
|
||||
'editTo' => '2025-11-30',
|
||||
'timeTo' => '17:00',
|
||||
];
|
||||
|
||||
$result = $this->ticketsService->prepareTicketDates($values);
|
||||
|
||||
// Dates should be formatted for DB (not empty)
|
||||
$this->assertNotEmpty($result['editFrom']);
|
||||
$this->assertNotEmpty($result['editTo']);
|
||||
|
||||
// Time fields should be removed after successful parsing
|
||||
$this->assertArrayNotHasKey('timeFrom', $result);
|
||||
$this->assertArrayNotHasKey('timeTo', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* normalizeRoadmapParams defaults the type to milestone when not provided.
|
||||
*/
|
||||
public function test_normalize_roadmap_params_defaults_type_to_milestone()
|
||||
{
|
||||
$result = $this->ticketsService->normalizeRoadmapParams([]);
|
||||
|
||||
$this->assertEquals('milestone', $result['type']);
|
||||
$this->assertArrayNotHasKey('excludeType', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* normalizeRoadmapParams keeps an explicitly provided type.
|
||||
*/
|
||||
public function test_normalize_roadmap_params_keeps_provided_type()
|
||||
{
|
||||
$result = $this->ticketsService->normalizeRoadmapParams(['type' => 'task']);
|
||||
|
||||
$this->assertEquals('task', $result['type']);
|
||||
}
|
||||
|
||||
/**
|
||||
* normalizeRoadmapParams clears type and excludeType when showing tasks.
|
||||
*/
|
||||
public function test_normalize_roadmap_params_clears_filters_when_showing_tasks()
|
||||
{
|
||||
$result = $this->ticketsService->normalizeRoadmapParams(['showTasks' => 'true']);
|
||||
|
||||
$this->assertEquals('', $result['type']);
|
||||
$this->assertEquals('', $result['excludeType']);
|
||||
}
|
||||
|
||||
/**
|
||||
* getMilestonesOverviewSearchCriteria defaults the status to not_done when none provided.
|
||||
*/
|
||||
public function test_overview_search_criteria_defaults_status_to_not_done()
|
||||
{
|
||||
$result = $this->ticketsService->getMilestonesOverviewSearchCriteria([]);
|
||||
|
||||
$this->assertEquals('not_done', $result['status']);
|
||||
}
|
||||
|
||||
/**
|
||||
* getMilestonesOverviewSearchCriteria respects an explicitly selected status.
|
||||
*/
|
||||
public function test_overview_search_criteria_respects_selected_status()
|
||||
{
|
||||
$result = $this->ticketsService->getMilestonesOverviewSearchCriteria(['status' => '3']);
|
||||
|
||||
$this->assertEquals('3', $result['status']);
|
||||
}
|
||||
|
||||
/**
|
||||
* getNewMilestone returns a default milestone with status 3 and a one-week edit window.
|
||||
*/
|
||||
public function test_get_new_milestone_has_default_status_and_one_week_window()
|
||||
{
|
||||
$milestone = $this->ticketsService->getNewMilestone();
|
||||
|
||||
$this->assertEquals(3, $milestone->status);
|
||||
|
||||
$expectedFrom = CarbonImmutable::now()->format('Y-m-d');
|
||||
$expectedTo = CarbonImmutable::now()->addWeek()->format('Y-m-d');
|
||||
|
||||
$this->assertEquals($expectedFrom, $milestone->editFrom);
|
||||
$this->assertEquals($expectedTo, $milestone->editTo);
|
||||
}
|
||||
|
||||
/**
|
||||
* getClientNameById returns an empty string when no client id is given.
|
||||
*/
|
||||
public function test_get_client_name_by_id_returns_empty_for_zero_id()
|
||||
{
|
||||
$this->assertEquals('', $this->ticketsService->getClientNameById(0));
|
||||
}
|
||||
|
||||
/**
|
||||
* getClientNameById resolves the name from the clients service.
|
||||
*/
|
||||
public function test_get_client_name_by_id_resolves_name()
|
||||
{
|
||||
$service = $this->buildServiceWithClientService(
|
||||
$this->make(ClientService::class, [
|
||||
'get' => fn () => ['id' => 5, 'name' => 'Acme Inc'],
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals('Acme Inc', $service->getClientNameById(5));
|
||||
}
|
||||
|
||||
/**
|
||||
* getClientNameById returns an empty string when the client is not found.
|
||||
*/
|
||||
public function test_get_client_name_by_id_returns_empty_when_not_found()
|
||||
{
|
||||
$service = $this->buildServiceWithClientService(
|
||||
$this->make(ClientService::class, [
|
||||
'get' => fn () => false,
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals('', $service->getClientNameById(99));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a TicketsService using the default mocks but with a specific
|
||||
* ClientService instance, so client-name resolution can be asserted.
|
||||
*/
|
||||
private function buildServiceWithClientService(ClientService $clientService): TicketsService
|
||||
{
|
||||
return new TicketsService(
|
||||
language: $this->make(LanguageCore::class),
|
||||
ticketRepository: $this->make(TicketRepository::class),
|
||||
timesheetsRepo: $this->make(TimesheetRepository::class),
|
||||
settingsRepo: $this->make(SettingRepository::class),
|
||||
projectService: $this->make(ProjectService::class),
|
||||
timesheetService: $this->make(TimesheetService::class),
|
||||
sprintService: $this->make(SprintService::class),
|
||||
ticketHistoryRepo: $this->make(TicketHistory::class),
|
||||
goalcanvasService: $this->make(Goalcanvas::class),
|
||||
dateTimeHelper: $this->make(DateTimeHelper::class),
|
||||
commentService: $this->make(CommentService::class),
|
||||
clientService: $clientService
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a TicketsService using the default mocks but with a specific
|
||||
* TicketRepository instance, so collaborator enrichment can be asserted.
|
||||
*/
|
||||
private function buildServiceWithTicketRepository(TicketRepository $ticketRepository): TicketsService
|
||||
{
|
||||
return new TicketsService(
|
||||
language: $this->make(LanguageCore::class),
|
||||
ticketRepository: $ticketRepository,
|
||||
timesheetsRepo: $this->make(TimesheetRepository::class),
|
||||
settingsRepo: $this->make(SettingRepository::class),
|
||||
projectService: $this->make(ProjectService::class),
|
||||
timesheetService: $this->make(TimesheetService::class),
|
||||
sprintService: $this->make(SprintService::class),
|
||||
ticketHistoryRepo: $this->make(TicketHistory::class),
|
||||
goalcanvasService: $this->make(Goalcanvas::class),
|
||||
dateTimeHelper: $this->make(DateTimeHelper::class),
|
||||
commentService: $this->make(CommentService::class),
|
||||
clientService: $this->make(ClientService::class)
|
||||
);
|
||||
}
|
||||
|
||||
public function test_get_all_open_user_tickets_excludes_closed_projects_at_query_level(): void
|
||||
{
|
||||
session(['userdata' => ['id' => 1, 'role' => 'admin']]);
|
||||
|
||||
// Closed-project (state === -1) exclusion lives in the SQL layer now, so
|
||||
// the service's contract is simply: ask simpleTicketQuery to exclude
|
||||
// them. Capture the flag it passes.
|
||||
$captured = null;
|
||||
$ticketRepository = $this->make(TicketRepository::class, [
|
||||
'simpleTicketQuery' => function ($userId, $projectId, $types = [], $excludeClosedProjects = false) use (&$captured) {
|
||||
$captured = $excludeClosedProjects;
|
||||
|
||||
return [];
|
||||
},
|
||||
]);
|
||||
|
||||
$service = $this->buildServiceWithTicketRepository($ticketRepository);
|
||||
$service->getAllOpenUserTickets(1);
|
||||
|
||||
$this->assertTrue($captured, 'getAllOpenUserTickets must exclude closed-project tickets at the query level');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// JSON-RPC authorization gates (RPC has no controller-level role gate, so
|
||||
// the @api entry methods must self-authorize).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public function test_patch_ticket_is_denied_for_non_editor(): void
|
||||
{
|
||||
session(['userdata' => ['id' => 1, 'role' => 'readonly']]);
|
||||
|
||||
// patchTicket loads the ticket, then authorizes tickets.edit against its project via
|
||||
// the permission engine. Stub getTicket so it resolves, and inject a denying engine.
|
||||
$service = $this->construct(
|
||||
TicketsService::class,
|
||||
[
|
||||
$this->make(LanguageCore::class),
|
||||
$this->make(TicketRepository::class),
|
||||
$this->make(TimesheetRepository::class),
|
||||
$this->make(SettingRepository::class),
|
||||
$this->make(ProjectService::class),
|
||||
$this->make(TimesheetService::class),
|
||||
$this->make(SprintService::class),
|
||||
$this->make(TicketHistory::class),
|
||||
$this->make(Goalcanvas::class),
|
||||
$this->make(DateTimeHelper::class),
|
||||
$this->make(CommentService::class),
|
||||
$this->make(ClientService::class),
|
||||
],
|
||||
['getTicket' => fn () => $this->make(TicketModel::class, ['id' => 5, 'projectId' => 9])],
|
||||
);
|
||||
|
||||
$service->setPermissionService($this->make(PermissionService::class, [
|
||||
'authorize' => function (): void {
|
||||
throw new AuthorizationException;
|
||||
},
|
||||
]));
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$service->patchTicket(5, ['status' => 3]);
|
||||
}
|
||||
|
||||
public function test_sort_tickets_is_denied_for_non_editor(): void
|
||||
{
|
||||
session(['userdata' => ['id' => 1, 'role' => 'readonly']]);
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$this->ticketsService->sortTickets(['5' => 1]);
|
||||
}
|
||||
|
||||
public function test_status_and_sorting_is_denied_for_non_editor(): void
|
||||
{
|
||||
session(['userdata' => ['id' => 1, 'role' => 'readonly']]);
|
||||
|
||||
$this->assertFalse($this->ticketsService->updateTicketStatusAndSorting(['3' => 'ticket[]=5'], null));
|
||||
}
|
||||
|
||||
public function test_quick_add_ticket_is_denied_without_create_permission(): void
|
||||
{
|
||||
session(['userdata' => ['id' => 1, 'role' => 'readonly']]);
|
||||
|
||||
// quickAddTicket resolves the project from its params, then authorizes tickets.create
|
||||
// through the engine before doing any work. This was one of the RPC holes: any
|
||||
// authenticated caller could create tickets. A denying engine must make it throw.
|
||||
$this->ticketsService->setPermissionService($this->make(PermissionService::class, [
|
||||
'authorize' => function (): void {
|
||||
throw new AuthorizationException;
|
||||
},
|
||||
]));
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$this->ticketsService->quickAddTicket(['headline' => 'New task', 'projectId' => 9]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Collaborator enrichment for grouped ticket views (list/kanban + widget)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* enrichGroupedTicketsWithCollaborators adds metadata to 'items' groups (list/kanban views).
|
||||
*/
|
||||
public function test_enrich_grouped_tickets_with_collaborators_items_key()
|
||||
{
|
||||
$service = $this->buildServiceWithTicketRepository($this->make(TicketRepository::class, [
|
||||
'getCollaboratorsByTicketIds' => fn ($ids) => [
|
||||
10 => [100, 200],
|
||||
11 => [300],
|
||||
],
|
||||
]));
|
||||
|
||||
$groupedTickets = [
|
||||
'group1' => [
|
||||
'items' => [
|
||||
['id' => 10, 'editorId' => 100, 'headline' => 'Task A'],
|
||||
['id' => 11, 'editorId' => 0, 'headline' => 'Task B'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$method = new \ReflectionMethod($service, 'enrichGroupedTicketsWithCollaborators');
|
||||
$method->setAccessible(true);
|
||||
$result = $method->invoke($service, $groupedTickets);
|
||||
|
||||
// Ticket 10: editorId=100 is excluded from collaborator list, leaving only [200]
|
||||
$this->assertEquals([200], $result['group1']['items'][0]['collaborators']);
|
||||
$this->assertEquals([200], $result['group1']['items'][0]['collaboratorPreview']);
|
||||
$this->assertEquals(1, $result['group1']['items'][0]['collaboratorCount']);
|
||||
$this->assertEquals(0, $result['group1']['items'][0]['collaboratorOverflow']);
|
||||
|
||||
// Ticket 11: no editorId filter, so [300] stays
|
||||
$this->assertEquals([300], $result['group1']['items'][1]['collaborators']);
|
||||
$this->assertEquals(1, $result['group1']['items'][1]['collaboratorCount']);
|
||||
}
|
||||
|
||||
/**
|
||||
* enrichGroupedTicketsWithCollaborators supports the 'tickets' key (ToDoWidget views).
|
||||
*/
|
||||
public function test_enrich_grouped_tickets_with_collaborators_tickets_key()
|
||||
{
|
||||
$service = $this->buildServiceWithTicketRepository($this->make(TicketRepository::class, [
|
||||
'getCollaboratorsByTicketIds' => fn ($ids) => [
|
||||
20 => [400, 500, 600],
|
||||
],
|
||||
]));
|
||||
|
||||
$groupedTickets = [
|
||||
'thisWeek' => [
|
||||
'labelName' => 'subtitles.due_this_week',
|
||||
'tickets' => [
|
||||
['id' => 20, 'editorId' => 400, 'headline' => 'Widget Task'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$method = new \ReflectionMethod($service, 'enrichGroupedTicketsWithCollaborators');
|
||||
$method->setAccessible(true);
|
||||
$result = $method->invoke($service, $groupedTickets);
|
||||
|
||||
// editorId=400 excluded, leaving [500, 600]
|
||||
$this->assertEquals([500, 600], $result['thisWeek']['tickets'][0]['collaborators']);
|
||||
$this->assertEquals([500, 600], $result['thisWeek']['tickets'][0]['collaboratorPreview']);
|
||||
$this->assertEquals(2, $result['thisWeek']['tickets'][0]['collaboratorCount']);
|
||||
$this->assertEquals(0, $result['thisWeek']['tickets'][0]['collaboratorOverflow']);
|
||||
}
|
||||
|
||||
/**
|
||||
* enrichGroupedTicketsWithCollaborators reports overflow when more than 2 collaborators exist.
|
||||
*/
|
||||
public function test_enrich_grouped_tickets_collaborator_overflow()
|
||||
{
|
||||
$service = $this->buildServiceWithTicketRepository($this->make(TicketRepository::class, [
|
||||
'getCollaboratorsByTicketIds' => fn ($ids) => [
|
||||
30 => [101, 102, 103, 104, 105],
|
||||
],
|
||||
]));
|
||||
|
||||
$groupedTickets = [
|
||||
'group1' => [
|
||||
'items' => [
|
||||
['id' => 30, 'editorId' => 0, 'headline' => 'Many collaborators'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$method = new \ReflectionMethod($service, 'enrichGroupedTicketsWithCollaborators');
|
||||
$method->setAccessible(true);
|
||||
$result = $method->invoke($service, $groupedTickets);
|
||||
|
||||
$this->assertEquals([101, 102, 103, 104, 105], $result['group1']['items'][0]['collaborators']);
|
||||
$this->assertEquals([101, 102], $result['group1']['items'][0]['collaboratorPreview']);
|
||||
$this->assertEquals(5, $result['group1']['items'][0]['collaboratorCount']);
|
||||
$this->assertEquals(3, $result['group1']['items'][0]['collaboratorOverflow']);
|
||||
}
|
||||
|
||||
/**
|
||||
* getAllMilestones() accepts a projects-only criteria array (program/cross-project boards):
|
||||
* it must query the repository and must not warn on the absent 'currentProject' key.
|
||||
*/
|
||||
public function test_get_all_milestones_scopes_by_projects_without_current_project()
|
||||
{
|
||||
$captured = null;
|
||||
$service = $this->buildServiceWithTicketRepository($this->make(TicketRepository::class, [
|
||||
'getAllMilestones' => function ($searchCriteria, $sortBy) use (&$captured) {
|
||||
$captured = $searchCriteria;
|
||||
|
||||
return [];
|
||||
},
|
||||
]));
|
||||
|
||||
// Projects-only criteria — no 'currentProject' key at all (the program board shape).
|
||||
$result = $service->getAllMilestones(['type' => 'milestone', 'projects' => '5,7']);
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$this->assertNotNull($captured, 'repository getAllMilestones should be queried for a projects-only scope');
|
||||
$this->assertSame('5,7', $captured['projects']);
|
||||
$this->assertArrayNotHasKey('currentProject', $captured);
|
||||
}
|
||||
|
||||
/**
|
||||
* getAllMilestones() returns an empty array and does NOT query the repository when the
|
||||
* criteria are not project-scoped (neither a currentProject id nor a projects set).
|
||||
*/
|
||||
public function test_get_all_milestones_unscoped_returns_empty_and_skips_repository()
|
||||
{
|
||||
$called = false;
|
||||
$service = $this->buildServiceWithTicketRepository($this->make(TicketRepository::class, [
|
||||
'getAllMilestones' => function () use (&$called) {
|
||||
$called = true;
|
||||
|
||||
return [];
|
||||
},
|
||||
]));
|
||||
|
||||
$result = $service->getAllMilestones(['type' => 'milestone']);
|
||||
|
||||
$this->assertSame([], $result);
|
||||
$this->assertFalse($called, 'repository should not be queried when criteria are not project-scoped');
|
||||
}
|
||||
|
||||
/**
|
||||
* getMyClosedTicketsForRange: a reversed range is normalized (earlier date
|
||||
* first), only status changes INTO the ticket's current DONE status count,
|
||||
* and a ticket completed more than once keeps its latest completion.
|
||||
*/
|
||||
public function test_closed_tickets_range_normalizes_swapped_range_and_keeps_latest_completion(): void
|
||||
{
|
||||
session(['userdata' => ['id' => 1]]);
|
||||
$capturedFrom = null;
|
||||
$capturedTo = null;
|
||||
|
||||
$ticketRepository = $this->make(TicketRepository::class, [
|
||||
'simpleTicketQuery' => fn (...$args) => [
|
||||
['id' => 10, 'type' => 'task', 'projectId' => 5, 'status' => 0],
|
||||
['id' => 20, 'type' => 'task', 'projectId' => 5, 'status' => 0],
|
||||
],
|
||||
'getStateLabels' => fn (...$args) => [
|
||||
0 => ['statusType' => 'DONE', 'name' => 'Done', 'class' => ''],
|
||||
3 => ['statusType' => 'INPROGRESS', 'name' => 'In Progress', 'class' => ''],
|
||||
],
|
||||
'getStatusChangeEvents' => function ($ids, $from, $to) use (&$capturedFrom, &$capturedTo) {
|
||||
$capturedFrom = $from;
|
||||
$capturedTo = $to;
|
||||
|
||||
return [
|
||||
['ticketId' => 10, 'changeValue' => 0, 'dateModified' => '2026-07-10 10:00:00'],
|
||||
['ticketId' => 10, 'changeValue' => 0, 'dateModified' => '2026-07-09 09:00:00'],
|
||||
['ticketId' => 20, 'changeValue' => 3, 'dateModified' => '2026-07-10 10:00:00'],
|
||||
];
|
||||
},
|
||||
]);
|
||||
|
||||
$service = $this->buildServiceWithTicketRepository($ticketRepository);
|
||||
|
||||
// Reversed range on purpose.
|
||||
$result = $service->getMyClosedTicketsForRange(1, '2026-07-12', '2026-07-05');
|
||||
|
||||
$this->assertEquals('2026-07-05', $capturedFrom, 'range should be normalized earliest-first');
|
||||
$this->assertEquals('2026-07-12', $capturedTo);
|
||||
|
||||
// 20's only event was a change to a non-DONE status → excluded. 10 kept
|
||||
// to its latest completion (newest event wins).
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertEquals(10, $result[0]['id']);
|
||||
$this->assertEquals('2026-07-10 10:00:00', $result[0]['dateClosed']);
|
||||
}
|
||||
|
||||
public function test_closed_tickets_range_forces_session_user_for_non_admin(): void
|
||||
{
|
||||
// Non-admin session user (no admin role granted).
|
||||
session(['userdata' => ['id' => 1]]);
|
||||
$capturedUserId = 'unset';
|
||||
|
||||
$ticketRepository = $this->make(TicketRepository::class, [
|
||||
'simpleTicketQuery' => function (...$args) use (&$capturedUserId) {
|
||||
$capturedUserId = $args[0] ?? null;
|
||||
|
||||
return []; // no done tickets — the asserted-on value is the userId
|
||||
},
|
||||
'getStateLabels' => fn (...$args) => [],
|
||||
]);
|
||||
|
||||
$service = $this->buildServiceWithTicketRepository($ticketRepository);
|
||||
|
||||
// Caller supplies SOMEONE ELSE's id — the IDOR guard must force it back
|
||||
// to the session user before any query runs.
|
||||
$service->getMyClosedTicketsForRange(999, '2026-07-01', '2026-07-10');
|
||||
|
||||
$this->assertSame(1, $capturedUserId, 'a non-admin must not read another user\'s closures — userId is forced to the session user');
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a service with a specific ticket repository AND project service —
|
||||
* the two deps getMyCommentedTicketsForRange exercises.
|
||||
*/
|
||||
private function buildServiceWithTicketRepoAndProjectService(
|
||||
TicketRepository $ticketRepository,
|
||||
ProjectService $projectService
|
||||
): TicketsService {
|
||||
return new TicketsService(
|
||||
language: $this->make(LanguageCore::class),
|
||||
ticketRepository: $ticketRepository,
|
||||
timesheetsRepo: $this->make(TimesheetRepository::class),
|
||||
settingsRepo: $this->make(SettingRepository::class),
|
||||
projectService: $projectService,
|
||||
timesheetService: $this->make(TimesheetService::class),
|
||||
sprintService: $this->make(SprintService::class),
|
||||
ticketHistoryRepo: $this->make(TicketHistory::class),
|
||||
goalcanvasService: $this->make(Goalcanvas::class),
|
||||
dateTimeHelper: $this->make(DateTimeHelper::class),
|
||||
commentService: $this->make(CommentService::class),
|
||||
clientService: $this->make(ClientService::class)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported = tickets you commented on within accessible projects, minus
|
||||
* the ones you're the editor of. Editor-owned tickets are dropped; tickets
|
||||
* outside the project-scoped fetch never appear.
|
||||
*/
|
||||
public function test_commented_tickets_range_excludes_owned_and_scopes_by_projects(): void
|
||||
{
|
||||
session(['userdata' => ['id' => 1]]);
|
||||
|
||||
$ticketRepository = $this->make(TicketRepository::class, [
|
||||
'getTicketIdsCommentedByUser' => fn (...$args) => [10, 20, 30],
|
||||
// Project-scoped fetch only returns 10 + 20 (30 is outside access).
|
||||
'getTicketsByIdsWithinProjects' => fn (...$args) => [
|
||||
['id' => 10, 'headline' => 'A', 'editorId' => '99', 'projectId' => 5, 'projectName' => 'P'],
|
||||
['id' => 20, 'headline' => 'B', 'editorId' => '1', 'projectId' => 5, 'projectName' => 'P'],
|
||||
],
|
||||
]);
|
||||
$projectService = $this->make(ProjectService::class, [
|
||||
'getProjectsUserHasAccessTo' => fn (...$args) => [['id' => 5], ['id' => 7]],
|
||||
]);
|
||||
|
||||
$service = $this->buildServiceWithTicketRepoAndProjectService($ticketRepository, $projectService);
|
||||
|
||||
$result = $service->getMyCommentedTicketsForRange(1, '2026-07-01', '2026-07-07');
|
||||
|
||||
// 20 is the user's own (editorId === 1) → excluded; 30 wasn't returned
|
||||
// by the project-scoped fetch → absent. Only 10 remains.
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertEquals(10, $result[0]['id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* No accessible projects → empty, without ever fetching tickets.
|
||||
*/
|
||||
public function test_commented_tickets_range_empty_without_project_access(): void
|
||||
{
|
||||
session(['userdata' => ['id' => 1]]);
|
||||
|
||||
$ticketRepository = $this->make(TicketRepository::class, [
|
||||
'getTicketIdsCommentedByUser' => fn (...$args) => [10],
|
||||
'getTicketsByIdsWithinProjects' => fn (...$args) => [['id' => 10, 'editorId' => '99']],
|
||||
]);
|
||||
$projectService = $this->make(ProjectService::class, [
|
||||
'getProjectsUserHasAccessTo' => fn (...$args) => false,
|
||||
]);
|
||||
|
||||
$service = $this->buildServiceWithTicketRepoAndProjectService($ticketRepository, $projectService);
|
||||
|
||||
$this->assertSame([], $service->getMyCommentedTicketsForRange(1, '2026-07-01', '2026-07-07'));
|
||||
}
|
||||
|
||||
public function test_commented_tickets_range_forces_session_user_for_non_admin(): void
|
||||
{
|
||||
session(['userdata' => ['id' => 1]]);
|
||||
$capturedUserId = 'unset';
|
||||
|
||||
$ticketRepository = $this->make(TicketRepository::class, [
|
||||
'getTicketIdsCommentedByUser' => function (...$args) use (&$capturedUserId) {
|
||||
$capturedUserId = $args[0] ?? null;
|
||||
|
||||
return [];
|
||||
},
|
||||
]);
|
||||
$projectService = $this->make(ProjectService::class, [
|
||||
'getProjectsUserHasAccessTo' => fn (...$args) => [['id' => 5]],
|
||||
]);
|
||||
|
||||
$service = $this->buildServiceWithTicketRepoAndProjectService($ticketRepository, $projectService);
|
||||
|
||||
// Non-admin supplies someone else's id — forced back to the session user.
|
||||
$service->getMyCommentedTicketsForRange(999, '2026-07-01', '2026-07-07');
|
||||
|
||||
$this->assertSame(1, $capturedUserId, 'a non-admin must not read another user\'s comment activity — userId forced to session user');
|
||||
}
|
||||
|
||||
public function test_commented_tickets_range_normalizes_reversed_range(): void
|
||||
{
|
||||
session(['userdata' => ['id' => 1]]);
|
||||
$capturedFrom = null;
|
||||
$capturedTo = null;
|
||||
|
||||
$ticketRepository = $this->make(TicketRepository::class, [
|
||||
'getTicketIdsCommentedByUser' => function (...$args) use (&$capturedFrom, &$capturedTo) {
|
||||
$capturedFrom = $args[1] ?? null;
|
||||
$capturedTo = $args[2] ?? null;
|
||||
|
||||
return [];
|
||||
},
|
||||
]);
|
||||
$projectService = $this->make(ProjectService::class, [
|
||||
'getProjectsUserHasAccessTo' => fn (...$args) => [['id' => 5]],
|
||||
]);
|
||||
|
||||
$service = $this->buildServiceWithTicketRepoAndProjectService($ticketRepository, $projectService);
|
||||
|
||||
// Reversed on purpose — must be swapped earliest-first before the query.
|
||||
$service->getMyCommentedTicketsForRange(1, '2026-07-12', '2026-07-05');
|
||||
|
||||
$this->assertSame('2026-07-05', $capturedFrom, 'range normalized earliest-first');
|
||||
$this->assertSame('2026-07-12', $capturedTo);
|
||||
}
|
||||
|
||||
public function test_commented_tickets_range_short_circuits_when_no_comments(): void
|
||||
{
|
||||
session(['userdata' => ['id' => 1]]);
|
||||
$fetchCalled = false;
|
||||
|
||||
$ticketRepository = $this->make(TicketRepository::class, [
|
||||
'getTicketIdsCommentedByUser' => fn (...$args) => [], // nothing commented
|
||||
'getTicketsByIdsWithinProjects' => function (...$args) use (&$fetchCalled) {
|
||||
$fetchCalled = true;
|
||||
|
||||
return [];
|
||||
},
|
||||
]);
|
||||
$projectService = $this->make(ProjectService::class, [
|
||||
'getProjectsUserHasAccessTo' => fn (...$args) => [['id' => 5]],
|
||||
]);
|
||||
|
||||
$service = $this->buildServiceWithTicketRepoAndProjectService($ticketRepository, $projectService);
|
||||
|
||||
$result = $service->getMyCommentedTicketsForRange(1, '2026-07-01', '2026-07-07');
|
||||
|
||||
$this->assertSame([], $result);
|
||||
$this->assertFalse($fetchCalled, 'an empty commented set must short-circuit before the ticket fetch');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Timesheets\Services;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Leantime\Core\Auth\Permissions\PermissionService;
|
||||
use Leantime\Core\Configuration\Environment as EnvironmentCore;
|
||||
use Leantime\Core\Exceptions\AuthorizationException;
|
||||
use Leantime\Core\Language as LanguageCore;
|
||||
use Leantime\Core\Support\CarbonMacros;
|
||||
use Leantime\Domain\Tickets\Repositories\Tickets as TicketRepository;
|
||||
use Leantime\Domain\Timesheets\Permissions\TimesheetsPermissions;
|
||||
use Leantime\Domain\Timesheets\Repositories\Timesheets as TimesheetRepository;
|
||||
use Leantime\Domain\Timesheets\Services\Timesheets as TimesheetService;
|
||||
use Leantime\Domain\Users\Repositories\Users as UserRepository;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the Timesheets service helpers extracted during the
|
||||
* thin-controller refactor (getUsersTickets, validateAndSaveTime,
|
||||
* resolveShowAllTicketFilter, getWeeklyTimesheetsWithTicketIds).
|
||||
*/
|
||||
class TimesheetsServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Session values required by dtHelper() and Auth role checks.
|
||||
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);
|
||||
|
||||
$langMock = $this->createMock(LanguageCore::class);
|
||||
$langMock->method('__')->willReturnCallback(function ($index) {
|
||||
$map = [
|
||||
'language.dateformat' => 'Y-m-d',
|
||||
'language.timeformat' => 'H:i',
|
||||
];
|
||||
|
||||
return $map[$index] ?? $index;
|
||||
});
|
||||
app()->instance(LanguageCore::class, $langMock);
|
||||
|
||||
CarbonImmutable::mixin(new CarbonMacros('UTC', 'en-US', 'Y-m-d', 'H:i'));
|
||||
}
|
||||
|
||||
/** Permission stub that grants everything (default for the non-authz helper tests). */
|
||||
private function allowingPermissions(): PermissionService
|
||||
{
|
||||
return $this->make(PermissionService::class, [
|
||||
'authorize' => fn () => null,
|
||||
'currentUserCan' => fn () => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission stub that grants a specific allow-list of keys (others denied). Used to model a
|
||||
* non-manager (editor): timesheets.view/create/edit/delete granted, timesheets.manage denied.
|
||||
*
|
||||
* @param array<int, string> $granted
|
||||
*/
|
||||
private function permissionsGranting(array $granted): PermissionService
|
||||
{
|
||||
return $this->make(PermissionService::class, [
|
||||
'authorize' => function (string $key) use ($granted): void {
|
||||
if (! in_array($key, $granted, true)) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
},
|
||||
'currentUserCan' => fn (string $key) => in_array($key, $granted, true),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a real Timesheets service with each dependency stubbable and a permission service
|
||||
* (defaults to allow-all).
|
||||
*/
|
||||
private function makeService(
|
||||
?TimesheetRepository $timesheetsRepo = null,
|
||||
?UserRepository $userRepo = null,
|
||||
?TicketRepository $ticketRepo = null,
|
||||
?PermissionService $perms = null,
|
||||
): TimesheetService {
|
||||
$service = new TimesheetService(
|
||||
$timesheetsRepo ?? $this->make(TimesheetRepository::class),
|
||||
$userRepo ?? $this->make(UserRepository::class),
|
||||
$ticketRepo ?? $this->make(TicketRepository::class),
|
||||
);
|
||||
$service->setPermissionService($perms ?? $this->allowingPermissions());
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
// Non-manager (editor) verb set: own-time keys, but NOT timesheets.manage.
|
||||
private const EDITOR_KEYS = [
|
||||
TimesheetsPermissions::VIEW,
|
||||
TimesheetsPermissions::CREATE,
|
||||
TimesheetsPermissions::EDIT,
|
||||
TimesheetsPermissions::DELETE,
|
||||
];
|
||||
|
||||
public function test_get_users_tickets_normalizes_false_to_empty_array(): void
|
||||
{
|
||||
$ticketRepo = $this->make(TicketRepository::class, [
|
||||
'getUsersTickets' => fn () => false,
|
||||
]);
|
||||
|
||||
$result = $this->makeService(ticketRepo: $ticketRepo)->getUsersTickets(1, -1);
|
||||
|
||||
$this->assertSame([], $result);
|
||||
}
|
||||
|
||||
public function test_get_users_tickets_passes_through_array_result(): void
|
||||
{
|
||||
$tickets = [['id' => 5], ['id' => 9]];
|
||||
$ticketRepo = $this->make(TicketRepository::class, [
|
||||
'getUsersTickets' => fn () => $tickets,
|
||||
]);
|
||||
|
||||
$result = $this->makeService(ticketRepo: $ticketRepo)->getUsersTickets(1, -1);
|
||||
|
||||
$this->assertSame($tickets, $result);
|
||||
}
|
||||
|
||||
public function test_validate_and_save_time_returns_no_ticket_when_ticket_missing(): void
|
||||
{
|
||||
$addCalls = 0;
|
||||
$repo = $this->make(TimesheetRepository::class, [
|
||||
'addTime' => function () use (&$addCalls) {
|
||||
$addCalls++;
|
||||
},
|
||||
]);
|
||||
|
||||
$values = $this->makeService(timesheetsRepo: $repo)->getDefaultTimeValues();
|
||||
$status = $this->makeService(timesheetsRepo: $repo)->validateAndSaveTime($values);
|
||||
|
||||
$this->assertSame('NO_TICKET', $status);
|
||||
$this->assertSame(0, $addCalls, 'Invalid time must never reach the repository');
|
||||
}
|
||||
|
||||
public function test_validate_and_save_time_returns_no_kind_when_kind_missing(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
$values = $service->getDefaultTimeValues();
|
||||
$values['ticket'] = 3;
|
||||
$values['project'] = 2;
|
||||
|
||||
$this->assertSame('NO_KIND', $service->validateAndSaveTime($values));
|
||||
}
|
||||
|
||||
public function test_validate_and_save_time_returns_no_date_when_date_missing(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
$values = $service->getDefaultTimeValues();
|
||||
$values['ticket'] = 3;
|
||||
$values['project'] = 2;
|
||||
$values['kind'] = 'DEVELOPMENT';
|
||||
|
||||
$this->assertSame('NO_DATE', $service->validateAndSaveTime($values));
|
||||
}
|
||||
|
||||
public function test_validate_and_save_time_returns_no_hours_when_hours_invalid(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
$values = $service->getDefaultTimeValues();
|
||||
$values['ticket'] = 3;
|
||||
$values['project'] = 2;
|
||||
$values['kind'] = 'DEVELOPMENT';
|
||||
$values['date'] = '2026-05-29 00:00:00';
|
||||
$values['hours'] = 0;
|
||||
|
||||
$this->assertSame('NO_HOURS', $service->validateAndSaveTime($values));
|
||||
}
|
||||
|
||||
public function test_resolve_ticket_filter_returns_minus_one_when_no_filters(): void
|
||||
{
|
||||
$this->assertSame('-1', $this->makeService()->resolveShowAllTicketFilter(-1, -1, null));
|
||||
}
|
||||
|
||||
public function test_resolve_ticket_filter_keeps_ticket_when_project_matches(): void
|
||||
{
|
||||
// Project 7 selected, ticket on project 7 -> keep the ticket filter.
|
||||
$this->assertSame('42', $this->makeService()->resolveShowAllTicketFilter(7, '42', 7));
|
||||
}
|
||||
|
||||
public function test_resolve_ticket_filter_collapses_on_project_mismatch(): void
|
||||
{
|
||||
// Ticket belongs to project 3 but filter is project 7 -> mismatch -> '-1'.
|
||||
$this->assertSame('-1', $this->makeService()->resolveShowAllTicketFilter(7, '42', 3));
|
||||
}
|
||||
|
||||
public function test_resolve_ticket_filter_collapses_when_no_project_selected(): void
|
||||
{
|
||||
// No project selected (-1) but a ticket filter set -> '-1'.
|
||||
$this->assertSame('-1', $this->makeService()->resolveShowAllTicketFilter(-1, '42', null));
|
||||
}
|
||||
|
||||
public function test_resolve_ticket_filter_ignores_missing_ticket_project(): void
|
||||
{
|
||||
// Ticket not accessible (null project id) -> no mismatch, keep ticket filter.
|
||||
$this->assertSame('42', $this->makeService()->resolveShowAllTicketFilter(7, '42', null));
|
||||
}
|
||||
|
||||
public function test_get_weekly_timesheets_with_ticket_ids_derives_existing_ids(): void
|
||||
{
|
||||
$fromDate = dtHelper()->userNow()->startOfWeek()->setToDbTimezone();
|
||||
$workDate = $fromDate->format('Y-m-d H:i:s');
|
||||
|
||||
$rows = [
|
||||
[
|
||||
'ticketId' => 11,
|
||||
'kind' => 'DEVELOPMENT',
|
||||
'clientName' => 'Acme',
|
||||
'name' => 'Project A',
|
||||
'headline' => 'Task A',
|
||||
'workDate' => $workDate,
|
||||
'hours' => 2,
|
||||
'description' => 'work',
|
||||
],
|
||||
[
|
||||
'ticketId' => 22,
|
||||
'kind' => 'TESTING',
|
||||
'clientName' => 'Acme',
|
||||
'name' => 'Project A',
|
||||
'headline' => 'Task B',
|
||||
'workDate' => $workDate,
|
||||
'hours' => 1,
|
||||
'description' => 'qa',
|
||||
],
|
||||
];
|
||||
|
||||
$repo = $this->make(TimesheetRepository::class, [
|
||||
'getWeeklyTimesheets' => fn () => $rows,
|
||||
]);
|
||||
|
||||
$result = $this->makeService(timesheetsRepo: $repo)
|
||||
->getWeeklyTimesheetsWithTicketIds(-1, $fromDate, 1);
|
||||
|
||||
$this->assertArrayHasKey('timesheets', $result);
|
||||
$this->assertArrayHasKey('existingTicketIds', $result);
|
||||
$this->assertSame([11, 22], array_values($result['existingTicketIds']));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Ownership / fail-closed authorization (session user id = 1).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public function test_get_timesheet_returns_own_entry_for_editor(): void
|
||||
{
|
||||
$repo = $this->make(TimesheetRepository::class, [
|
||||
'getTimesheet' => fn () => ['id' => 5, 'userId' => 1, 'projectId' => 9],
|
||||
]);
|
||||
|
||||
$result = $this->makeService(timesheetsRepo: $repo, perms: $this->permissionsGranting(self::EDITOR_KEYS))->getTimesheet(5);
|
||||
|
||||
$this->assertSame(5, $result['id']);
|
||||
}
|
||||
|
||||
public function test_get_timesheet_soft_denies_another_users_entry_for_editor(): void
|
||||
{
|
||||
// Editor (no timesheets.manage) reading another user's entry → false, same as not-found.
|
||||
$repo = $this->make(TimesheetRepository::class, [
|
||||
'getTimesheet' => fn () => ['id' => 5, 'userId' => 2, 'projectId' => 9],
|
||||
]);
|
||||
|
||||
$result = $this->makeService(timesheetsRepo: $repo, perms: $this->permissionsGranting(self::EDITOR_KEYS))->getTimesheet(5);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function test_get_timesheet_returns_false_for_missing(): void
|
||||
{
|
||||
$repo = $this->make(TimesheetRepository::class, ['getTimesheet' => fn () => false]);
|
||||
|
||||
$this->assertFalse($this->makeService(timesheetsRepo: $repo)->getTimesheet(999));
|
||||
}
|
||||
|
||||
public function test_update_invoices_requires_manage(): void
|
||||
{
|
||||
$updated = 0;
|
||||
$repo = $this->make(TimesheetRepository::class, [
|
||||
'updateInvoices' => function () use (&$updated) {
|
||||
$updated++;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
try {
|
||||
$this->makeService(timesheetsRepo: $repo, perms: $this->permissionsGranting(self::EDITOR_KEYS))->updateInvoices([1], [], []);
|
||||
} finally {
|
||||
$this->assertSame(0, $updated, 'Invoices must not be touched without timesheets.manage');
|
||||
}
|
||||
}
|
||||
|
||||
public function test_get_all_for_own_user_needs_only_view(): void
|
||||
{
|
||||
$repo = $this->make(TimesheetRepository::class, ['getAll' => fn () => [['id' => 1]]]);
|
||||
|
||||
$result = $this->makeService(timesheetsRepo: $repo, perms: $this->permissionsGranting(self::EDITOR_KEYS))
|
||||
->getAll(dtHelper()->userNow(), dtHelper()->userNow(), userId: 1);
|
||||
|
||||
$this->assertSame([['id' => 1]], $result);
|
||||
}
|
||||
|
||||
public function test_get_all_for_another_user_requires_manage(): void
|
||||
{
|
||||
$repo = $this->make(TimesheetRepository::class, ['getAll' => fn () => [['id' => 1]]]);
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->makeService(timesheetsRepo: $repo, perms: $this->permissionsGranting(self::EDITOR_KEYS))
|
||||
->getAll(dtHelper()->userNow(), dtHelper()->userNow(), userId: 2);
|
||||
}
|
||||
|
||||
public function test_get_all_for_all_users_requires_manage(): void
|
||||
{
|
||||
// userId null = the company-wide report → manager only.
|
||||
$repo = $this->make(TimesheetRepository::class, ['getAll' => fn () => [['id' => 1]]]);
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
$this->makeService(timesheetsRepo: $repo, perms: $this->permissionsGranting(self::EDITOR_KEYS))
|
||||
->getAll(dtHelper()->userNow(), dtHelper()->userNow(), userId: null);
|
||||
}
|
||||
|
||||
public function test_delete_time_denies_another_users_entry_for_editor(): void
|
||||
{
|
||||
$deleted = 0;
|
||||
$repo = $this->make(TimesheetRepository::class, [
|
||||
'getTimesheet' => fn () => ['id' => 5, 'userId' => 2],
|
||||
'deleteTime' => function () use (&$deleted) {
|
||||
$deleted++;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService(timesheetsRepo: $repo, perms: $this->permissionsGranting(self::EDITOR_KEYS))->deleteTime(5);
|
||||
|
||||
$this->assertFalse($result);
|
||||
$this->assertSame(0, $deleted, "An editor must not delete another user's time");
|
||||
}
|
||||
|
||||
public function test_delete_time_allows_own_entry_for_editor(): void
|
||||
{
|
||||
$deleted = 0;
|
||||
$repo = $this->make(TimesheetRepository::class, [
|
||||
'getTimesheet' => fn () => ['id' => 5, 'userId' => 1],
|
||||
'deleteTime' => function () use (&$deleted) {
|
||||
$deleted++;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService(timesheetsRepo: $repo, perms: $this->permissionsGranting(self::EDITOR_KEYS))->deleteTime(5);
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertSame(1, $deleted);
|
||||
}
|
||||
|
||||
public function test_users_ticket_hours_soft_denies_another_user_for_editor(): void
|
||||
{
|
||||
$loaded = 0;
|
||||
$repo = $this->make(TimesheetRepository::class, [
|
||||
'getUsersTicketHours' => function () use (&$loaded) {
|
||||
$loaded++;
|
||||
|
||||
return 7;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService(timesheetsRepo: $repo, perms: $this->permissionsGranting(self::EDITOR_KEYS))->getUsersTicketHours(3, 2);
|
||||
|
||||
$this->assertSame(0, $result);
|
||||
$this->assertSame(0, $loaded, "An editor must not read another user's ticket hours");
|
||||
}
|
||||
|
||||
public function test_users_tickets_soft_denies_another_user_for_editor(): void
|
||||
{
|
||||
$repo = $this->make(TimesheetRepository::class);
|
||||
$ticketRepo = $this->make(TicketRepository::class, ['getUsersTickets' => fn () => [['id' => 1]]]);
|
||||
|
||||
$result = $this->makeService(timesheetsRepo: $repo, ticketRepo: $ticketRepo, perms: $this->permissionsGranting(self::EDITOR_KEYS))
|
||||
->getUsersTickets(2, -1);
|
||||
|
||||
$this->assertSame([], $result);
|
||||
}
|
||||
|
||||
public function test_add_time_pins_non_manager_to_own_user(): void
|
||||
{
|
||||
$captured = null;
|
||||
$repo = $this->make(TimesheetRepository::class, [
|
||||
'addTime' => function ($values) use (&$captured) {
|
||||
$captured = $values;
|
||||
},
|
||||
]);
|
||||
|
||||
// Editor (no manage) tries to log for user 2 → pinned to self (user 1).
|
||||
$this->makeService(timesheetsRepo: $repo, perms: $this->permissionsGranting(self::EDITOR_KEYS))
|
||||
->addTime(['userId' => 2, 'hours' => 1]);
|
||||
|
||||
$this->assertSame(1, $captured['userId'], 'A non-manager must be pinned to their own userId');
|
||||
}
|
||||
|
||||
// ---- Weekly grid bucketing across DST (#3310: Monday entry echoed on previous week's Sunday) ----
|
||||
|
||||
/** Switches the datetime helpers to America/New_York for the DST bucketing tests. */
|
||||
private function useNewYorkTimezone(): void
|
||||
{
|
||||
session(['usersettings.timezone' => 'America/New_York']);
|
||||
CarbonImmutable::mixin(new CarbonMacros('America/New_York', 'en-US', 'Y-m-d', 'H:i'));
|
||||
}
|
||||
|
||||
/** A timesheet row as returned by the repository's weekly query. */
|
||||
private function weeklyRow(string $workDate, float $hours = 1.0): array
|
||||
{
|
||||
return [
|
||||
'workDate' => $workDate,
|
||||
'ticketId' => 42,
|
||||
'kind' => 'GENERAL_BILLABLE',
|
||||
'hours' => $hours,
|
||||
'description' => 'work',
|
||||
'clientName' => 'Acme',
|
||||
'name' => 'Project',
|
||||
'headline' => 'Ticket',
|
||||
];
|
||||
}
|
||||
|
||||
public function test_weekly_grid_buckets_entry_into_its_local_calendar_day(): void
|
||||
{
|
||||
$this->useNewYorkTimezone();
|
||||
|
||||
// Week of Mon 2026-01-05 (EST, UTC-5): anchor is local midnight in UTC.
|
||||
$fromDate = CarbonImmutable::parse('2026-01-05 05:00:00', 'UTC');
|
||||
// Entry logged for Wednesday 2026-01-07 (local midnight EST → 05:00 UTC).
|
||||
$repo = $this->make(TimesheetRepository::class, [
|
||||
'getWeeklyTimesheets' => fn () => [$this->weeklyRow('2026-01-07 05:00:00', 2.5)],
|
||||
]);
|
||||
|
||||
$groups = $this->makeService(timesheetsRepo: $repo)->getWeeklyTimesheets(-1, $fromDate, 1);
|
||||
|
||||
$this->assertCount(1, $groups);
|
||||
$group = array_values($groups)[0];
|
||||
$this->assertSame(2.5, (float) $group['day3']['hours'], 'Wednesday entry must land in the Wednesday column');
|
||||
$this->assertSame(2.5, (float) $group['rowSum']);
|
||||
}
|
||||
|
||||
public function test_monday_entry_does_not_render_in_previous_weeks_sunday_column(): void
|
||||
{
|
||||
$this->useNewYorkTimezone();
|
||||
|
||||
// US DST 2026 starts Sun 2026-03-08. An entry logged for Monday 2026-03-09
|
||||
// (local midnight EDT) is stored as 04:00 UTC — inside the PREVIOUS week's flat
|
||||
// +168h UTC window anchored at Mon 2026-03-02 05:00 UTC (EST).
|
||||
$previousWeekAnchor = CarbonImmutable::parse('2026-03-02 05:00:00', 'UTC');
|
||||
$repo = $this->make(TimesheetRepository::class, [
|
||||
'getWeeklyTimesheets' => fn () => [$this->weeklyRow('2026-03-09 04:00:00')],
|
||||
]);
|
||||
|
||||
$groups = $this->makeService(timesheetsRepo: $repo)->getWeeklyTimesheets(-1, $previousWeekAnchor, 1);
|
||||
|
||||
$this->assertSame([], $groups, 'A Monday entry must not appear in the previous week (was rendered on Sunday)');
|
||||
}
|
||||
|
||||
public function test_monday_entry_renders_on_monday_in_its_own_week(): void
|
||||
{
|
||||
$this->useNewYorkTimezone();
|
||||
|
||||
// The same entry viewed in ITS week: anchor Mon 2026-03-09 local midnight EDT = 04:00 UTC.
|
||||
$weekAnchor = CarbonImmutable::parse('2026-03-09 04:00:00', 'UTC');
|
||||
$repo = $this->make(TimesheetRepository::class, [
|
||||
'getWeeklyTimesheets' => fn () => [$this->weeklyRow('2026-03-09 04:00:00')],
|
||||
]);
|
||||
|
||||
$groups = $this->makeService(timesheetsRepo: $repo)->getWeeklyTimesheets(-1, $weekAnchor, 1);
|
||||
|
||||
$this->assertCount(1, $groups);
|
||||
$group = array_values($groups)[0];
|
||||
$this->assertSame(1.0, (float) $group['day1']['hours'], 'Monday entry must land in the Monday column');
|
||||
}
|
||||
|
||||
public function test_entry_stored_under_previous_dst_offset_still_buckets_to_intended_day(): void
|
||||
{
|
||||
$this->useNewYorkTimezone();
|
||||
|
||||
// Entry logged for Monday 2026-03-09 while the clock was still EST (05:00 UTC),
|
||||
// viewed in the EDT-anchored week (anchor 04:00 UTC). Locally that's Mon 01:00 —
|
||||
// rounding to the nearest midnight keeps it on Monday.
|
||||
$weekAnchor = CarbonImmutable::parse('2026-03-09 04:00:00', 'UTC');
|
||||
$repo = $this->make(TimesheetRepository::class, [
|
||||
'getWeeklyTimesheets' => fn () => [$this->weeklyRow('2026-03-09 05:00:00')],
|
||||
]);
|
||||
|
||||
$groups = $this->makeService(timesheetsRepo: $repo)->getWeeklyTimesheets(-1, $weekAnchor, 1);
|
||||
|
||||
$group = array_values($groups)[0];
|
||||
$this->assertSame(1.0, (float) $group['day1']['hours'], 'Offset-drifted Monday entry must stay in the Monday column');
|
||||
}
|
||||
}
|
||||
122
tests/Unit/app/Domain/TwoFA/Services/TwoFAServiceTest.php
Normal file
122
tests/Unit/app/Domain/TwoFA/Services/TwoFAServiceTest.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\TwoFA\Services;
|
||||
|
||||
use Leantime\Domain\TwoFA\Services\TwoFA;
|
||||
use Leantime\Domain\Users\Repositories\Users as UserRepository;
|
||||
use RobThree\Auth\TwoFactorAuth;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the TwoFA service extracted from the TwoFA/Edit controller.
|
||||
*/
|
||||
class TwoFAServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
public function test_disable_clears_secret_and_flag(): void
|
||||
{
|
||||
$captured = null;
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'patchUser' => function ($id, $params) use (&$captured) {
|
||||
$captured = [$id, $params];
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
(new TwoFA($repo))->disable2FA(7);
|
||||
|
||||
$this->assertSame(7, $captured[0]);
|
||||
$this->assertSame(0, $captured[1]['twoFAEnabled']);
|
||||
$this->assertNull($captured[1]['twoFASecret']);
|
||||
}
|
||||
|
||||
public function test_save_secret_persists_without_enabling(): void
|
||||
{
|
||||
$captured = null;
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'patchUser' => function ($id, $params) use (&$captured) {
|
||||
$captured = $params;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
(new TwoFA($repo))->saveSecret(7, 'SECRETBASE32');
|
||||
|
||||
$this->assertSame(['twoFASecret' => 'SECRETBASE32'], $captured);
|
||||
}
|
||||
|
||||
public function test_verify_and_enable_rejects_invalid_code(): void
|
||||
{
|
||||
$persistCalls = 0;
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'patchUser' => function () use (&$persistCalls) {
|
||||
$persistCalls++;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$tfa = new TwoFactorAuth('Leantime', 6, 30, 'sha1');
|
||||
$secret = $tfa->createSecret(160);
|
||||
$validCode = $tfa->getCode($secret);
|
||||
// A numerically-adjacent code is not a valid TOTP code for this secret.
|
||||
$wrongCode = str_pad((string) ((((int) $validCode) + 1) % 1000000), 6, '0', STR_PAD_LEFT);
|
||||
|
||||
$result = (new TwoFA($repo))->verifyAndEnable(7, $secret, $wrongCode);
|
||||
|
||||
$this->assertFalse($result);
|
||||
$this->assertSame(0, $persistCalls, 'An invalid code must not enable 2FA');
|
||||
}
|
||||
|
||||
public function test_verify_and_enable_accepts_valid_code(): void
|
||||
{
|
||||
$captured = null;
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'patchUser' => function ($id, $params) use (&$captured) {
|
||||
$captured = $params;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$tfa = new TwoFactorAuth('Leantime', 6, 30, 'sha1');
|
||||
$secret = $tfa->createSecret(160);
|
||||
$validCode = $tfa->getCode($secret);
|
||||
|
||||
$result = (new TwoFA($repo))->verifyAndEnable(7, $secret, $validCode);
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertSame(1, $captured['twoFAEnabled']);
|
||||
$this->assertSame($secret, $captured['twoFASecret']);
|
||||
}
|
||||
|
||||
public function test_get_setup_data_generates_secret_and_qr_when_not_enabled(): void
|
||||
{
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'getUser' => fn ($id) => ['username' => 'jane@example.com', 'twoFASecret' => '', 'twoFAEnabled' => 0],
|
||||
]);
|
||||
|
||||
$setup = (new TwoFA($repo))->getSetupData(7);
|
||||
|
||||
$this->assertNotEmpty($setup['secret']);
|
||||
$this->assertFalse($setup['twoFAEnabled']);
|
||||
$this->assertIsString($setup['qrData']);
|
||||
$this->assertStringStartsWith('data:image/png', $setup['qrData']);
|
||||
}
|
||||
|
||||
public function test_get_setup_data_omits_qr_when_already_enabled(): void
|
||||
{
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'getUser' => fn ($id) => ['username' => 'jane@example.com', 'twoFASecret' => 'EXISTINGSECRET', 'twoFAEnabled' => 1],
|
||||
]);
|
||||
|
||||
$setup = (new TwoFA($repo))->getSetupData(7);
|
||||
|
||||
$this->assertSame('EXISTINGSECRET', $setup['secret']);
|
||||
$this->assertTrue($setup['twoFAEnabled']);
|
||||
$this->assertNull($setup['qrData']);
|
||||
}
|
||||
}
|
||||
147
tests/Unit/app/Domain/Users/EditUserAuthorizationTest.php
Normal file
147
tests/Unit/app/Domain/Users/EditUserAuthorizationTest.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unit\app\Domain\Users;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\DefaultRolePermissions;
|
||||
use Leantime\Core\Auth\Permissions\Permission;
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Domain\Users\Controllers\EditUser;
|
||||
use Leantime\Domain\Users\Permissions\UsersPermissions;
|
||||
use Leantime\Domain\Users\Services\Users as UsersService;
|
||||
use ReflectionMethod;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Regression guard for the write-path authorization on user records.
|
||||
*
|
||||
* A third-party review (2026-07-18) flagged: the Blade capacity section
|
||||
* gates on `Roles::admin`, but the controller `buildValuesFromPost`
|
||||
* reads `weekly_hours` + `employment_type` straight from `$_POST`. If
|
||||
* `EditUser::post()` did not independently require admin at the server
|
||||
* boundary, a non-admin could set these fields by crafting a POST.
|
||||
*
|
||||
* The server gate exists — every write surface carries
|
||||
* `#[RequiresPermission(UsersPermissions::EDIT, global: true)]`, and
|
||||
* `users.edit` is granted only to admin+ by `DefaultRolePermissions`.
|
||||
* PermissionEnforcer throws `AuthorizationException` before the method
|
||||
* body runs (Frontcontroller for legacy convention routes,
|
||||
* CheckPermissions middleware for Laravel routes, Jsonrpc for the
|
||||
* RPC surface).
|
||||
*
|
||||
* This test guards against silent removal of that attribute (any of
|
||||
* the three surfaces) or a future default-permission grant that would
|
||||
* hand `users.edit` to a lower role. Both would silently reopen the
|
||||
* bypass the reviewer flagged.
|
||||
*/
|
||||
class EditUserAuthorizationTest extends TestCase
|
||||
{
|
||||
// ─── Attribute presence on every write surface ────────────────────
|
||||
|
||||
public function test_controller_post_requires_users_edit_permission_globally(): void
|
||||
{
|
||||
// Legacy convention route: /users/editUser/{id}. If someone
|
||||
// strips this attribute, PermissionEnforcer stops enforcing
|
||||
// and any authenticated user can POST — the bypass scenario.
|
||||
$this->assertRequiresPermission(
|
||||
EditUser::class,
|
||||
'post',
|
||||
UsersPermissions::EDIT,
|
||||
);
|
||||
}
|
||||
|
||||
public function test_controller_get_requires_users_edit_permission_globally(): void
|
||||
{
|
||||
// GET is gated too — otherwise a non-admin could view the
|
||||
// admin edit form (info leak) even without being able to POST.
|
||||
$this->assertRequiresPermission(
|
||||
EditUser::class,
|
||||
'get',
|
||||
UsersPermissions::EDIT,
|
||||
);
|
||||
}
|
||||
|
||||
public function test_service_edit_user_requires_users_edit_permission_globally(): void
|
||||
{
|
||||
// Service-layer surface — any caller (JSON-RPC, plugins,
|
||||
// service-to-service) also passes through PermissionEnforcer
|
||||
// because the attribute is on the method, not the controller.
|
||||
$this->assertRequiresPermission(
|
||||
UsersService::class,
|
||||
'editUser',
|
||||
UsersPermissions::EDIT,
|
||||
);
|
||||
}
|
||||
|
||||
public function test_service_update_user_requires_users_edit_permission_globally(): void
|
||||
{
|
||||
// updateUser is the JSON-RPC entry point — wraps editUser +
|
||||
// project reconciliation. Its attribute is what secures the
|
||||
// RPC path (RPC bypasses the controller gate, per the
|
||||
// RequiresPermission docblock).
|
||||
$this->assertRequiresPermission(
|
||||
UsersService::class,
|
||||
'updateUser',
|
||||
UsersPermissions::EDIT,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Default-grant hierarchy — who has users.edit ─────────────────
|
||||
|
||||
public function test_users_edit_is_granted_to_admin_and_owner_only(): void
|
||||
{
|
||||
// The other half of the bypass guarantee: the attribute above
|
||||
// is only meaningful if `users.edit` isn't handed out to a
|
||||
// lower role by default. Owner + admin get it; manager gets
|
||||
// only users.create; editor/commenter/readonly get no users.*.
|
||||
$catalog = [new Permission(UsersPermissions::EDIT, 'Edit users', false)];
|
||||
|
||||
$this->assertContains(
|
||||
UsersPermissions::EDIT,
|
||||
DefaultRolePermissions::grantsFor('admin', $catalog),
|
||||
'admin must retain users.edit — the primary gate'
|
||||
);
|
||||
$this->assertContains(
|
||||
UsersPermissions::EDIT,
|
||||
DefaultRolePermissions::grantsFor('owner', $catalog),
|
||||
'owner must retain users.edit — inherits admin grants'
|
||||
);
|
||||
|
||||
// Everything below admin must NOT have it. If a future default
|
||||
// hands users.edit to manager or below, this test fails and
|
||||
// the reviewer's bypass concern re-materialises silently.
|
||||
foreach (['manager', 'editor', 'commenter', 'readonly'] as $role) {
|
||||
$this->assertNotContains(
|
||||
UsersPermissions::EDIT,
|
||||
DefaultRolePermissions::grantsFor($role, $catalog),
|
||||
sprintf('%s must NOT have users.edit by default', $role)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private function assertRequiresPermission(string $class, string $method, string $permission): void
|
||||
{
|
||||
$reflection = new ReflectionMethod($class, $method);
|
||||
$attributes = $reflection->getAttributes(RequiresPermission::class);
|
||||
|
||||
$this->assertCount(
|
||||
1,
|
||||
$attributes,
|
||||
sprintf('%s::%s must declare exactly one #[RequiresPermission] attribute', $class, $method)
|
||||
);
|
||||
|
||||
$attr = $attributes[0]->newInstance();
|
||||
$this->assertSame(
|
||||
$permission,
|
||||
$attr->permission,
|
||||
sprintf('%s::%s must require %s', $class, $method, $permission)
|
||||
);
|
||||
$this->assertTrue(
|
||||
$attr->global,
|
||||
sprintf('%s::%s must be global-scoped (users.* are company-wide, not project-scoped)', $class, $method)
|
||||
);
|
||||
}
|
||||
}
|
||||
81
tests/Unit/app/Domain/Users/Enums/EmploymentTypeTest.php
Normal file
81
tests/Unit/app/Domain/Users/Enums/EmploymentTypeTest.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unit\app\Domain\Users\Enums;
|
||||
|
||||
use Leantime\Domain\Users\Enums\EmploymentType;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Behaviors under test — the semantics that downstream capacity math
|
||||
* will trust:
|
||||
*
|
||||
* 1. Volunteer is the only case excluded from capacity accounting
|
||||
* (countsAgainstCapacity()=false). Everyone else counts.
|
||||
* 2. Over-cap warning tone maps cleanly per type — FTE = warn (target
|
||||
* exceeded, a burnout signal but not a violation), PT/Contractor =
|
||||
* danger (violates an explicit ceiling or billable cap), Volunteer
|
||||
* = none (best-effort work has no cap to violate).
|
||||
* 3. tryFrom() rejects arbitrary strings — the enum is the write-path
|
||||
* validation surface, so an unrecognised value must return null
|
||||
* rather than throw or coerce.
|
||||
* 4. Every case has both a human label AND an i18n key so admin UIs
|
||||
* can render translated selects without special-casing.
|
||||
*/
|
||||
class EmploymentTypeTest extends TestCase
|
||||
{
|
||||
public function test_volunteer_is_the_only_case_excluded_from_capacity(): void
|
||||
{
|
||||
// The whole point of the Volunteer type — best-effort work is
|
||||
// additive to team throughput but shouldn't fire over-cap
|
||||
// warnings that would flag someone for helping too much.
|
||||
$this->assertFalse(EmploymentType::Volunteer->countsAgainstCapacity());
|
||||
|
||||
$this->assertTrue(EmploymentType::FTE->countsAgainstCapacity());
|
||||
$this->assertTrue(EmploymentType::PartTime->countsAgainstCapacity());
|
||||
$this->assertTrue(EmploymentType::Contractor->countsAgainstCapacity());
|
||||
}
|
||||
|
||||
public function test_overcap_tone_reflects_target_vs_ceiling_semantics(): void
|
||||
{
|
||||
// FTE going over is a burnout signal — amber, not red. PT + Contractor
|
||||
// ceilings are explicit commitments (part-time hours the user set,
|
||||
// billable caps the org set) — over = red. Volunteer never fires.
|
||||
$this->assertSame('warn', EmploymentType::FTE->overCapTone());
|
||||
$this->assertSame('danger', EmploymentType::PartTime->overCapTone());
|
||||
$this->assertSame('danger', EmploymentType::Contractor->overCapTone());
|
||||
$this->assertSame('none', EmploymentType::Volunteer->overCapTone());
|
||||
}
|
||||
|
||||
public function test_try_from_rejects_arbitrary_strings(): void
|
||||
{
|
||||
// This is the exact surface the write path relies on. If tryFrom
|
||||
// ever starts coercing garbage into a case, the repo guard opens
|
||||
// a store-arbitrary-string escape hatch.
|
||||
$this->assertNull(EmploymentType::tryFrom('ATTACKER'));
|
||||
$this->assertNull(EmploymentType::tryFrom(''));
|
||||
$this->assertNull(EmploymentType::tryFrom('FTE')); // wrong case — enum values are lowercase
|
||||
$this->assertNull(EmploymentType::tryFrom('full-time'));
|
||||
}
|
||||
|
||||
public function test_try_from_accepts_the_four_canonical_values(): void
|
||||
{
|
||||
$this->assertSame(EmploymentType::FTE, EmploymentType::tryFrom('fte'));
|
||||
$this->assertSame(EmploymentType::PartTime, EmploymentType::tryFrom('pt'));
|
||||
$this->assertSame(EmploymentType::Contractor, EmploymentType::tryFrom('contractor'));
|
||||
$this->assertSame(EmploymentType::Volunteer, EmploymentType::tryFrom('volunteer'));
|
||||
}
|
||||
|
||||
public function test_every_case_has_a_label_and_lang_key(): void
|
||||
{
|
||||
foreach (EmploymentType::cases() as $type) {
|
||||
$this->assertNotSame('', $type->label(), sprintf('%s has empty label', $type->name));
|
||||
$this->assertStringStartsWith('users.employment_type.', $type->langKey());
|
||||
// The i18n suffix must be the enum's value — templates read
|
||||
// the value and look up the string, so a mismatch means the
|
||||
// admin select renders as a raw key.
|
||||
$this->assertStringEndsWith('.'.$type->value, $type->langKey());
|
||||
}
|
||||
}
|
||||
}
|
||||
252
tests/Unit/app/Domain/Users/Repositories/UsersRepositoryTest.php
Normal file
252
tests/Unit/app/Domain/Users/Repositories/UsersRepositoryTest.php
Normal file
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unit\app\Domain\Users\Repositories;
|
||||
|
||||
use Leantime\Domain\Users\Enums\EmploymentType;
|
||||
use Leantime\Domain\Users\Repositories\Users as UsersRepository;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Behaviors under test — the persistence contract for the two new
|
||||
* capacity attributes on zp_user (weekly_hours + employment_type,
|
||||
* added in migration 30523):
|
||||
*
|
||||
* 1. Values omitted from the update payload MUST NOT be nulled out —
|
||||
* the array_key_exists guard is the whole partial-update contract
|
||||
* downstream capacity math will rely on. If a caller sends only
|
||||
* {name: 'x'}, weekly_hours must remain untouched.
|
||||
* 2. Empty string ('') and null both mean "clear this" → persisted
|
||||
* as NULL, not 0 (the "not configured" state is meaningful; it's
|
||||
* what suppresses over-cap warnings).
|
||||
* 3. weekly_hours accepts int-ish strings and clamps to 0..168.
|
||||
* Anything outside that range OR non-numeric normalises to NULL
|
||||
* rather than storing garbage.
|
||||
* 4. employment_type is validated through EmploymentType::tryFrom() —
|
||||
* only the four canonical values persist; unknown strings (including
|
||||
* a crafted POST) normalise to NULL.
|
||||
*
|
||||
* The tests exercise the private normalizers via a testable subclass
|
||||
* that swaps the DB write for a captured payload — same shape as the
|
||||
* real query builder, no actual DB touched.
|
||||
*/
|
||||
class UsersRepositoryTest extends TestCase
|
||||
{
|
||||
public function test_weekly_hours_omitted_from_payload_is_not_written(): void
|
||||
{
|
||||
// The array_key_exists guard's whole reason for existing: a
|
||||
// partial-update caller (e.g. a form that only edits name) must
|
||||
// not accidentally clear a capacity value someone else set.
|
||||
$repo = $this->makeRepo();
|
||||
$repo->editUser($this->baseValues([/* no weekly_hours key */]), 1);
|
||||
|
||||
$this->assertArrayNotHasKey('weekly_hours', $repo->lastUpdate);
|
||||
}
|
||||
|
||||
public function test_employment_type_omitted_from_payload_is_not_written(): void
|
||||
{
|
||||
$repo = $this->makeRepo();
|
||||
$repo->editUser($this->baseValues([/* no employment_type key */]), 1);
|
||||
|
||||
$this->assertArrayNotHasKey('employment_type', $repo->lastUpdate);
|
||||
}
|
||||
|
||||
public function test_weekly_hours_empty_string_persists_as_null(): void
|
||||
{
|
||||
// Distinct from omission — empty string means "the form was
|
||||
// rendered, the user cleared the field, they want it unset."
|
||||
// Persisting 0 here would fabricate a value they did not enter.
|
||||
$repo = $this->makeRepo();
|
||||
$repo->editUser($this->baseValues(['weekly_hours' => '']), 1);
|
||||
|
||||
$this->assertArrayHasKey('weekly_hours', $repo->lastUpdate);
|
||||
$this->assertNull($repo->lastUpdate['weekly_hours']);
|
||||
}
|
||||
|
||||
public function test_weekly_hours_null_persists_as_null(): void
|
||||
{
|
||||
$repo = $this->makeRepo();
|
||||
$repo->editUser($this->baseValues(['weekly_hours' => null]), 1);
|
||||
|
||||
$this->assertNull($repo->lastUpdate['weekly_hours']);
|
||||
}
|
||||
|
||||
public function test_weekly_hours_valid_int_string_persists_as_int(): void
|
||||
{
|
||||
$repo = $this->makeRepo();
|
||||
$repo->editUser($this->baseValues(['weekly_hours' => '40']), 1);
|
||||
|
||||
$this->assertSame(40, $repo->lastUpdate['weekly_hours']);
|
||||
}
|
||||
|
||||
public function test_weekly_hours_out_of_range_persists_as_null(): void
|
||||
{
|
||||
// Upper bound is 168 (hours in a week). Anything higher has no
|
||||
// physical meaning — downstream capacity math would divide by
|
||||
// absurd numbers. Same on the lower side for negatives.
|
||||
foreach (['169', '99999', '-1', '-500'] as $value) {
|
||||
$repo = $this->makeRepo();
|
||||
$repo->editUser($this->baseValues(['weekly_hours' => $value]), 1);
|
||||
|
||||
$this->assertNull(
|
||||
$repo->lastUpdate['weekly_hours'],
|
||||
sprintf('weekly_hours=%s should normalise to NULL, got %s', $value, var_export($repo->lastUpdate['weekly_hours'] ?? 'MISSING', true))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_weekly_hours_boundary_values_are_accepted(): void
|
||||
{
|
||||
// 0 and 168 are inclusive — 0 is a valid "no hours" (e.g. an
|
||||
// inactive account that hasn't been offboarded), 168 is a
|
||||
// theoretical ceiling.
|
||||
$repo = $this->makeRepo();
|
||||
$repo->editUser($this->baseValues(['weekly_hours' => '0']), 1);
|
||||
$this->assertSame(0, $repo->lastUpdate['weekly_hours']);
|
||||
|
||||
$repo = $this->makeRepo();
|
||||
$repo->editUser($this->baseValues(['weekly_hours' => '168']), 1);
|
||||
$this->assertSame(168, $repo->lastUpdate['weekly_hours']);
|
||||
}
|
||||
|
||||
public function test_weekly_hours_non_numeric_persists_as_null(): void
|
||||
{
|
||||
// Belt-and-suspenders: HTML enforces type=number but a crafted
|
||||
// POST can send anything.
|
||||
$repo = $this->makeRepo();
|
||||
$repo->editUser($this->baseValues(['weekly_hours' => 'forty']), 1);
|
||||
|
||||
$this->assertNull($repo->lastUpdate['weekly_hours']);
|
||||
}
|
||||
|
||||
public function test_employment_type_valid_case_persists(): void
|
||||
{
|
||||
foreach (EmploymentType::cases() as $type) {
|
||||
$repo = $this->makeRepo();
|
||||
$repo->editUser($this->baseValues(['employment_type' => $type->value]), 1);
|
||||
|
||||
$this->assertSame(
|
||||
$type->value,
|
||||
$repo->lastUpdate['employment_type'],
|
||||
sprintf('%s should round-trip', $type->name)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_employment_type_unknown_string_persists_as_null(): void
|
||||
{
|
||||
// The write-path guard against the exact IDOR-adjacent scenario
|
||||
// Marcel flagged — a crafted POST used to store garbage that
|
||||
// later EmploymentType::from() would throw on.
|
||||
foreach (['ATTACKER_STRING', 'FTE', 'full-time', 'admin'] as $bogus) {
|
||||
$repo = $this->makeRepo();
|
||||
$repo->editUser($this->baseValues(['employment_type' => $bogus]), 1);
|
||||
|
||||
$this->assertNull(
|
||||
$repo->lastUpdate['employment_type'],
|
||||
sprintf('employment_type=%s should normalise to NULL', $bogus)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_employment_type_empty_string_persists_as_null(): void
|
||||
{
|
||||
$repo = $this->makeRepo();
|
||||
$repo->editUser($this->baseValues(['employment_type' => '']), 1);
|
||||
|
||||
$this->assertNull($repo->lastUpdate['employment_type']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a testable UsersRepository that captures the DB payload
|
||||
* without touching the connection. Overrides the one query builder
|
||||
* call editUser makes; every other method is inherited unchanged.
|
||||
*/
|
||||
private function makeRepo(): object
|
||||
{
|
||||
return new class extends UsersRepository
|
||||
{
|
||||
public array $lastUpdate = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Skip parent constructor — no DB connection needed for
|
||||
// this test. The normalizers are pure functions of the
|
||||
// payload, and editUser's only external call is the
|
||||
// update() we override below.
|
||||
}
|
||||
|
||||
public function editUser(array $values, $id): bool
|
||||
{
|
||||
// Re-run the exact normalisation logic from the parent
|
||||
// (copied here since the parent method also calls the
|
||||
// connection). Kept in lockstep with the parent — any
|
||||
// change to the parent's normalization must mirror here.
|
||||
unset($this->userMemo[$id]);
|
||||
|
||||
$updateData = [
|
||||
'firstname' => $values['firstname'],
|
||||
'lastname' => $values['lastname'],
|
||||
'username' => $values['user'],
|
||||
'phone' => $values['phone'] ?? '',
|
||||
'status' => $values['status'],
|
||||
'role' => $values['role'],
|
||||
'hours' => $values['hours'] ?? 0,
|
||||
'wage' => $values['wage'] ?? 0,
|
||||
'clientId' => $values['clientId'],
|
||||
'jobTitle' => $values['jobTitle'] ?? '',
|
||||
'jobLevel' => $values['jobLevel'] ?? '',
|
||||
'department' => $values['department'] ?? '',
|
||||
// 'modified' omitted from capture — non-deterministic timestamp.
|
||||
];
|
||||
|
||||
if (array_key_exists('weekly_hours', $values)) {
|
||||
$updateData['weekly_hours'] = $this->normalizeWeeklyHoursForTest($values['weekly_hours']);
|
||||
}
|
||||
if (array_key_exists('employment_type', $values)) {
|
||||
$updateData['employment_type'] = $this->normalizeEmploymentTypeForTest($values['employment_type']);
|
||||
}
|
||||
|
||||
$this->lastUpdate = $updateData;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Bridges to the parent's private normalizers via reflection —
|
||||
// this lets the test exercise the SAME code path production
|
||||
// uses, not a copy that could drift.
|
||||
private function normalizeWeeklyHoursForTest(mixed $value): ?int
|
||||
{
|
||||
$r = new \ReflectionMethod(UsersRepository::class, 'normalizeWeeklyHours');
|
||||
|
||||
return $r->invoke($this, $value);
|
||||
}
|
||||
|
||||
private function normalizeEmploymentTypeForTest(mixed $value): ?string
|
||||
{
|
||||
$r = new \ReflectionMethod(UsersRepository::class, 'normalizeEmploymentType');
|
||||
|
||||
return $r->invoke($this, $value);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The minimum payload editUser expects, plus whatever keys the test
|
||||
* wants to override or add. Uses defaults for all the non-capacity
|
||||
* fields since editUser doesn't guard those (a separate concern).
|
||||
*/
|
||||
private function baseValues(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'firstname' => 'Test',
|
||||
'lastname' => 'User',
|
||||
'user' => 'test@example.com',
|
||||
'phone' => '',
|
||||
'status' => 'a',
|
||||
'role' => 20,
|
||||
'clientId' => 0,
|
||||
], $overrides);
|
||||
}
|
||||
}
|
||||
99
tests/Unit/app/Domain/Users/Services/InviteRateLimitTest.php
Normal file
99
tests/Unit/app/Domain/Users/Services/InviteRateLimitTest.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Users\Services;
|
||||
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Leantime\Core\Language as LanguageCore;
|
||||
use Leantime\Core\Support\Avatarcreator;
|
||||
use Leantime\Core\UI\Theme as ThemeCore;
|
||||
use Leantime\Domain\Auth\Services\Auth as AuthService;
|
||||
use Leantime\Domain\Clients\Repositories\Clients as ClientRepository;
|
||||
use Leantime\Domain\Files\Services\Files;
|
||||
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Leantime\Domain\Setting\Services\Setting as SettingService;
|
||||
use Leantime\Domain\Users\Repositories\Users as UserRepository;
|
||||
use Leantime\Domain\Users\Services\Users;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Regression guard for the invite-spam rate limit. When an inviter exceeds the per-user cap,
|
||||
* createUserInvite() must short-circuit and never reach the DB insert — proving the limiter is
|
||||
* the real backstop for every entry point (web, JSON-RPC, resend all funnel through here).
|
||||
*/
|
||||
class InviteRateLimitTest extends TestCase
|
||||
{
|
||||
private const INVITER_ID = 4242;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// The array cache store persists within a single test run, so clear the keys this test
|
||||
// touches to keep it independent of ordering and of any prior limiter state.
|
||||
foreach ($this->limiterKeys() as $key) {
|
||||
RateLimiter::clear($key);
|
||||
}
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach ($this->limiterKeys() as $key) {
|
||||
RateLimiter::clear($key);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_create_user_invite_returns_false_and_skips_db_when_user_cap_exceeded(): void
|
||||
{
|
||||
session(['userdata' => ['id' => self::INVITER_ID, 'name' => 'Inviter', 'mail' => 'inviter@example.com']]);
|
||||
|
||||
// Exhaust the per-user hourly cap (default 10) on the exact key the service computes.
|
||||
[$userKey] = $this->limiterKeys();
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
RateLimiter::hit($userKey, 3600);
|
||||
}
|
||||
|
||||
// The DB layer must never be touched once the cap is hit.
|
||||
$userRepo = $this->createMock(UserRepository::class);
|
||||
$userRepo->expects($this->never())->method('addUser');
|
||||
|
||||
$service = new Users(
|
||||
$userRepo,
|
||||
$this->createMock(LanguageCore::class),
|
||||
$this->createMock(ProjectRepository::class),
|
||||
$this->createMock(ClientRepository::class),
|
||||
$this->createMock(AuthService::class),
|
||||
$this->createMock(Files::class),
|
||||
$this->createMock(Avatarcreator::class),
|
||||
$this->createMock(SettingService::class),
|
||||
$this->createMock(ThemeCore::class),
|
||||
$this->createMock(ProjectService::class),
|
||||
);
|
||||
|
||||
$result = $service->createUserInvite([
|
||||
'user' => 'newuser@example.com',
|
||||
'firstname' => 'New',
|
||||
'lastname' => 'User',
|
||||
'role' => '20',
|
||||
]);
|
||||
|
||||
$this->assertFalse($result, 'createUserInvite must return false once the invite cap is exceeded');
|
||||
}
|
||||
|
||||
/**
|
||||
* The user + tenant limiter keys, computed exactly as Users::invitesRateLimited() does.
|
||||
*
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
private function limiterKeys(): array
|
||||
{
|
||||
$scope = defined('BASE_URL') ? BASE_URL : 'default';
|
||||
|
||||
return [
|
||||
'invites:'.$scope.':user:'.self::INVITER_ID,
|
||||
'invites:'.$scope.':tenant',
|
||||
];
|
||||
}
|
||||
}
|
||||
509
tests/Unit/app/Domain/Users/Services/UsersServiceTest.php
Normal file
509
tests/Unit/app/Domain/Users/Services/UsersServiceTest.php
Normal file
@@ -0,0 +1,509 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Users\Services;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\PermissionService;
|
||||
use Leantime\Core\Exceptions\AuthorizationException;
|
||||
use Leantime\Core\Language as LanguageCore;
|
||||
use Leantime\Core\Support\Avatarcreator;
|
||||
use Leantime\Core\UI\Theme as ThemeCore;
|
||||
use Leantime\Domain\Auth\Services\Auth as AuthService;
|
||||
use Leantime\Domain\Clients\Repositories\Clients as ClientRepository;
|
||||
use Leantime\Domain\Files\Services\Files;
|
||||
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Leantime\Domain\Setting\Services\Setting as SettingService;
|
||||
use Leantime\Domain\Users\Repositories\Users as UserRepository;
|
||||
use Leantime\Domain\Users\Services\Users as UserService;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the Users service helpers extracted during the
|
||||
* thin-controller refactor (saveModalDismissal).
|
||||
*/
|
||||
class UsersServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/**
|
||||
* Builds a real Users service with mocked dependencies, injecting the
|
||||
* provided (stubbed) repository so we can observe persistence calls.
|
||||
* Optional overrides let individual tests swap in stubbed collaborators.
|
||||
*
|
||||
* @param array<string, mixed> $overrides Keyed by dependency short name.
|
||||
*/
|
||||
private function makeService(UserRepository $userRepo, array $overrides = []): UserService
|
||||
{
|
||||
return new UserService(
|
||||
$userRepo,
|
||||
$overrides['language'] ?? $this->make(LanguageCore::class),
|
||||
$overrides['projectRepository'] ?? $this->make(ProjectRepository::class),
|
||||
$overrides['clientRepo'] ?? $this->make(ClientRepository::class),
|
||||
$overrides['authService'] ?? $this->make(AuthService::class),
|
||||
$overrides['fileService'] ?? $this->make(Files::class),
|
||||
$overrides['avatarcreator'] ?? $this->make(Avatarcreator::class),
|
||||
$overrides['settingsService'] ?? $this->make(SettingService::class),
|
||||
$overrides['themeCore'] ?? $this->make(ThemeCore::class),
|
||||
$overrides['projectService'] ?? $this->make(ProjectService::class),
|
||||
);
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
session(['userdata.id' => 1]);
|
||||
session()->forget('usersettings');
|
||||
}
|
||||
|
||||
public function test_session_only_dismissal_records_session_without_persisting(): void
|
||||
{
|
||||
$persistCalls = 0;
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'patchUser' => function () use (&$persistCalls) {
|
||||
$persistCalls++;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService($repo)->saveModalDismissal('welcomeModal', false);
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertSame(1, session('usersettings.modals.welcomeModal'));
|
||||
$this->assertSame(0, $persistCalls, 'A non-permanent dismissal must not touch the repository');
|
||||
}
|
||||
|
||||
public function test_permanent_dismissal_persists_to_user_settings(): void
|
||||
{
|
||||
$persistCalls = 0;
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'patchUser' => function ($id, $params) use (&$persistCalls) {
|
||||
$persistCalls++;
|
||||
|
||||
// The service must persist the serialized usersettings blob.
|
||||
$this->assertArrayHasKey('settings', $params);
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService($repo)->saveModalDismissal('welcomeModal', true);
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertSame('1', session('usersettings.modals.welcomeModal'));
|
||||
$this->assertSame(1, $persistCalls, 'A permanent dismissal must persist via the repository');
|
||||
}
|
||||
|
||||
public function test_get_user_project_ids_flattens_relation_rows(): void
|
||||
{
|
||||
$repo = $this->make(UserRepository::class);
|
||||
$projectService = $this->make(ProjectService::class, [
|
||||
'getUserProjectRelation' => fn () => [
|
||||
['projectId' => 5],
|
||||
['projectId' => 9],
|
||||
['projectId' => 12],
|
||||
],
|
||||
]);
|
||||
|
||||
$ids = $this->makeService($repo, ['projectService' => $projectService])->getUserProjectIds(3);
|
||||
|
||||
$this->assertSame([5, 9, 12], $ids);
|
||||
}
|
||||
|
||||
public function test_validate_user_update_rejects_empty_username(): void
|
||||
{
|
||||
$repo = $this->make(UserRepository::class);
|
||||
$service = $this->makeService($repo);
|
||||
|
||||
$result = $service->validateUserUpdate(
|
||||
['user' => ''],
|
||||
['username' => 'old@example.com'],
|
||||
7,
|
||||
[]
|
||||
);
|
||||
|
||||
$this->assertSame('passwords_dont_match', $result);
|
||||
}
|
||||
|
||||
public function test_validate_user_update_rejects_invalid_email(): void
|
||||
{
|
||||
$repo = $this->make(UserRepository::class);
|
||||
$service = $this->makeService($repo);
|
||||
|
||||
$result = $service->validateUserUpdate(
|
||||
['user' => 'not-an-email'],
|
||||
['username' => 'old@example.com'],
|
||||
7,
|
||||
[]
|
||||
);
|
||||
|
||||
$this->assertSame('no_valid_email', $result);
|
||||
}
|
||||
|
||||
public function test_validate_user_update_rejects_taken_email_on_change(): void
|
||||
{
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'usernameExist' => fn () => true,
|
||||
]);
|
||||
$service = $this->makeService($repo);
|
||||
|
||||
$result = $service->validateUserUpdate(
|
||||
['user' => 'new@example.com'],
|
||||
['username' => 'old@example.com'],
|
||||
7,
|
||||
[]
|
||||
);
|
||||
|
||||
$this->assertSame('user_exists', $result);
|
||||
}
|
||||
|
||||
public function test_validate_user_update_passes_for_unchanged_valid_email(): void
|
||||
{
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'usernameExist' => fn () => true,
|
||||
]);
|
||||
$service = $this->makeService($repo);
|
||||
|
||||
// Email unchanged, so usernameExist must NOT block it.
|
||||
$result = $service->validateUserUpdate(
|
||||
['user' => 'same@example.com'],
|
||||
['username' => 'same@example.com'],
|
||||
7,
|
||||
[]
|
||||
);
|
||||
|
||||
$this->assertSame('valid', $result);
|
||||
}
|
||||
|
||||
public function test_invite_new_user_rejects_invalid_email(): void
|
||||
{
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'usernameExist' => fn () => false,
|
||||
]);
|
||||
$service = $this->makeService($repo);
|
||||
|
||||
$result = $service->inviteNewUser(
|
||||
['user' => 'nope'],
|
||||
sessionClientId: null,
|
||||
isManager: false
|
||||
);
|
||||
|
||||
$this->assertSame('no_valid_email', $result);
|
||||
}
|
||||
|
||||
public function test_invite_new_user_rejects_existing_user(): void
|
||||
{
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'usernameExist' => fn () => true,
|
||||
]);
|
||||
$service = $this->makeService($repo);
|
||||
|
||||
$result = $service->inviteNewUser(
|
||||
['user' => 'taken@example.com'],
|
||||
sessionClientId: null,
|
||||
isManager: false
|
||||
);
|
||||
|
||||
$this->assertSame('user_exists', $result);
|
||||
}
|
||||
|
||||
public function test_change_own_password_rejects_wrong_current_password(): void
|
||||
{
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'getUser' => fn () => [
|
||||
'id' => 1,
|
||||
'password' => password_hash('correct-horse', PASSWORD_DEFAULT),
|
||||
'firstname' => 'A',
|
||||
'lastname' => 'B',
|
||||
'username' => 'a@b.com',
|
||||
'phone' => '',
|
||||
'notifications' => 1,
|
||||
'twoFAEnabled' => 0,
|
||||
],
|
||||
]);
|
||||
$service = $this->makeService($repo);
|
||||
|
||||
$result = $service->changeOwnPassword(1, 'wrong', 'NewPass1!', 'NewPass1!');
|
||||
|
||||
$this->assertSame('previous_password_incorrect', $result);
|
||||
}
|
||||
|
||||
public function test_change_own_password_rejects_mismatched_confirmation(): void
|
||||
{
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'getUser' => fn () => [
|
||||
'id' => 1,
|
||||
'password' => password_hash('correct-horse', PASSWORD_DEFAULT),
|
||||
'firstname' => 'A',
|
||||
'lastname' => 'B',
|
||||
'username' => 'a@b.com',
|
||||
'phone' => '',
|
||||
'notifications' => 1,
|
||||
'twoFAEnabled' => 0,
|
||||
],
|
||||
]);
|
||||
$service = $this->makeService($repo);
|
||||
|
||||
$result = $service->changeOwnPassword(1, 'correct-horse', 'NewPass1!', 'Different1!');
|
||||
|
||||
$this->assertSame('passwords_dont_match', $result);
|
||||
}
|
||||
|
||||
public function test_change_own_password_persists_on_success(): void
|
||||
{
|
||||
$savedValues = null;
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'getUser' => fn () => [
|
||||
'id' => 1,
|
||||
'password' => password_hash('correct-horse', PASSWORD_DEFAULT),
|
||||
'firstname' => 'A',
|
||||
'lastname' => 'B',
|
||||
'username' => 'a@b.com',
|
||||
'phone' => '',
|
||||
'notifications' => 1,
|
||||
'twoFAEnabled' => 0,
|
||||
],
|
||||
'editOwn' => function ($values) use (&$savedValues) {
|
||||
$savedValues = $values;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
$service = $this->makeService($repo);
|
||||
|
||||
$result = $service->changeOwnPassword(1, 'correct-horse', 'NewPass1!', 'NewPass1!');
|
||||
|
||||
$this->assertSame('success', $result);
|
||||
$this->assertSame('NewPass1!', $savedValues['password']);
|
||||
}
|
||||
|
||||
public function test_save_own_profile_blocks_duplicate_email(): void
|
||||
{
|
||||
$editCalls = 0;
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'getUser' => fn () => [
|
||||
'id' => 1,
|
||||
'firstname' => 'A',
|
||||
'lastname' => 'B',
|
||||
'username' => 'old@example.com',
|
||||
'phone' => '',
|
||||
'notifications' => 1,
|
||||
'twoFAEnabled' => 0,
|
||||
],
|
||||
'usernameExist' => fn () => true,
|
||||
'editOwn' => function () use (&$editCalls) {
|
||||
$editCalls++;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
$service = $this->makeService($repo);
|
||||
|
||||
$result = $service->saveOwnProfile(1, ['user' => 'taken@example.com']);
|
||||
|
||||
$this->assertSame('user_exists', $result);
|
||||
$this->assertSame(0, $editCalls, 'A duplicate email must not be persisted');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// searchProjectUsers() — JSON-RPC entry for the @mention autocomplete.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public function test_search_project_users_filters_by_query(): void
|
||||
{
|
||||
session(['userdata' => ['id' => 1], 'currentProject' => 5]);
|
||||
|
||||
$projectRepository = $this->make(ProjectRepository::class, [
|
||||
'isUserAssignedToProject' => fn () => true,
|
||||
'getProject' => fn () => ['psettings' => 'restricted', 'clientId' => 0],
|
||||
'getUsersAssignedToProject' => fn () => [
|
||||
['id' => 1, 'firstname' => 'Alice'],
|
||||
['id' => 2, 'firstname' => 'Bob'],
|
||||
],
|
||||
]);
|
||||
|
||||
$users = $this->makeService($this->make(UserRepository::class), ['projectRepository' => $projectRepository])
|
||||
->searchProjectUsers(5, 'alice');
|
||||
|
||||
$this->assertCount(1, $users);
|
||||
$this->assertSame('Alice', $users[0]['firstname']);
|
||||
}
|
||||
|
||||
public function test_search_project_users_returns_empty_without_project_access(): void
|
||||
{
|
||||
session(['userdata' => ['id' => 1], 'currentProject' => 5]);
|
||||
|
||||
$projectRepository = $this->make(ProjectRepository::class, [
|
||||
'isUserAssignedToProject' => fn () => false,
|
||||
]);
|
||||
|
||||
$users = $this->makeService($this->make(UserRepository::class), ['projectRepository' => $projectRepository])
|
||||
->searchProjectUsers(5);
|
||||
|
||||
$this->assertSame([], $users);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Authorization. The company-wide manage-others methods
|
||||
// (editUser/updateUser/addUser/getAll/…) gate via the dispatch-time
|
||||
// #[RequiresPermission(global: true)] attribute (covered by PermissionEnforcerTest).
|
||||
// These two methods authorize in their own body, so they gate on direct calls too.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private function denyingPermissions(): PermissionService
|
||||
{
|
||||
return $this->make(PermissionService::class, [
|
||||
'currentUserCan' => fn () => false,
|
||||
'authorize' => function (): void {
|
||||
throw new AuthorizationException;
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
private function allowingPermissions(): PermissionService
|
||||
{
|
||||
return $this->make(PermissionService::class, [
|
||||
'currentUserCan' => fn () => true,
|
||||
'authorize' => fn () => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_delete_user_throws_without_delete_permission(): void
|
||||
{
|
||||
$service = $this->makeService($this->make(UserRepository::class));
|
||||
$service->setPermissionService($this->denyingPermissions());
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$service->deleteUser(5);
|
||||
}
|
||||
|
||||
public function test_patch_user_allows_self_with_limited_fields_without_edit_permission(): void
|
||||
{
|
||||
session(['userdata' => ['id' => 7]]);
|
||||
|
||||
$patched = [];
|
||||
$service = $this->makeService($this->make(UserRepository::class, [
|
||||
'patchUser' => function ($id, $fields) use (&$patched) {
|
||||
$patched = ['id' => $id, 'fields' => $fields];
|
||||
|
||||
return true;
|
||||
},
|
||||
]));
|
||||
$service->setPermissionService($this->denyingPermissions()); // no users.edit
|
||||
|
||||
// Editing OWN account (id === session user) is allowed even without users.edit...
|
||||
$result = $service->patchUser(7, ['firstname' => 'Bob', 'role' => '50']);
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertSame(7, $patched['id']);
|
||||
$this->assertArrayHasKey('firstname', $patched['fields']);
|
||||
// ...but the privileged 'role' field is stripped — no self privilege-escalation.
|
||||
$this->assertArrayNotHasKey('role', $patched['fields']);
|
||||
}
|
||||
|
||||
public function test_patch_user_denies_other_account_without_edit_permission(): void
|
||||
{
|
||||
session(['userdata' => ['id' => 7]]);
|
||||
|
||||
$service = $this->makeService($this->make(UserRepository::class, [
|
||||
'patchUser' => fn () => true,
|
||||
]));
|
||||
$service->setPermissionService($this->denyingPermissions());
|
||||
|
||||
// Patching ANOTHER account without users.edit must fail (closes the RPC escalation hole).
|
||||
$this->assertFalse($service->patchUser(99, ['role' => '50']));
|
||||
}
|
||||
|
||||
public function test_patch_user_allows_other_account_with_edit_permission(): void
|
||||
{
|
||||
session(['userdata' => ['id' => 7]]);
|
||||
|
||||
$patched = [];
|
||||
$service = $this->makeService($this->make(UserRepository::class, [
|
||||
'patchUser' => function ($id, $fields) use (&$patched) {
|
||||
$patched = ['id' => $id, 'fields' => $fields];
|
||||
|
||||
return true;
|
||||
},
|
||||
]));
|
||||
$service->setPermissionService($this->allowingPermissions()); // has users.edit
|
||||
|
||||
$result = $service->patchUser(99, ['role' => '20']);
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertSame(99, $patched['id']);
|
||||
// A users.edit holder may set privileged fields on another account.
|
||||
$this->assertArrayHasKey('role', $patched['fields']);
|
||||
}
|
||||
|
||||
public function test_self_service_methods_ignore_caller_supplied_id_and_pin_to_session(): void
|
||||
{
|
||||
// Self-service methods (editOwn/saveOwn*/getOwn*/changeOwnPassword) must operate on the
|
||||
// authenticated user only — over JSON-RPC a caller controls the $userId argument, so a
|
||||
// foreign id must NOT be honored (otherwise it is a cross-account IDOR). Representative
|
||||
// check via changeOwnPassword: the credential lookup must hit the SESSION user (7), not
|
||||
// the attacker-supplied id (99).
|
||||
session(['userdata' => ['id' => 7]]);
|
||||
|
||||
$seenId = null;
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'getUser' => function ($id) use (&$seenId) {
|
||||
$seenId = $id;
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'password' => password_hash('correct-horse', PASSWORD_DEFAULT),
|
||||
'firstname' => 'A', 'lastname' => 'B', 'username' => 'a@b.com',
|
||||
'phone' => '', 'notifications' => 1, 'twoFAEnabled' => 0,
|
||||
];
|
||||
},
|
||||
]);
|
||||
|
||||
$this->makeService($repo)->changeOwnPassword(99, 'wrong', 'NewPass1!', 'NewPass1!');
|
||||
|
||||
$this->assertSame(7, $seenId, 'self-service must pin to the session user, not the caller-supplied id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression for #3556: getUser is @api and intentionally ungated, so any
|
||||
* authenticated client can request an arbitrary id. It must never return
|
||||
* credentials — password hash, plaintext 2FA seed, session token, or the
|
||||
* password-reset token/metadata — while keeping the safe profile fields
|
||||
* the view composers rely on.
|
||||
*/
|
||||
public function test_get_user_strips_sensitive_fields_from_api_response(): void
|
||||
{
|
||||
$fullRow = [
|
||||
'id' => 5,
|
||||
'firstname' => 'Ada',
|
||||
'lastname' => 'Lovelace',
|
||||
'username' => 'ada@example.com',
|
||||
'role' => '20',
|
||||
'password' => '$2y$10$abcdefghijklmnopqrstuv',
|
||||
'twoFASecret' => 'SECRET2FASEED',
|
||||
'session' => 'sess-token-xyz',
|
||||
'sessiontime' => '1700000000',
|
||||
'pwReset' => 'reset-token',
|
||||
'pwResetExpiration' => '2026-01-01 00:00:00',
|
||||
'pwResetCount' => 2,
|
||||
];
|
||||
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'getUser' => fn () => $fullRow,
|
||||
]);
|
||||
|
||||
$user = $this->makeService($repo)->getUser(5);
|
||||
|
||||
$this->assertIsArray($user);
|
||||
// Safe profile fields survive so composers/avatars keep working.
|
||||
$this->assertSame('Ada', $user['firstname']);
|
||||
$this->assertSame('ada@example.com', $user['username']);
|
||||
|
||||
// Every credential/session/reset field is stripped.
|
||||
foreach (['password', 'twoFASecret', 'session', 'sessiontime', 'pwReset', 'pwResetExpiration', 'pwResetCount'] as $secret) {
|
||||
$this->assertArrayNotHasKey($secret, $user, "getUser must not leak {$secret} over the API");
|
||||
}
|
||||
}
|
||||
}
|
||||
328
tests/Unit/app/Domain/Widgets/Services/DashboardServiceTest.php
Normal file
328
tests/Unit/app/Domain/Widgets/Services/DashboardServiceTest.php
Normal file
@@ -0,0 +1,328 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Widgets\Services;
|
||||
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Leantime\Domain\Reports\Services\Reports as ReportService;
|
||||
use Leantime\Domain\Setting\Services\Setting as SettingService;
|
||||
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
|
||||
use Leantime\Domain\Users\Services\Users as UserService;
|
||||
use Leantime\Domain\Widgets\Services\Dashboard;
|
||||
use Leantime\Domain\Widgets\Services\Widgets;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the Widgets Dashboard service that backs the Welcome and
|
||||
* "My To-Dos" dashboard widgets.
|
||||
*/
|
||||
class DashboardServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Provide everything dtHelper() needs so getWelcomeWidgetData() can call
|
||||
// dtHelper()->userNow() without reaching for the Environment/Language.
|
||||
session(['usersettings.timezone' => 'UTC']);
|
||||
session(['usersettings.language' => 'en-US']);
|
||||
session(['usersettings.date_format' => 'Y-m-d']);
|
||||
session(['usersettings.time_format' => 'H:i']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Dashboard service with the supplied (mocked) collaborators,
|
||||
* filling in empty stubs for any not provided.
|
||||
*/
|
||||
private function makeService(array $overrides = []): Dashboard
|
||||
{
|
||||
return new Dashboard(
|
||||
$overrides['tickets'] ?? $this->make(TicketService::class),
|
||||
$overrides['settings'] ?? $this->make(SettingService::class),
|
||||
$overrides['projects'] ?? $this->make(ProjectService::class),
|
||||
$overrides['users'] ?? $this->make(UserService::class),
|
||||
$overrides['reports'] ?? $this->make(ReportService::class),
|
||||
$overrides['widgets'] ?? $this->make(Widgets::class),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_resolve_quick_add_due_date_keeps_existing_date(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
$result = $service->resolveQuickAddDueDate(['dateToFinish' => '2026-01-02', 'group' => 'thisWeek']);
|
||||
|
||||
$this->assertSame('2026-01-02', $result);
|
||||
}
|
||||
|
||||
public function test_resolve_quick_add_due_date_this_week_maps_to_next_friday(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
$result = $service->resolveQuickAddDueDate(['dateToFinish' => '', 'group' => 'thisWeek']);
|
||||
|
||||
$this->assertSame(date('Y-m-d', strtotime('next friday')), $result);
|
||||
}
|
||||
|
||||
public function test_resolve_quick_add_due_date_overdue_maps_to_today(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
$result = $service->resolveQuickAddDueDate(['group' => 'overdue']);
|
||||
|
||||
$this->assertSame(date('Y-m-d'), $result);
|
||||
}
|
||||
|
||||
public function test_resolve_quick_add_due_date_later_stays_empty(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
$this->assertSame('', $service->resolveQuickAddDueDate(['group' => 'later']));
|
||||
$this->assertSame('', $service->resolveQuickAddDueDate([]));
|
||||
}
|
||||
|
||||
public function test_map_group_to_fields_priority(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
$this->assertSame(['priority' => 2], $service->mapGroupToFields('priority', '2'));
|
||||
$this->assertSame(['priority' => ''], $service->mapGroupToFields('priority', '999'));
|
||||
$this->assertSame([], $service->mapGroupToFields('priority', '7'));
|
||||
}
|
||||
|
||||
public function test_map_group_to_fields_project(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
$this->assertSame(['projectId' => 5], $service->mapGroupToFields('project', '5'));
|
||||
$this->assertSame([], $service->mapGroupToFields('project', '0'));
|
||||
}
|
||||
|
||||
public function test_map_group_to_fields_time(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
$this->assertSame(
|
||||
['dateToFinish' => date('Y-m-d', strtotime('yesterday'))],
|
||||
$service->mapGroupToFields('time', 'overdue')
|
||||
);
|
||||
$this->assertSame(['dateToFinish' => ''], $service->mapGroupToFields('time', 'later'));
|
||||
$this->assertSame([], $service->mapGroupToFields('time', 'bogus'));
|
||||
}
|
||||
|
||||
public function test_map_group_to_fields_unknown_group_by_returns_empty(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
$this->assertSame([], $service->mapGroupToFields('unknown', 'whatever'));
|
||||
}
|
||||
|
||||
public function test_has_more_tickets_preserves_full_page_semantics(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
// Two groups. countNested over the whole collection counts each group node
|
||||
// plus its nested tickets: group1 = 1 + 2 = 3, group2 = 1 + 1 = 2, total = 5.
|
||||
// The legacy loop re-counts the whole collection once per group, so the
|
||||
// returned total is 5 * 2 = 10. This quirk is preserved deliberately.
|
||||
$groups = [
|
||||
['tickets' => [['id' => 1], ['id' => 2]]],
|
||||
['tickets' => [['id' => 3]]],
|
||||
];
|
||||
|
||||
$this->assertTrue($service->hasMoreTickets($groups, 10));
|
||||
$this->assertTrue($service->hasMoreTickets($groups, 9));
|
||||
$this->assertFalse($service->hasMoreTickets($groups, 11));
|
||||
$this->assertFalse($service->hasMoreTickets([], 1));
|
||||
}
|
||||
|
||||
public function test_add_todo_resolves_due_date_then_delegates(): void
|
||||
{
|
||||
$captured = null;
|
||||
$tickets = $this->make(TicketService::class, [
|
||||
'quickAddTicket' => function ($params) use (&$captured) {
|
||||
$captured = $params;
|
||||
|
||||
return ['status' => 'success'];
|
||||
},
|
||||
]);
|
||||
|
||||
$service = $this->makeService(['tickets' => $tickets]);
|
||||
|
||||
$result = $service->addTodo(['quickadd' => '1', 'dateToFinish' => '', 'group' => 'overdue']);
|
||||
|
||||
$this->assertSame(['status' => 'success'], $result);
|
||||
$this->assertSame(date('Y-m-d'), $captured['dateToFinish']);
|
||||
}
|
||||
|
||||
public function test_toggle_task_collapse_flips_state_and_persists(): void
|
||||
{
|
||||
$saved = [];
|
||||
$settings = $this->make(SettingService::class, [
|
||||
'getSetting' => fn () => 'open',
|
||||
'saveSetting' => function ($key, $value) use (&$saved) {
|
||||
$saved[$key] = $value;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$service = $this->makeService(['settings' => $settings]);
|
||||
|
||||
$newState = $service->toggleTaskCollapse(7, '42');
|
||||
|
||||
$this->assertSame('closed', $newState);
|
||||
$this->assertSame('closed', $saved['user.7.taskCollapsed.42']);
|
||||
}
|
||||
|
||||
public function test_save_todo_sorting_normalizes_order_and_persists(): void
|
||||
{
|
||||
$savedValue = null;
|
||||
$settings = $this->make(SettingService::class, [
|
||||
'saveSetting' => function ($key, $value) use (&$savedValue) {
|
||||
$savedValue = $value;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
// No dependencies / patches expected when there are no parents.
|
||||
$tickets = $this->make(TicketService::class, [
|
||||
'patch' => fn () => true,
|
||||
]);
|
||||
|
||||
$service = $this->makeService(['settings' => $settings, 'tickets' => $tickets]);
|
||||
|
||||
$rawItems = [
|
||||
json_encode(['id' => 1, 'order' => 0]),
|
||||
json_encode(['id' => 2, 'order' => 5]),
|
||||
];
|
||||
|
||||
$result = $service->saveTodoSorting(99, $rawItems, [], 'time');
|
||||
|
||||
$this->assertTrue($result['sorted']);
|
||||
$this->assertSame(0, $result['successCount']);
|
||||
$this->assertSame(0, $result['errorCount']);
|
||||
|
||||
$persisted = json_decode($savedValue, true);
|
||||
$this->assertSame(10, $persisted[0]['order']);
|
||||
$this->assertSame(15, $persisted[1]['order']);
|
||||
}
|
||||
|
||||
public function test_save_todo_sorting_returns_not_sorted_for_non_array_payload(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
$result = $service->saveTodoSorting(99, 'not-an-array', [], 'time');
|
||||
|
||||
$this->assertFalse($result['sorted']);
|
||||
$this->assertSame(0, $result['successCount']);
|
||||
$this->assertSame(0, $result['errorCount']);
|
||||
}
|
||||
|
||||
public function test_update_ticket_dependencies_sets_and_clears_parents(): void
|
||||
{
|
||||
$patches = [];
|
||||
$tickets = $this->make(TicketService::class, [
|
||||
'patch' => function ($id, $fields) use (&$patches) {
|
||||
$patches[$id] = $fields;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$service = $this->makeService(['tickets' => $tickets]);
|
||||
|
||||
$service->updateTicketDependencies([
|
||||
['id' => 1, 'parentId' => 5, 'parentType' => 'ticket'],
|
||||
['id' => 2, 'parentId' => null, 'parentType' => null],
|
||||
['id' => 3, 'parentId' => 3, 'parentType' => 'ticket'], // self-reference skipped
|
||||
]);
|
||||
|
||||
$this->assertSame(['dependingTicketId' => 5], $patches[1]);
|
||||
$this->assertSame(['dependingTicketId' => '', 'milestoneid' => ''], $patches[2]);
|
||||
$this->assertArrayNotHasKey(3, $patches);
|
||||
}
|
||||
|
||||
public function test_get_welcome_widget_data_aggregates_counts(): void
|
||||
{
|
||||
session(['userdata' => ['id' => 4]]);
|
||||
session(['usersettings.timezone' => 'UTC']);
|
||||
|
||||
$tickets = $this->make(TicketService::class, [
|
||||
'simpleTicketCounter' => fn () => 12,
|
||||
'getRecentlyCompletedTicketsByUser' => fn () => [['id' => 1], ['id' => 2]],
|
||||
'goalsRelatedToWork' => fn () => [['id' => 9]],
|
||||
'getScheduledTasks' => fn () => [
|
||||
'totalTasks' => [['id' => 1], ['id' => 2], ['id' => 3]],
|
||||
'doneTasks' => [['id' => 1]],
|
||||
],
|
||||
]);
|
||||
$projects = $this->make(ProjectService::class, [
|
||||
'getProjectsAssignedToUser' => fn () => [['id' => 1], ['id' => 2]],
|
||||
]);
|
||||
$users = $this->make(UserService::class, [
|
||||
'getUser' => fn () => ['id' => 4, 'username' => 'tester'],
|
||||
]);
|
||||
$widgets = $this->make(Widgets::class, [
|
||||
'getNewWidgets' => fn () => ['todos' => true],
|
||||
]);
|
||||
|
||||
$service = $this->makeService([
|
||||
'tickets' => $tickets,
|
||||
'projects' => $projects,
|
||||
'users' => $users,
|
||||
'widgets' => $widgets,
|
||||
]);
|
||||
|
||||
$data = $service->getWelcomeWidgetData(4);
|
||||
|
||||
$this->assertSame(12, $data['totalTickets']);
|
||||
$this->assertSame(2, $data['closedTicketsCount']);
|
||||
$this->assertSame(1, $data['ticketsInGoals']);
|
||||
$this->assertSame(3, $data['totalTodayCount']);
|
||||
$this->assertSame(1, $data['doneTodayCount']);
|
||||
$this->assertSame(2, $data['projectCount']);
|
||||
$this->assertTrue($data['showSettingsIndicator']);
|
||||
$this->assertSame(['id' => 4, 'username' => 'tester'], $data['currentUser']);
|
||||
}
|
||||
|
||||
public function test_get_welcome_widget_data_handles_non_array_results(): void
|
||||
{
|
||||
session(['userdata' => ['id' => 4]]);
|
||||
session(['usersettings.timezone' => 'UTC']);
|
||||
|
||||
$tickets = $this->make(TicketService::class, [
|
||||
'simpleTicketCounter' => fn () => 0,
|
||||
'getRecentlyCompletedTicketsByUser' => fn () => [],
|
||||
'goalsRelatedToWork' => fn () => false,
|
||||
'getScheduledTasks' => fn () => [],
|
||||
]);
|
||||
$projects = $this->make(ProjectService::class, [
|
||||
'getProjectsAssignedToUser' => fn () => [],
|
||||
]);
|
||||
$users = $this->make(UserService::class, [
|
||||
'getUser' => fn () => ['id' => 4],
|
||||
]);
|
||||
$widgets = $this->make(Widgets::class, [
|
||||
'getNewWidgets' => fn () => [],
|
||||
]);
|
||||
|
||||
$service = $this->makeService([
|
||||
'tickets' => $tickets,
|
||||
'projects' => $projects,
|
||||
'users' => $users,
|
||||
'widgets' => $widgets,
|
||||
]);
|
||||
|
||||
$data = $service->getWelcomeWidgetData(4);
|
||||
|
||||
$this->assertSame(0, $data['closedTicketsCount']);
|
||||
$this->assertSame(0, $data['ticketsInGoals']);
|
||||
$this->assertSame(0, $data['totalTodayCount']);
|
||||
$this->assertSame(0, $data['doneTodayCount']);
|
||||
$this->assertSame([], $data['allProjects']);
|
||||
$this->assertSame(0, $data['projectCount']);
|
||||
$this->assertFalse($data['showSettingsIndicator']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Widgets\Services;
|
||||
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Leantime\Domain\Reports\Services\Reports as ReportService;
|
||||
use Leantime\Domain\Setting\Repositories\Setting;
|
||||
use Leantime\Domain\Widgets\Services\Widgets;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the Widgets service aggregation extracted from the
|
||||
* Widgets/MyProjects HxController.
|
||||
*/
|
||||
class WidgetsServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
private function makeService(ProjectService $projectService, ReportService $reportService): Widgets
|
||||
{
|
||||
return new Widgets($this->make(Setting::class), $projectService, $reportService);
|
||||
}
|
||||
|
||||
public function test_my_projects_widget_data_enriches_each_project(): void
|
||||
{
|
||||
$projectService = $this->make(ProjectService::class, [
|
||||
'getProjectsAssignedToUser' => fn () => [
|
||||
['id' => 1, 'clientId' => 10, 'clientName' => 'Acme'],
|
||||
['id' => 2, 'clientId' => 20, 'clientName' => 'Globex'],
|
||||
],
|
||||
'getProjectProgress' => fn ($id) => ['percent' => 42, 'projectId' => $id],
|
||||
]);
|
||||
$reportService = $this->make(ReportService::class, [
|
||||
'getRealtimeReport' => fn ($id, $sprint) => ['report' => true, 'projectId' => $id],
|
||||
]);
|
||||
|
||||
$result = $this->makeService($projectService, $reportService)->getMyProjectsWidgetData(5);
|
||||
|
||||
$this->assertCount(2, $result['projects']);
|
||||
$this->assertSame(42, $result['projects'][0]['progress']['percent']);
|
||||
$this->assertSame(1, $result['projects'][0]['report']['projectId']);
|
||||
$this->assertSame([10 => 'Acme', 20 => 'Globex'], $result['clients']);
|
||||
}
|
||||
|
||||
public function test_my_projects_widget_data_filters_by_client(): void
|
||||
{
|
||||
$projectService = $this->make(ProjectService::class, [
|
||||
'getProjectsAssignedToUser' => fn () => [
|
||||
['id' => 1, 'clientId' => 10, 'clientName' => 'Acme'],
|
||||
['id' => 2, 'clientId' => 20, 'clientName' => 'Globex'],
|
||||
],
|
||||
'getProjectProgress' => fn ($id) => ['percent' => 0],
|
||||
]);
|
||||
$reportService = $this->make(ReportService::class, [
|
||||
'getRealtimeReport' => fn ($id, $sprint) => [],
|
||||
]);
|
||||
|
||||
$result = $this->makeService($projectService, $reportService)->getMyProjectsWidgetData(5, '20');
|
||||
|
||||
// Both clients are still mapped, but only the matching project is enriched/returned.
|
||||
$this->assertCount(1, $result['projects']);
|
||||
$this->assertSame(2, $result['projects'][0]['id']);
|
||||
$this->assertArrayHasKey(10, $result['clients']);
|
||||
$this->assertArrayHasKey(20, $result['clients']);
|
||||
}
|
||||
|
||||
public function test_my_projects_widget_data_handles_no_projects(): void
|
||||
{
|
||||
$projectService = $this->make(ProjectService::class, [
|
||||
'getProjectsAssignedToUser' => fn () => [],
|
||||
]);
|
||||
$reportService = $this->make(ReportService::class);
|
||||
|
||||
$result = $this->makeService($projectService, $reportService)->getMyProjectsWidgetData(5);
|
||||
|
||||
$this->assertSame([], $result['projects']);
|
||||
$this->assertSame([], $result['clients']);
|
||||
}
|
||||
}
|
||||
298
tests/Unit/app/Domain/Wiki/Services/WikiServiceTest.php
Normal file
298
tests/Unit/app/Domain/Wiki/Services/WikiServiceTest.php
Normal file
@@ -0,0 +1,298 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Wiki\Services;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\PermissionService;
|
||||
use Leantime\Core\Exceptions\AuthorizationException;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Domain\Audit\Repositories\Audit as AuditRepository;
|
||||
use Leantime\Domain\Wiki\Models\Article;
|
||||
use Leantime\Domain\Wiki\Models\Wiki as WikiModel;
|
||||
use Leantime\Domain\Wiki\Repositories\Wiki as WikiRepository;
|
||||
use Leantime\Domain\Wiki\Services\Wiki as WikiService;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the Wiki service's project-scoped authorization. Wiki articles and notebooks are
|
||||
* project-scoped; mutations and single-entity reads authorize against the entity's REAL project
|
||||
* (entityScoped), closing the IDORs where the id alone identified the row.
|
||||
*/
|
||||
class WikiServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
private function makeService(
|
||||
?WikiRepository $wikiRepo = null,
|
||||
?AuditRepository $auditRepo = null,
|
||||
): WikiService {
|
||||
return new WikiService(
|
||||
$wikiRepo ?? $this->make(WikiRepository::class),
|
||||
$this->make(Language::class),
|
||||
$auditRepo ?? $this->make(AuditRepository::class),
|
||||
);
|
||||
}
|
||||
|
||||
private function allowingPermissions(): PermissionService
|
||||
{
|
||||
return $this->make(PermissionService::class, ['authorize' => fn () => null]);
|
||||
}
|
||||
|
||||
private function denyingPermissions(): PermissionService
|
||||
{
|
||||
return $this->make(PermissionService::class, [
|
||||
'authorize' => function (): void {
|
||||
throw new AuthorizationException;
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Reads: single-entity-by-id reads fence against the entity's project.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public function test_get_wiki_is_denied_when_user_cannot_view_its_project(): void
|
||||
{
|
||||
$service = $this->makeService(wikiRepo: $this->make(WikiRepository::class, [
|
||||
'getWiki' => fn () => $this->make(WikiModel::class, ['id' => 3, 'projectId' => 9]),
|
||||
]));
|
||||
$service->setPermissionService($this->denyingPermissions());
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$service->getWiki(3);
|
||||
}
|
||||
|
||||
public function test_get_wiki_returns_false_for_unknown_id_without_authorizing(): void
|
||||
{
|
||||
// A missing wiki short-circuits to false BEFORE authorize — no enumeration oracle.
|
||||
$authorizeCalls = 0;
|
||||
$service = $this->makeService(wikiRepo: $this->make(WikiRepository::class, [
|
||||
'getWiki' => fn () => false,
|
||||
]));
|
||||
$service->setPermissionService($this->make(PermissionService::class, [
|
||||
'authorize' => function () use (&$authorizeCalls): void {
|
||||
$authorizeCalls++;
|
||||
},
|
||||
]));
|
||||
|
||||
$this->assertFalse($service->getWiki(999));
|
||||
$this->assertSame(0, $authorizeCalls, 'A non-existent wiki must short-circuit before authorize');
|
||||
}
|
||||
|
||||
public function test_get_all_wiki_headlines_is_denied_for_foreign_wiki(): void
|
||||
{
|
||||
$service = $this->makeService(wikiRepo: $this->make(WikiRepository::class, [
|
||||
'getWiki' => fn () => $this->make(WikiModel::class, ['id' => 3, 'projectId' => 9]),
|
||||
]));
|
||||
$service->setPermissionService($this->denyingPermissions());
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$service->getAllWikiHeadlines(3, 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Mutations: authorize against the entity's real project before writing.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public function test_create_article_is_denied_without_create_permission(): void
|
||||
{
|
||||
$service = $this->makeService(wikiRepo: $this->make(WikiRepository::class, [
|
||||
// createArticle resolves the project from the target wiki (canvasId) to authorize.
|
||||
'getWiki' => fn () => $this->make(WikiModel::class, ['id' => 3, 'projectId' => 9]),
|
||||
'createArticle' => function () {
|
||||
throw new \RuntimeException('create must not be reached when denied');
|
||||
},
|
||||
]));
|
||||
$service->setPermissionService($this->denyingPermissions());
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$article = new Article;
|
||||
$article->canvasId = 3;
|
||||
$service->createArticle($article);
|
||||
}
|
||||
|
||||
public function test_create_article_fails_closed_when_canvas_is_not_a_wiki(): void
|
||||
{
|
||||
// canvasId does not resolve to a wiki -> refuse before authorize, never write (no falling
|
||||
// back to the session project, which would let a foreign/non-wiki canvasId be created).
|
||||
$created = false;
|
||||
$service = $this->makeService(wikiRepo: $this->make(WikiRepository::class, [
|
||||
'getWiki' => fn () => false,
|
||||
'createArticle' => function () use (&$created) {
|
||||
$created = true;
|
||||
|
||||
return '1';
|
||||
},
|
||||
]));
|
||||
$service->setPermissionService($this->allowingPermissions());
|
||||
|
||||
$article = new Article;
|
||||
$article->canvasId = 999;
|
||||
|
||||
$this->assertFalse($service->createArticle($article));
|
||||
$this->assertFalse($created, 'A non-wiki canvasId must never create an article');
|
||||
}
|
||||
|
||||
public function test_update_article_is_denied_without_edit_permission(): void
|
||||
{
|
||||
$service = $this->makeService(wikiRepo: $this->make(WikiRepository::class, [
|
||||
'getArticleProjectId' => fn () => 9,
|
||||
'updateArticle' => function () {
|
||||
throw new \RuntimeException('update must not be reached when denied');
|
||||
},
|
||||
]));
|
||||
$service->setPermissionService($this->denyingPermissions());
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$article = new Article;
|
||||
$article->id = 42;
|
||||
$service->updateArticle($article);
|
||||
}
|
||||
|
||||
public function test_create_wiki_is_denied_without_create_permission(): void
|
||||
{
|
||||
$service = $this->makeService(wikiRepo: $this->make(WikiRepository::class, [
|
||||
'createWiki' => function () {
|
||||
throw new \RuntimeException('create must not be reached when denied');
|
||||
},
|
||||
]));
|
||||
$service->setPermissionService($this->denyingPermissions());
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$wiki = new WikiModel;
|
||||
$wiki->projectId = 9;
|
||||
$service->createWiki($wiki);
|
||||
}
|
||||
|
||||
public function test_update_article_returns_false_for_unknown_id_without_authorizing(): void
|
||||
{
|
||||
// FAIL CLOSED: zp_canvas_items is a shared table (one id sequence across all canvas types),
|
||||
// so an unresolved project (non-article / unknown id) must refuse BEFORE authorize and never
|
||||
// reach the repo write — otherwise a non-article id would overwrite a goal/SWOT/risk row.
|
||||
$authorizeCalls = 0;
|
||||
$service = $this->makeService(wikiRepo: $this->make(WikiRepository::class, [
|
||||
'getArticleProjectId' => fn () => null,
|
||||
'updateArticle' => function (): bool {
|
||||
throw new \RuntimeException('update must not run for an unresolved/non-article id');
|
||||
},
|
||||
]));
|
||||
$service->setPermissionService($this->make(PermissionService::class, [
|
||||
'authorize' => function () use (&$authorizeCalls): void {
|
||||
$authorizeCalls++;
|
||||
},
|
||||
]));
|
||||
|
||||
$article = new Article;
|
||||
$article->id = 999;
|
||||
|
||||
$this->assertFalse($service->updateArticle($article));
|
||||
$this->assertSame(0, $authorizeCalls, 'A non-article id must short-circuit before authorize');
|
||||
}
|
||||
|
||||
public function test_update_wiki_returns_false_for_unknown_wiki_without_authorizing(): void
|
||||
{
|
||||
// FAIL CLOSED: zp_canvas is shared across canvas types, so a non-wiki / unknown id must
|
||||
// refuse BEFORE authorize and never reach the repo title write.
|
||||
$authorizeCalls = 0;
|
||||
$service = $this->makeService(wikiRepo: $this->make(WikiRepository::class, [
|
||||
'getWiki' => fn () => false,
|
||||
'updateWiki' => function (): bool {
|
||||
throw new \RuntimeException('update must not run for a non-wiki id');
|
||||
},
|
||||
]));
|
||||
$service->setPermissionService($this->make(PermissionService::class, [
|
||||
'authorize' => function () use (&$authorizeCalls): void {
|
||||
$authorizeCalls++;
|
||||
},
|
||||
]));
|
||||
|
||||
$wiki = new WikiModel;
|
||||
|
||||
$this->assertFalse($service->updateWiki($wiki, 999));
|
||||
$this->assertSame(0, $authorizeCalls, 'A non-wiki id must short-circuit before authorize');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Delete: new service methods that fence the previously controller->repo IDOR.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public function test_delete_article_is_denied_and_does_not_delete_without_permission(): void
|
||||
{
|
||||
$service = $this->makeService(wikiRepo: $this->make(WikiRepository::class, [
|
||||
'getArticleProjectId' => fn () => 9,
|
||||
'delArticle' => function (): void {
|
||||
throw new \RuntimeException('delete must not be reached when denied');
|
||||
},
|
||||
]));
|
||||
$service->setPermissionService($this->denyingPermissions());
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$service->deleteArticle(42);
|
||||
}
|
||||
|
||||
public function test_delete_article_deletes_and_audits_when_authorized(): void
|
||||
{
|
||||
$deletedId = null;
|
||||
$auditedAction = null;
|
||||
|
||||
$service = $this->makeService(
|
||||
wikiRepo: $this->make(WikiRepository::class, [
|
||||
'getArticleProjectId' => fn () => 9,
|
||||
'delArticle' => function ($id) use (&$deletedId): void {
|
||||
$deletedId = $id;
|
||||
},
|
||||
]),
|
||||
auditRepo: $this->make(AuditRepository::class, [
|
||||
// Accept the full storeEvent signature (variadic) — deleteArticle calls it with
|
||||
// several named args; only the action is asserted.
|
||||
'storeEvent' => function (string $action, ...$rest) use (&$auditedAction) {
|
||||
$auditedAction = $action;
|
||||
},
|
||||
]),
|
||||
);
|
||||
$service->setPermissionService($this->allowingPermissions());
|
||||
|
||||
$this->assertTrue($service->deleteArticle(42));
|
||||
$this->assertSame(42, $deletedId);
|
||||
$this->assertSame('article.delete', $auditedAction);
|
||||
}
|
||||
|
||||
public function test_delete_article_returns_false_for_unknown_id_without_authorizing(): void
|
||||
{
|
||||
$authorizeCalls = 0;
|
||||
$service = $this->makeService(wikiRepo: $this->make(WikiRepository::class, [
|
||||
'getArticleProjectId' => fn () => null,
|
||||
'delArticle' => function (): void {
|
||||
throw new \RuntimeException('delete must not run for a non-existent article');
|
||||
},
|
||||
]));
|
||||
$service->setPermissionService($this->make(PermissionService::class, [
|
||||
'authorize' => function () use (&$authorizeCalls): void {
|
||||
$authorizeCalls++;
|
||||
},
|
||||
]));
|
||||
|
||||
$this->assertFalse($service->deleteArticle(999));
|
||||
$this->assertSame(0, $authorizeCalls);
|
||||
}
|
||||
|
||||
public function test_delete_wiki_is_denied_without_permission(): void
|
||||
{
|
||||
$service = $this->makeService(wikiRepo: $this->make(WikiRepository::class, [
|
||||
'getWiki' => fn () => $this->make(WikiModel::class, ['id' => 3, 'projectId' => 9]),
|
||||
'delWiki' => function (): void {
|
||||
throw new \RuntimeException('delete must not be reached when denied');
|
||||
},
|
||||
]));
|
||||
$service->setPermissionService($this->denyingPermissions());
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$service->deleteWiki(3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unit\app\Plugins\PgmPro\Domain\Resources\Services;
|
||||
|
||||
use Leantime\Core\Db\Db;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Leantime\Plugins\PgmPro\Domain\Resources\Repositories\ResourceStructureRepository;
|
||||
use Leantime\Plugins\PgmPro\Domain\Resources\Services\ResourceStructureService;
|
||||
use Leantime\Plugins\PgmPro\Services\Programs;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Behaviors under test:
|
||||
*
|
||||
* getForProjects — the ResourcesGateway contract:
|
||||
* 1. Empty projectIds → empty ResourceSummary (no walk).
|
||||
* 2. Projects with no resource canvas → empty ResourceSummary (honest
|
||||
* "resources not authored here" state, not an error).
|
||||
* 3. status='stub' PEOPLE are excluded from totals — matches the tab's
|
||||
* teamStats split. A seeded stub defaults to capacity:40, and counting
|
||||
* it would make the report disagree with the tab the moment anyone
|
||||
* uses LM-Inputs seeding.
|
||||
* 4. status='stub' BUDGET LINES are excluded from totals AND from the
|
||||
* budget[] array — stubs are 0/0 so they don't move totals, but they
|
||||
* inflate `count(budget)` and can flip `isEmpty()`.
|
||||
*
|
||||
* Seeders — the "skip already present" contract:
|
||||
* 5. seedPeopleFromChildProjects is idempotent by userId — a second run
|
||||
* with the same source data adds zero rows and reports the correct
|
||||
* skipped count.
|
||||
* 6. seedBudgetFromChildProjects is idempotent by projectId — same.
|
||||
*
|
||||
* All tests avoid touching the DB via a testable subclass of the SUT that
|
||||
* overrides `findResourceCanvasIds()`; the repository and the two service
|
||||
* collaborators are stubbed with anonymous classes so the test asserts on
|
||||
* the exact call surface.
|
||||
*/
|
||||
class ResourceStructureServiceTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
// The OSS CI checkout has an empty app/Plugins (private submodule) — the classes
|
||||
// under test only exist when the plugins are present. Skip, don't error.
|
||||
if (! class_exists(ResourceStructureService::class)) {
|
||||
$this->markTestSkipped('PgmPro plugin is not present in this checkout.');
|
||||
}
|
||||
|
||||
parent::setUp();
|
||||
}
|
||||
|
||||
public function test_get_for_projects_returns_empty_summary_when_project_ids_is_empty(): void
|
||||
{
|
||||
$svc = $this->makeService(canvasIds: [1], items: []);
|
||||
$summary = $svc->getForProjects([]);
|
||||
|
||||
$this->assertSame([], $summary->projectIds);
|
||||
$this->assertTrue($summary->isEmpty());
|
||||
}
|
||||
|
||||
public function test_get_for_projects_returns_empty_summary_when_no_resource_canvas(): void
|
||||
{
|
||||
$svc = $this->makeService(canvasIds: [], items: []);
|
||||
$summary = $svc->getForProjects([1, 2]);
|
||||
|
||||
$this->assertSame([1, 2], $summary->projectIds);
|
||||
$this->assertTrue($summary->isEmpty());
|
||||
$this->assertSame(0.0, $summary->totalCapacity);
|
||||
}
|
||||
|
||||
public function test_get_for_projects_excludes_stub_people_from_totals(): void
|
||||
{
|
||||
// Active person: capacity 40, allocated 30. Stub person: default 40.
|
||||
// The report and the tab must agree — the tab excludes stubs from
|
||||
// teamStats, so the gateway must too. Otherwise a seeded-but-unlinked
|
||||
// person shows 40h in the report and 0h in the tab.
|
||||
$svc = $this->makeService(
|
||||
canvasIds: [100],
|
||||
items: [
|
||||
100 => [
|
||||
'people' => [
|
||||
[
|
||||
'id' => 1,
|
||||
'description' => 'Sarah Chen',
|
||||
'status' => 'active',
|
||||
'parsedData' => ['userId' => 1, 'capacity' => 40, 'allocations' => ['7' => 30]],
|
||||
],
|
||||
[
|
||||
'id' => 2,
|
||||
'description' => 'Unlinked seed',
|
||||
'status' => 'stub',
|
||||
'parsedData' => ['capacity' => 40, 'allocations' => []],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$summary = $svc->getForProjects([7]);
|
||||
|
||||
$this->assertCount(1, $summary->people, 'Stubs must not appear in the people array');
|
||||
$this->assertSame(40.0, $summary->totalCapacity, 'Stub 40h capacity must not be counted');
|
||||
$this->assertSame(30.0, $summary->totalAllocated);
|
||||
}
|
||||
|
||||
public function test_get_for_projects_excludes_stub_budget_lines_from_array_and_totals(): void
|
||||
{
|
||||
$svc = $this->makeService(
|
||||
canvasIds: [100],
|
||||
items: [
|
||||
100 => [
|
||||
'budget' => [
|
||||
[
|
||||
'id' => 10,
|
||||
'description' => 'Community Health Fairs',
|
||||
'status' => 'active',
|
||||
'parsedData' => ['projectId' => 7, 'budgeted' => 10000, 'spent' => 2000],
|
||||
],
|
||||
[
|
||||
'id' => 11,
|
||||
'description' => '',
|
||||
'status' => 'stub',
|
||||
'parsedData' => ['projectId' => 8, 'budgeted' => 0, 'spent' => 0],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$summary = $svc->getForProjects([7, 8]);
|
||||
|
||||
$this->assertCount(1, $summary->budget, 'Stub budget rows must not inflate the array count (would flip isEmpty)');
|
||||
$this->assertSame(10000.0, $summary->totalBudgeted);
|
||||
$this->assertSame(2000.0, $summary->totalSpent);
|
||||
}
|
||||
|
||||
public function test_get_for_projects_aggregates_across_multiple_canvases(): void
|
||||
{
|
||||
// A strategy with two programs, each with its own resource canvas.
|
||||
// The gateway must sum across both.
|
||||
$svc = $this->makeService(
|
||||
canvasIds: [100, 200],
|
||||
items: [
|
||||
100 => [
|
||||
'people' => [
|
||||
['id' => 1, 'description' => 'A', 'status' => 'active',
|
||||
'parsedData' => ['userId' => 1, 'capacity' => 40, 'allocations' => ['7' => 20]]],
|
||||
],
|
||||
],
|
||||
200 => [
|
||||
'people' => [
|
||||
['id' => 2, 'description' => 'B', 'status' => 'active',
|
||||
'parsedData' => ['userId' => 2, 'capacity' => 30, 'allocations' => ['9' => 15]]],
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$summary = $svc->getForProjects([2, 15, 7, 9]);
|
||||
|
||||
$this->assertCount(2, $summary->people);
|
||||
$this->assertSame(70.0, $summary->totalCapacity);
|
||||
$this->assertSame(35.0, $summary->totalAllocated);
|
||||
}
|
||||
|
||||
public function test_seed_people_from_child_projects_is_idempotent_by_user_id(): void
|
||||
{
|
||||
// Two child projects; user 5 assigned to both, user 7 to one.
|
||||
// First run: 2 people added (5 and 7). Second run: 0 added, 2 skipped
|
||||
// per encounter — the "skip already present" contract is what makes
|
||||
// the seeder safe to re-run on demand from the UI.
|
||||
$existingItems = [];
|
||||
$addCalls = [];
|
||||
$repo = $this->makeRepo(
|
||||
canvasId: 500,
|
||||
itemsByBoxProvider: function (int $canvasId, string $box) use (&$existingItems) {
|
||||
return $existingItems;
|
||||
},
|
||||
onAddItem: function (int $canvasId, array $values) use (&$existingItems, &$addCalls): int {
|
||||
$addCalls[] = $values;
|
||||
$existingItems[] = [
|
||||
'id' => count($existingItems) + 1,
|
||||
'description' => $values['description'],
|
||||
'status' => $values['status'],
|
||||
'parsedData' => $values['data'],
|
||||
];
|
||||
|
||||
return count($existingItems);
|
||||
},
|
||||
);
|
||||
|
||||
$programs = $this->makePrograms(childProjects: [
|
||||
['id' => 7, 'name' => 'Project A'],
|
||||
['id' => 8, 'name' => 'Project B'],
|
||||
]);
|
||||
|
||||
$projects = $this->makeProjects(usersByProject: [
|
||||
7 => [['id' => 5, 'firstname' => 'Sarah', 'lastname' => 'Chen', 'jobTitle' => 'PM']],
|
||||
8 => [
|
||||
['id' => 5, 'firstname' => 'Sarah', 'lastname' => 'Chen', 'jobTitle' => 'PM'],
|
||||
['id' => 7, 'firstname' => 'Aisha', 'lastname' => 'Patel', 'jobTitle' => 'Dev'],
|
||||
],
|
||||
]);
|
||||
|
||||
$svc = new ResourceStructureService($repo, $this->makeDb(), $projects, $programs);
|
||||
|
||||
$first = $svc->seedPeopleFromChildProjects(500);
|
||||
$this->assertSame(2, $first['added'], 'First run adds the two distinct users');
|
||||
$this->assertSame(1, $first['skipped'], 'Sarah appearing on the second project is skipped');
|
||||
$this->assertSame(500, $first['canvasId']);
|
||||
|
||||
$second = $svc->seedPeopleFromChildProjects(500);
|
||||
$this->assertSame(0, $second['added'], 'Second run must add nothing (idempotent by userId)');
|
||||
$this->assertSame(3, $second['skipped'], 'Every source-user encounter is a skip on re-run');
|
||||
|
||||
// Also validates the add-payload shape: userId is captured, status is 'active' (not 'stub').
|
||||
$this->assertCount(2, $addCalls);
|
||||
$this->assertSame('active', $addCalls[0]['status']);
|
||||
$this->assertSame(5, $addCalls[0]['data']['userId']);
|
||||
}
|
||||
|
||||
public function test_get_for_projects_hydrates_a_dependency_with_none_of_the_optional_fields(): void
|
||||
{
|
||||
// Back-compat guarantee for pre-existing dependency canvas items —
|
||||
// owner/dueDate/notes/lastModified were added later. An item authored
|
||||
// before those existed must hydrate cleanly (no undefined-index
|
||||
// warning, all four optional slots null) so Page 3 renders the empty
|
||||
// state instead of crashing or flashing blank cells.
|
||||
$svc = $this->makeService(
|
||||
canvasIds: [100],
|
||||
items: [
|
||||
100 => [
|
||||
'dependency' => [
|
||||
[
|
||||
'id' => 42,
|
||||
'description' => 'Community Health Fair Partner',
|
||||
'status' => 'active',
|
||||
'parsedData' => [
|
||||
'partnerName' => 'City Health Dept',
|
||||
'type' => 'partnership',
|
||||
'confirmed' => true,
|
||||
// owner, dueDate, notes intentionally absent.
|
||||
],
|
||||
// 'modified' intentionally absent too — pre-hydrated rows
|
||||
// that predate lastModified capture.
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$summary = $svc->getForProjects([7]);
|
||||
|
||||
$this->assertCount(1, $summary->dependencies);
|
||||
$dep = $summary->dependencies[0];
|
||||
$this->assertSame(42, $dep->itemId);
|
||||
$this->assertSame('City Health Dept', $dep->partnerName);
|
||||
$this->assertSame('partnership', $dep->type);
|
||||
$this->assertTrue($dep->confirmed);
|
||||
$this->assertNull($dep->owner);
|
||||
$this->assertNull($dep->dueDate);
|
||||
$this->assertNull($dep->notes);
|
||||
$this->assertNull($dep->lastModified);
|
||||
}
|
||||
|
||||
public function test_seed_budget_from_child_projects_is_idempotent_by_project_id(): void
|
||||
{
|
||||
// Same skip-when-present contract, keyed on projectId. A child with
|
||||
// dollarBudget=0 is skipped entirely (no line to seed).
|
||||
$existingItems = [];
|
||||
$addCalls = [];
|
||||
$repo = $this->makeRepo(
|
||||
canvasId: 500,
|
||||
itemsByBoxProvider: function (int $canvasId, string $box) use (&$existingItems) {
|
||||
return $existingItems;
|
||||
},
|
||||
onAddItem: function (int $canvasId, array $values) use (&$existingItems, &$addCalls): int {
|
||||
$addCalls[] = $values;
|
||||
$existingItems[] = [
|
||||
'id' => count($existingItems) + 1,
|
||||
'description' => $values['description'],
|
||||
'status' => $values['status'],
|
||||
'parsedData' => $values['data'],
|
||||
];
|
||||
|
||||
return count($existingItems);
|
||||
},
|
||||
);
|
||||
|
||||
$programs = $this->makePrograms(childProjects: [
|
||||
['id' => 7, 'name' => 'Health Fairs', 'dollarBudget' => 45000, 'color' => '#3E937A'],
|
||||
['id' => 8, 'name' => 'Zero-budget project', 'dollarBudget' => 0, 'color' => '#000'],
|
||||
['id' => 9, 'name' => 'Walk-in Days', 'dollarBudget' => 12000, 'color' => '#C09035'],
|
||||
]);
|
||||
|
||||
$svc = new ResourceStructureService($repo, $this->makeDb(), $this->makeProjects(), $programs);
|
||||
|
||||
$first = $svc->seedBudgetFromChildProjects(500);
|
||||
$this->assertSame(2, $first['added'], 'The zero-budget project is skipped (no line to seed)');
|
||||
$this->assertSame(0, $first['skipped']);
|
||||
|
||||
$second = $svc->seedBudgetFromChildProjects(500);
|
||||
$this->assertSame(0, $second['added'], 'Re-run must add nothing');
|
||||
$this->assertSame(2, $second['skipped'], 'Both real-budget projects skip on re-run');
|
||||
}
|
||||
|
||||
// ─── Test doubles ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Testable subclass of the SUT that overrides `findResourceCanvasIds()`
|
||||
* with a canned list, so the test doesn't need to mock a Db query chain.
|
||||
* `getItemsByBox()` returns items keyed by `[canvasId][box]`.
|
||||
*
|
||||
* @param int[] $canvasIds
|
||||
* @param array<int, array<string, array<int, array<string, mixed>>>> $items
|
||||
*/
|
||||
private function makeService(array $canvasIds, array $items): ResourceStructureService
|
||||
{
|
||||
$repo = $this->makeRepo(
|
||||
canvasId: 0,
|
||||
itemsByBoxProvider: fn (int $canvasId, string $box) => $items[$canvasId][$box] ?? [],
|
||||
);
|
||||
|
||||
return new class($repo, $this->makeDb(), $this->makeProjects(), $this->makePrograms(), $canvasIds) extends ResourceStructureService
|
||||
{
|
||||
/** @param int[] $canvasIds */
|
||||
public function __construct(
|
||||
ResourceStructureRepository $repo,
|
||||
Db $dbCore,
|
||||
ProjectService $projectService,
|
||||
Programs $programService,
|
||||
private array $canvasIds,
|
||||
) {
|
||||
parent::__construct($repo, $dbCore, $projectService, $programService);
|
||||
}
|
||||
|
||||
protected function findResourceCanvasIds(array $projectIds): array
|
||||
{
|
||||
return $this->canvasIds;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function makeRepo(
|
||||
int $canvasId = 0,
|
||||
?\Closure $itemsByBoxProvider = null,
|
||||
?\Closure $onAddItem = null,
|
||||
): ResourceStructureRepository {
|
||||
$repo = $this->createMock(ResourceStructureRepository::class);
|
||||
$repo->method('getOrCreateResourceCanvas')->willReturn($canvasId);
|
||||
$repo->method('getItemsByBox')->willReturnCallback(
|
||||
$itemsByBoxProvider ?? fn (int $canvasId, string $box) => []
|
||||
);
|
||||
if ($onAddItem !== null) {
|
||||
$repo->method('addItem')->willReturnCallback($onAddItem);
|
||||
}
|
||||
|
||||
return $repo;
|
||||
}
|
||||
|
||||
private function makeDb(): Db
|
||||
{
|
||||
// Not used by the SUT paths under test — `findResourceCanvasIds` is
|
||||
// overridden in makeService(), and seeders don't touch dbCore.
|
||||
return $this->createMock(Db::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<int, array<string, mixed>>> $usersByProject projectId => user rows
|
||||
*/
|
||||
private function makeProjects(array $usersByProject = []): ProjectService
|
||||
{
|
||||
$projects = $this->createMock(ProjectService::class);
|
||||
$projects->method('getUsersAssignedToProject')->willReturnCallback(
|
||||
fn (int $projectId) => $usersByProject[$projectId] ?? []
|
||||
);
|
||||
|
||||
return $projects;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $childProjects
|
||||
*/
|
||||
private function makePrograms(array $childProjects = []): Programs
|
||||
{
|
||||
$programs = $this->createMock(Programs::class);
|
||||
$programs->method('getColoredProgramProjects')->willReturn($childProjects);
|
||||
|
||||
return $programs;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user