OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user