diff --git a/CHANGELOG.md b/CHANGELOG.md index 685edcfa..28731bdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +* [PR-334](https://github.com/itk-dev/economics/pull/334) + * Paginated the Leantime delete sync, following [data-api#21](https://github.com/ITK-Leantime/data-api/pull/21): + `/deleted` now serves one type per request with `start`/`limit`, so `delete()` queues a message per type and + `deleteAsJob()` pages through them the way `updateAsJob()` already does. The whole deletion history no longer + has to arrive in a single response — which is what the 300s `max_duration` in `config/packages/framework.yaml` + was sized for, though it stays as it is for the entity endpoints. + * The delete request now sends its timestamp as `deletedAfter`, the endpoint's new name for it, matching + `modifiedAfter` on the entity endpoints. The old `deleted` answers 400 rather than being ignored, so the key + cannot go missing unnoticed again. + * The delete cursor is the endpoint's new `deletionId`, not the deleted entity's `id`: deletions are ordered by + when they happened. It advances past a deletion that names no entity, since a skipped row still has to be paged + past, and a full page with no usable `deletionId` stops with an error rather than re-queueing itself. * [PR-326](https://github.com/itk-dev/economics/pull/326) * Stopped the pagination cursor in `updateAsJob()` looping on a page it cannot advance past. Skipping null ids left the cursor where it started, so a full page of them re-queued the same page forever and starved the diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index bcacd07b..1bc6d3c8 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -2085,7 +2085,7 @@ parameters: - message: '#^Access to an undefined property object\:\:\$resultsCount\.$#' identifier: property.notFound - count: 1 + count: 2 path: src/Service/LeantimeApiService.php - @@ -2121,7 +2121,7 @@ parameters: - message: '#^Match expression does not handle remaining value\: string$#' identifier: match.unhandled - count: 1 + count: 2 path: src/Service/LeantimeApiService.php - @@ -2965,7 +2965,7 @@ parameters: path: tests/Integration/Service/LeantimeApiServiceTest.php - - message: '#^Parameter \#1 \$dataProviderId of method App\\Service\\LeantimeApiService\:\:deleteAsJob\(\) expects int, int\|null given\.$#' + message: '#^Parameter \#4 \$dataProviderId of method App\\Service\\LeantimeApiService\:\:deleteAsJob\(\) expects int, int\|null given\.$#' identifier: argument.type count: 1 path: tests/Integration/Service/LeantimeApiServiceTest.php diff --git a/src/Message/LeantimeDeleteMessage.php b/src/Message/LeantimeDeleteMessage.php index 495a2825..32a54e9c 100644 --- a/src/Message/LeantimeDeleteMessage.php +++ b/src/Message/LeantimeDeleteMessage.php @@ -5,6 +5,9 @@ readonly class LeantimeDeleteMessage { public function __construct( + public string $type, + public int $start, + public int $limit, public int $dataProviderId, public bool $asyncJobQueue, public ?\DateTimeInterface $deletedAfter, diff --git a/src/MessageHandler/LeantimeDeleteHandler.php b/src/MessageHandler/LeantimeDeleteHandler.php index 64d56d9f..63fd8ed3 100644 --- a/src/MessageHandler/LeantimeDeleteHandler.php +++ b/src/MessageHandler/LeantimeDeleteHandler.php @@ -21,9 +21,17 @@ public function __construct( public function __invoke(LeantimeDeleteMessage $message): void { try { - $this->logger->info('Handling delete message. deletedAfter: '.$message->deletedAfter?->format('c')); + $this->logger->info(sprintf( + 'Handling delete message. type: %s, start: %d, deletedAfter: %s', + $message->type, + $message->start, + $message->deletedAfter?->format('c') ?? 'none', + )); $this->leantimeApiService->deleteAsJob( + $message->type, + $message->start, + $message->limit, $message->dataProviderId, $message->asyncJobQueue, $message->deletedAfter, diff --git a/src/Service/LeantimeApiService.php b/src/Service/LeantimeApiService.php index e726dc38..d9af7794 100644 --- a/src/Service/LeantimeApiService.php +++ b/src/Service/LeantimeApiService.php @@ -44,6 +44,11 @@ class LeantimeApiService implements DataProviderInterface public const TICKETS = 'tickets'; public const TIMESHEETS = 'timesheets'; public const WORKERS = 'workers'; + // The types the deleted endpoint tracks, children before the parents they hang off. A parent is + // only hard-removable once its children are gone: DataProviderService refuses to remove a project + // or issue that still has any, and marks it with sourceDeletedDate instead. Nothing revisits that + // mark, so a parent reached too early stays half-deleted for good. + private const DELETED_TYPES = [self::TIMESHEETS, self::TICKETS, self::MILESTONES, self::PROJECTS]; private const LIMIT = 100; // Placeholder for a null name; the name columns are not nullable, and dropping the row would // lose real data — for issues it would make their worklogs unstorable. The tracker id is @@ -99,14 +104,24 @@ public function delete(bool $asyncJobQueue = false, ?\DateTimeInterface $deleted $dataProviders = $this->getEnabledLeantimeDataProviders(); foreach ($dataProviders as $dataProvider) { - $this->messageBus->dispatch( - new LeantimeDeleteMessage($dataProvider->getId(), $asyncJobQueue, $deletedAfter), - [new TransportNamesStamp($asyncJobQueue ? $this::QUEUE_ASYNC : $this::QUEUE_SYNC)], - ); + // One message per type, since the endpoint answers with a single type's page. + // What keeps DELETED_TYPES in order is the sync transport, not the dispatch order: + // deleteAll() passes asyncJobQueue false, so each handler — the removals and the + // next-page dispatch alike — runs inline, and a type's every page is done before the + // next type is dispatched. Fanning all four out up front is only safe under that. + // On the async queue they would interleave, and a project could be reached while its + // timesheets sat a page behind; that path would have to chain the types instead, + // dispatching the next one only once the current is exhausted. + foreach ($this::DELETED_TYPES as $type) { + $this->messageBus->dispatch( + new LeantimeDeleteMessage($type, 0, $this::LIMIT, $dataProvider->getId(), $asyncJobQueue, $deletedAfter), + [new TransportNamesStamp($asyncJobQueue ? $this::QUEUE_ASYNC : $this::QUEUE_SYNC)], + ); + } } } - public function deleteAsJob(int $dataProviderId, bool $asyncJobQueue = false, ?\DateTimeInterface $deletedAfter = null): void + public function deleteAsJob(string $type, int $startId, int $limit, int $dataProviderId, bool $asyncJobQueue = false, ?\DateTimeInterface $deletedAfter = null): void { $dataProvider = $this->dataProviderRepository->find($dataProviderId); @@ -114,65 +129,80 @@ public function deleteAsJob(int $dataProviderId, bool $asyncJobQueue = false, ?\ throw new NotFoundException("DataProvider with id: $dataProviderId not found"); } - $types = [ - self::TIMESHEETS, - self::TICKETS, - self::MILESTONES, - self::PROJECTS, - ]; + $classname = match ($type) { + self::PROJECTS => Project::class, + self::MILESTONES => Version::class, + self::TICKETS => Issue::class, + self::TIMESHEETS => Worklog::class, + }; $params = [ - 'types' => $types, - // The plugin reads 'deleted'; anything else is discarded and the whole deletion - // history is returned, which /deleted does not paginate. - 'deleted' => $deletedAfter?->getTimestamp(), + 'type' => $type, + 'start' => $startId, + 'limit' => $limit, + // The plugin reads 'deletedAfter'; under the old 'deleted' it answers 400, and under + // any other key the timestamp is discarded and every deletion ever recorded is paged + // through. + 'deletedAfter' => $deletedAfter?->getTimestamp(), ]; // Get data from Leantime. $data = $this->fetchFromLeantime($dataProvider, 'deleted', $params); - $results = $data->results; // Queue delete. - foreach ($types as $type) { - if (!isset($results->{$type})) { - continue; + $maxDeletionId = null; + + foreach ($data->results as $result) { + // Tracked before anything below can skip the row: a deletion that cannot be acted + // on still has to be paged past. The cursor is deletionId, not id — the endpoint + // orders deletions by when they happened, not by the entity they refer to. + if (is_numeric($result->deletionId ?? null)) { + $deletionId = (int) $result->deletionId; + $maxDeletionId = null === $maxDeletionId ? $deletionId : max($maxDeletionId, $deletionId); } - $classname = match ($type) { - self::PROJECTS => Project::class, - self::MILESTONES => Version::class, - self::TICKETS => Issue::class, - self::TIMESHEETS => Worklog::class, - }; + // Nothing identifies the entity to remove. + if (null === $result->id) { + $this->logger->error(sprintf('Skipping deleted %s entry with no id', $type)); - foreach ($results->{$type} as $result) { - // Nothing identifies the entity to remove. - if (null === $result->id) { - $this->logger->error(sprintf('Skipping deleted %s entry with no id', $type)); + continue; + } - continue; + $projectTrackerId = $result->id; + + // Now that the request actually filters by timestamp, an entry dropped here is + // dropped for good; before, every run re-read the full history and healed itself. + // So one bad entry must not take the rest of the run with it. + try { + $deletedDate = $this->getLeanDateTime($result->deletedDate); + + $this->messageBus->dispatch( + new EntityRemovedFromDataProviderMessage($classname, $dataProviderId, $projectTrackerId, $deletedDate), + [new TransportNamesStamp($asyncJobQueue ? $this::QUEUE_ASYNC : $this::QUEUE_SYNC)], + ); + } catch (HandlerFailedException|\DateMalformedStringException|\TypeError $e) { + if ($e instanceof HandlerFailedException) { + $this->rethrowUnlessRowLevel($e); } - $projectTrackerId = $result->id; - - // Now that the request actually filters by timestamp, an entry dropped here is - // dropped for good; before, every run re-read the full history and healed itself. - // So one bad entry must not take the rest of the run with it. - try { - $deletedDate = $this->getLeanDateTime($result->deletedDate); + $this->logger->error(sprintf('Skipping deleted %s id %s: %s', $type, $projectTrackerId, $e->getMessage())); + } + } - $this->messageBus->dispatch( - new EntityRemovedFromDataProviderMessage($classname, $dataProviderId, $projectTrackerId, $deletedDate), - [new TransportNamesStamp($asyncJobQueue ? $this::QUEUE_ASYNC : $this::QUEUE_SYNC)], - ); - } catch (HandlerFailedException|\DateMalformedStringException|\TypeError $e) { - if ($e instanceof HandlerFailedException) { - $this->rethrowUnlessRowLevel($e); - } + // Queue next page. + if ($data->resultsCount === $limit) { + // A full page with nothing to advance on cannot be paged past. Stopping is visible in + // the log; continuing would re-read the same page until the queue starves. + if (null === $maxDeletionId || $maxDeletionId < $startId) { + $this->logger->error(sprintf('Stopping deleted %s sync at start %d: no usable deletionId on a full page, cursor cannot advance.', $type, $startId)); - $this->logger->error(sprintf('Skipping deleted %s id %s: %s', $type, $projectTrackerId, $e->getMessage())); - } + return; } + + $this->messageBus->dispatch( + new LeantimeDeleteMessage($type, $maxDeletionId + 1, $limit, $dataProviderId, $asyncJobQueue, $deletedAfter), + [new TransportNamesStamp($asyncJobQueue ? $this::QUEUE_ASYNC : $this::QUEUE_SYNC)], + ); } } diff --git a/tests/Integration/Service/LeantimeApiServiceTest.php b/tests/Integration/Service/LeantimeApiServiceTest.php index df6ed0a2..68bf96aa 100644 --- a/tests/Integration/Service/LeantimeApiServiceTest.php +++ b/tests/Integration/Service/LeantimeApiServiceTest.php @@ -378,18 +378,25 @@ public function testDeleted(): void $httpClientMock = $this->createMock(HttpClientInterface::class); - $responseMock = $this->createMock(ResponseInterface::class); - $responseMock->method('getStatusCode')->willReturn(200); - $responseMock->method('getContent')->willReturn(json_encode($this->getDeletedData())); + // One response per type: the endpoint serves a single type's page per request. + $responseMocks = []; + + foreach ($this->getDeletedData() as $type => $payload) { + $responseMock = $this->createMock(ResponseInterface::class); + $responseMock->method('getStatusCode')->willReturn(200); + $responseMock->method('getContent')->willReturn(json_encode($payload)); + + $responseMocks[$type] = $responseMock; + } - // Capture what was actually sent, so the request body can be asserted after the call. - $requestJson = null; - $httpClientMock->expects($this->once()) + // Capture what was actually sent, so the request bodies can be asserted after the calls. + $requestJson = []; + $httpClientMock->expects($this->exactly(count($responseMocks))) ->method('request') - ->willReturnCallback(function (string $method, string $url, array $options) use ($responseMock, &$requestJson): ResponseInterface { - $requestJson = $options['json'] ?? null; + ->willReturnCallback(function (string $method, string $url, array $options) use ($responseMocks, &$requestJson): ResponseInterface { + $requestJson[] = $options['json'] ?? null; - return $responseMock; + return $responseMocks[$options['json']['type']]; }); $service = new LeantimeApiService( @@ -559,14 +566,22 @@ public function testDeleted(): void $deletedAfter = new \DateTime('2025-10-06T11:36:08.000000Z'); - $service->deleteAsJob($id, false, $deletedAfter); + // Stands in for delete()'s dispatch: one request per type, children before the parents they + // hang off. Calling it in a loop is what the sync transport does anyway — asyncJobQueue is + // false below, so every removal is handled inline before the next type starts, which is the + // ordering the assertions further down rely on. + foreach ([LeantimeApiService::TIMESHEETS, LeantimeApiService::TICKETS, LeantimeApiService::MILESTONES, LeantimeApiService::PROJECTS] as $type) { + $service->deleteAsJob($type, 0, 100, $id, false, $deletedAfter); + } - // The plugin only reads 'deleted'. Under any other key the timestamp is silently discarded - // and every run pulls the entire deletion history, which /deleted does not paginate. + // The plugin only reads 'deletedAfter'. Under any other key the timestamp is silently + // discarded and every run pages through the entire deletion history. $this->assertSame( [ - 'types' => ['timesheets', 'tickets', 'milestones', 'projects'], - 'deleted' => $deletedAfter->getTimestamp(), + ['type' => 'timesheets', 'start' => 0, 'limit' => 100, 'deletedAfter' => $deletedAfter->getTimestamp()], + ['type' => 'tickets', 'start' => 0, 'limit' => 100, 'deletedAfter' => $deletedAfter->getTimestamp()], + ['type' => 'milestones', 'start' => 0, 'limit' => 100, 'deletedAfter' => $deletedAfter->getTimestamp()], + ['type' => 'projects', 'start' => 0, 'limit' => 100, 'deletedAfter' => $deletedAfter->getTimestamp()], ], $requestJson ); @@ -805,71 +820,98 @@ private function getDeletedUserTimesheets(): object ', null, 512, JSON_THROW_ON_ERROR); } - private function getDeletedData(): object + /** + * One response per type, keyed by the type it answers. `deletionId` is the deletion's own id, + * which the results are ordered and paged on; `id` is the entity that was deleted. + * + * @return array + */ + private function getDeletedData(): array { - return json_decode(' - { - "parameters": { - "types": [ - "projects", - "milestones", - "tickets", - "timesheets" - ] - }, - "resultsCount": 6, - "results": { - "projects": [ - { - "id": 64, - "deletedDate": "2025-10-24T11:36:08.000000Z" - }, - { - "id": 65, - "deletedDate": "2025-10-24T11:36:08.000000Z" - } - ], - "milestones": [ - { - "id": 6724, - "deletedDate": "2025-10-24T11:36:08.000000Z" - }, - { - "id": 6725, - "deletedDate": "2025-10-24T11:36:08.000000Z" - } - ], - "tickets": [ - { - "id": 6723, - "deletedDate": "2025-10-24T11:36:08.000000Z" - }, - { - "id": 6726, - "deletedDate": "2025-10-24T11:36:08.000000Z" - } - ], - "timesheets": [ - { - "id": null, - "deletedDate": "2025-10-24T11:36:08.000000Z" - }, - { - "id": 66939, - "deletedDate": "not a date" - }, - { - "id": 66937, - "deletedDate": "2025-10-24T11:36:08.000000Z" - }, - { - "id": 66938, - "deletedDate": "2025-10-24T11:36:08.000000Z" - } - ] - } - } - ', null, 512, JSON_THROW_ON_ERROR); + return [ + 'projects' => json_decode(' + { + "parameters": {"type": "projects", "start": 0, "limit": 100}, + "resultsCount": 2, + "results": [ + { + "deletionId": 1, + "id": 64, + "deletedDate": "2025-10-24T11:36:08.000000Z" + }, + { + "deletionId": 2, + "id": 65, + "deletedDate": "2025-10-24T11:36:08.000000Z" + } + ] + } + ', null, 512, JSON_THROW_ON_ERROR), + 'milestones' => json_decode(' + { + "parameters": {"type": "milestones", "start": 0, "limit": 100}, + "resultsCount": 2, + "results": [ + { + "deletionId": 1, + "id": 6724, + "deletedDate": "2025-10-24T11:36:08.000000Z" + }, + { + "deletionId": 3, + "id": 6725, + "deletedDate": "2025-10-24T11:36:08.000000Z" + } + ] + } + ', null, 512, JSON_THROW_ON_ERROR), + 'tickets' => json_decode(' + { + "parameters": {"type": "tickets", "start": 0, "limit": 100}, + "resultsCount": 2, + "results": [ + { + "deletionId": 2, + "id": 6723, + "deletedDate": "2025-10-24T11:36:08.000000Z" + }, + { + "deletionId": 4, + "id": 6726, + "deletedDate": "2025-10-24T11:36:08.000000Z" + } + ] + } + ', null, 512, JSON_THROW_ON_ERROR), + 'timesheets' => json_decode(' + { + "parameters": {"type": "timesheets", "start": 0, "limit": 100}, + "resultsCount": 4, + "results": [ + { + "deletionId": 1, + "id": null, + "deletedDate": "2025-10-24T11:36:08.000000Z" + }, + { + "deletionId": 2, + "id": 66939, + "deletedDate": "not a date" + }, + { + "deletionId": 3, + "id": 66937, + "deletedDate": "2025-10-24T11:36:08.000000Z" + }, + { + "deletionId": 4, + "id": 66938, + "deletedDate": "2025-10-24T11:36:08.000000Z" + } + ] + } + ', null, 512, JSON_THROW_ON_ERROR), + ]; } private function getProjects($modifiedYear = 2024): object diff --git a/tests/Unit/MessageHandler/LeantimeDeleteHandlerTest.php b/tests/Unit/MessageHandler/LeantimeDeleteHandlerTest.php index b3168698..806f1cf2 100644 --- a/tests/Unit/MessageHandler/LeantimeDeleteHandlerTest.php +++ b/tests/Unit/MessageHandler/LeantimeDeleteHandlerTest.php @@ -15,12 +15,12 @@ class LeantimeDeleteHandlerTest extends TestCase public function testInvokeCallsDeleteAsJob(): void { $deletedAfter = new \DateTime('2024-01-01'); - $message = new LeantimeDeleteMessage(1, false, $deletedAfter); + $message = new LeantimeDeleteMessage(LeantimeApiService::TICKETS, 82, 100, 1, false, $deletedAfter); $service = $this->createMock(LeantimeApiService::class); $service->expects($this->once()) ->method('deleteAsJob') - ->with(1, false, $deletedAfter); + ->with(LeantimeApiService::TICKETS, 82, 100, 1, false, $deletedAfter); $handler = new LeantimeDeleteHandler($this->createMock(LoggerInterface::class), $service); $handler($message); @@ -28,7 +28,7 @@ public function testInvokeCallsDeleteAsJob(): void public function testInvokeOnRowLevelFailureThrowsUnrecoverable(): void { - $message = new LeantimeDeleteMessage(1, false, null); + $message = new LeantimeDeleteMessage(LeantimeApiService::TICKETS, 0, 100, 1, false, null); $service = $this->createMock(LeantimeApiService::class); $service->method('deleteAsJob')->willThrowException(new NotFoundException('fail')); @@ -41,7 +41,7 @@ public function testInvokeOnRowLevelFailureThrowsUnrecoverable(): void public function testInvokeOnInfrastructureFailurePropagates(): void { - $message = new LeantimeDeleteMessage(1, false, null); + $message = new LeantimeDeleteMessage(LeantimeApiService::TICKETS, 0, 100, 1, false, null); $service = $this->createMock(LeantimeApiService::class); $service->method('deleteAsJob')->willThrowException(new \RuntimeException('the database went away')); diff --git a/tests/Unit/Service/LeantimeApiServiceTest.php b/tests/Unit/Service/LeantimeApiServiceTest.php index 4fe0c858..b72b5f6a 100644 --- a/tests/Unit/Service/LeantimeApiServiceTest.php +++ b/tests/Unit/Service/LeantimeApiServiceTest.php @@ -4,6 +4,7 @@ use App\Entity\DataProvider; use App\Entity\Project; +use App\Message\LeantimeDeleteMessage; use App\Message\LeantimeUpdateMessage; use App\Repository\DataProviderRepository; use App\Repository\ProjectRepository; @@ -17,11 +18,14 @@ use Symfony\Contracts\HttpClient\ResponseInterface; /** - * Pagination cursor behaviour of updateAsJob(). + * Pagination cursor behaviour of updateAsJob() and deleteAsJob(). * - * The cursor is what keeps the sync moving: updateAsJob() queues the next page as its last action, - * using the ids of the rows it just saw. A cursor that fails to advance re-queues the same page - * forever and the single worker never drains. + * The cursor is what keeps the sync moving: both queue the next page as their last action, using + * the ids of the rows they just saw. A cursor that fails to advance re-queues the same page forever + * and the single worker never drains. + * + * The two page on different columns — updateAsJob() on the entity's id, deleteAsJob() on the + * deletion's own `deletionId` — because deletions are ordered by when they happened. */ class LeantimeApiServiceTest extends TestCase { @@ -96,6 +100,72 @@ public function testPartialPageQueuesNoNextPage(): void $this->assertNull($this->findNextPageMessage()); } + public function testDeletedNextPageStartsAfterHighestDeletionId(): void + { + $service = $this->createService($this->deletedPage(range(1, self::LIMIT))); + + $service->deleteAsJob(LeantimeApiService::TICKETS, 0, self::LIMIT, 1); + + $next = $this->nextDeletePageMessage(); + $this->assertSame(self::LIMIT + 1, $next->start); + $this->assertSame(LeantimeApiService::TICKETS, $next->type, 'The next page has to stay on the same type.'); + } + + /** + * The entity ids are whatever was deleted, in no particular order — paging on them would skip + * deletions. Only deletionId is monotonic. + */ + public function testDeletedCursorFollowsTheDeletionIdRatherThanTheEntityId(): void + { + $service = $this->createService($this->deletedPage( + range(1, self::LIMIT), + array_map(static fn (int $id) => 90000 - $id, range(1, self::LIMIT)), + )); + + $service->deleteAsJob(LeantimeApiService::TICKETS, 0, self::LIMIT, 1); + + $this->assertSame(self::LIMIT + 1, $this->nextDeletePageMessage()->start); + } + + /** + * A deletion whose entity id is null is skipped, but it still occupies a place in the page, so + * the cursor has to move past it or the next request refetches this same page. + */ + public function testADeletionWithoutAnEntityIdStillMovesTheCursor(): void + { + $ids = range(1, self::LIMIT); + $ids[self::LIMIT - 1] = null; + + $service = $this->createService($this->deletedPage(range(1, self::LIMIT), $ids)); + + $service->deleteAsJob(LeantimeApiService::TICKETS, 0, self::LIMIT, 1); + + $this->assertSame(self::LIMIT + 1, $this->nextDeletePageMessage()->start); + $this->assertCount(1, $this->loggedErrors); + } + + public function testDeletedPageWithNoUsableDeletionIdStopsAndLogs(): void + { + // The entities are all identifiable; it is the deletions themselves that cannot be paged on. + $service = $this->createService($this->deletedPage(array_fill(0, self::LIMIT, null), range(1, self::LIMIT))); + + $service->deleteAsJob(LeantimeApiService::TICKETS, 40, self::LIMIT, 1); + + $this->assertNull($this->findNextDeletePageMessage(), 'No next page may be queued when the cursor cannot advance.'); + $this->assertCount(1, $this->loggedErrors); + $this->assertStringContainsString(LeantimeApiService::TICKETS, $this->loggedErrors[0]); + $this->assertStringContainsString('40', $this->loggedErrors[0]); + } + + public function testDeletedPartialPageQueuesNoNextPage(): void + { + $service = $this->createService($this->deletedPage(range(1, 10))); + + $service->deleteAsJob(LeantimeApiService::TICKETS, 0, self::LIMIT, 1); + + $this->assertNull($this->findNextDeletePageMessage()); + } + /** * A page of project rows with the given ids. Only the cursor matters here, so every other * field is fixed and valid. @@ -116,6 +186,30 @@ private function page(array $ids): object ]; } + /** + * A page of deletions. `$entityIds` defaults to mirroring the deletion ids; pass it explicitly + * to tell the two columns apart. + * + * @param array $deletionIds + * @param array|null $entityIds + */ + private function deletedPage(array $deletionIds, ?array $entityIds = null): object + { + $deletionIds = array_values($deletionIds); + $entityIds = null === $entityIds ? $deletionIds : array_values($entityIds); + + $results = array_map(static fn ($deletionId, $id) => (object) [ + 'deletionId' => $deletionId, + 'id' => $id, + 'deletedDate' => '2026-07-30T12:00:00.000000Z', + ], $deletionIds, $entityIds); + + return (object) [ + 'results' => $results, + 'resultsCount' => count($results), + ]; + } + private function createService(object $page): LeantimeApiService { $response = $this->createMock(ResponseInterface::class); @@ -181,4 +275,23 @@ private function findNextPageMessage(): ?LeantimeUpdateMessage return null; } + + private function nextDeletePageMessage(): LeantimeDeleteMessage + { + $message = $this->findNextDeletePageMessage(); + $this->assertNotNull($message, 'Expected a next-page message to be queued.'); + + return $message; + } + + private function findNextDeletePageMessage(): ?LeantimeDeleteMessage + { + foreach ($this->dispatched as $message) { + if ($message instanceof LeantimeDeleteMessage) { + return $message; + } + } + + return null; + } }