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

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

View File

@@ -0,0 +1,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"
);
}
}
}

View File

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

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