|
20504
|
888
|
6
|
2026-05-11T15:41:56.446249+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778514116446_m1.jpg...
|
PhpStorm
|
faVsco.js – ClientTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
8043719072324535154
|
-8628527368849355612
|
visual_change
|
hybrid
|
NULL
|
Project: faVsco.js, menu
iTerm2ShellEditViewSessio Project: faVsco.js, menu
iTerm2ShellEditViewSessionScriptsProfilesWindowHelpDOCKERAPP (-zsh)-zsh++₴81DEV (docker)₴2APP (-zsh)ScrmService->syncOpportunity('374720564');ScrmService-›matchByName('Robot');-zsh> 0 hhl*5screenpipe"100% <78• Mon 11 May 18:41:55T₴1O ₴6-zsh*7 |+end diffAPPFixed 4 of 5666 files in 146.870 seconds, 60.00 MB memory usedWhat's next:Try Docker Debug for seamless, persistentdebugging tools in any container or image → docker debug docker_lamp_1Learn moreat [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfixdocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diffPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.PHP runtime: 8.3.30Running analysis on 7 cores with 10 files per process.Parallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!Loadedconfig default from".php-cs-fixer.dist.php".5666/5666 [100%Fixed 0 of 5666 files in 66.457 seconds, 60.00 MB memory usedWhat's next:Try Docker Debug for seamless, persistent debugging tools in any container or image » docker debug docker_1amp_1Learn more at https://docs.docker.com/go/debug-cli/lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ I...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20505
|
889
|
6
|
2026-05-11T15:41:58.025956+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778514118025_m2.jpg...
|
PhpStorm
|
faVsco.js – ClientTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhostormVIewINavigareCodeLaravelKeractorWindowmelp PhostormVIewINavigareCodeLaravelKeractorWindowmelpFV faVsco.js?9 JY-20725-handle-HS-search-rate-limitProiectRematchActivityOnCrmObjectDetach.php© HubspotPaginationService.php(C) TrackAutomatedReportGenerateaevent.onpAutoScoreD CrmDealRisksC) UserAutomatedReportscontroller.ongOhuospot/service.pnpOhubspot/service.pnp© SyncCrmEntitiesTrait.phpC) CachedCrmServiceDecorator.ong© CheckAndRetryRemoteMatch.php• D ElasticSearchb Groups>D Import>@ Mailbox> @ Opportunities© MatchActivityCrmData.phg© RateLimitException.phpC) HandlerubspotkateLimit.php©) ClientTest.php x © Kernel.php© PaginationState.phpw Playlists>CJ Teamsclass Cllentrest extends Testcaseprivate Client $client;8 usagesprivate Configuration $config;m A19 A144 X11 ^a lranscription•JUsers* @vac SocialAccountService&Mock0bjectwedhook07 MailModelsNotiticationsprivace soc1alAccountservice ssoc1alAccouncserv1cenock™b ObserversPolicies• ProvidersRenositories* dvar HubspotraqinatzonservicexmockubnectSholfLocal ChangesConsoleXLog xChanaes 8 fles+ → a Side-by-side viewer +8 02d5214b app/Jobs/Crm/MatchActivityCrmData.phpDo not ignoreHighlight wordsXBB ?=.env.local app@ Client.pho anp/Services/Crm/Hubspot© ClientTest.php tests/Unit/Services/Crm/Hubspot© HandleHubspotRateLimitTest.php tests/Unit/Jobs/Middleware© JiminnyDebugCommand.php app/Console/Commandsphp logging.php config© MatchActivityCrmData.php app/Jobs/CrmRateLimitException.php app/ExceptionsUnversioned Files 9 files.env.nikilocal app= env.other app@ CanAccessAiReportsTest.php tests/Unit/Policies© CreateMockAskJiminnyReportResultCommand.php app/Console/Commands/Re( favicon.ico publicE ids.txt appBraw sal_querv.sal app© SimulateWebhooksCommand.php app/Console/Commands/Crm/Hubspot'exception' => sexception->qetMessaqeo.'attempts = sthis->attemptso.if (Sexcention instanceof RateLimitException ll Sexcention instanceof \Illuminate\Queue\MaxAttemotsExceededException) ≤Loq::warnina('MatchActivitvcrmbatal Joolpermanently failed due to rate limiting'. Scontext)'}else {Log: :error('[MatchActivityCrmData) Job permanently failed after all retries', Scontext);M.WE8TOOK FILTERING IMPLEMENTATION.mo a00Tacts naccod: 80 (10 minutes acolhhl100% C47 • Mon 11 May 18:41:57ClientTestA SF [jiminny@localhost]4 HS_local [jiminny@localhost]A console [PROD]A console [EUiconsole [STAGINGI"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWM0Q.UfZEXDZyHz2mBUFdzdo2gTHEsOkX1 ~07-May-26 14:51:15 GMT: domain=.hubapi.com: Http0nly: Secure: SameSite=None"]."кeрoгс-1о"."?"url": "http:V/AWa.nel.cloudfLare.com/V/report|V/v4?s=NYALsVTP0fYm52qrsDgxYE4sd2RwRq15p5wHsmd=g<LZOYdxLx2B1XVpHmsKnS0%2BKVA5mFLJ2m/YRECD65Ho2BW2LYT206FM4%2lm"max age":604800*"]"success traction".0.olg"max age":6048002""Serven":"cloudflare"?>4"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",1 differenceTeirront vorcian'exception' => $exception->getMessage()'attempts = sthis->attemptso.// RateLimitException reaches failed() only when retryUntil() expires while the job is// still held in the "released" state by HandleHubspotRateLimit - the middleware neven// re-throws, so the exception is not the direct cause of the failure in normal flowif (Sexcention instanceof RateLimitException ll Sexcention instanceof \Tlluminate\Queue\MaxAttemotsExceededException) ≤Loa::warnina('[MatchActivitvCrmDatal Job permanently failed due to rate Limiting'. Scontext):}else {Log::error('[MatchActivityCrmData] Job permanently failed after all retries', Scontext);W Windsurf Teams 61:8 UTF-8 P 4 spaces ®...
|
NULL
|
8235358747761985773
|
NULL
|
visual_change
|
ocr
|
NULL
|
PhostormVIewINavigareCodeLaravelKeractorWindowmelp PhostormVIewINavigareCodeLaravelKeractorWindowmelpFV faVsco.js?9 JY-20725-handle-HS-search-rate-limitProiectRematchActivityOnCrmObjectDetach.php© HubspotPaginationService.php(C) TrackAutomatedReportGenerateaevent.onpAutoScoreD CrmDealRisksC) UserAutomatedReportscontroller.ongOhuospot/service.pnpOhubspot/service.pnp© SyncCrmEntitiesTrait.phpC) CachedCrmServiceDecorator.ong© CheckAndRetryRemoteMatch.php• D ElasticSearchb Groups>D Import>@ Mailbox> @ Opportunities© MatchActivityCrmData.phg© RateLimitException.phpC) HandlerubspotkateLimit.php©) ClientTest.php x © Kernel.php© PaginationState.phpw Playlists>CJ Teamsclass Cllentrest extends Testcaseprivate Client $client;8 usagesprivate Configuration $config;m A19 A144 X11 ^a lranscription•JUsers* @vac SocialAccountService&Mock0bjectwedhook07 MailModelsNotiticationsprivace soc1alAccountservice ssoc1alAccouncserv1cenock™b ObserversPolicies• ProvidersRenositories* dvar HubspotraqinatzonservicexmockubnectSholfLocal ChangesConsoleXLog xChanaes 8 fles+ → a Side-by-side viewer +8 02d5214b app/Jobs/Crm/MatchActivityCrmData.phpDo not ignoreHighlight wordsXBB ?=.env.local app@ Client.pho anp/Services/Crm/Hubspot© ClientTest.php tests/Unit/Services/Crm/Hubspot© HandleHubspotRateLimitTest.php tests/Unit/Jobs/Middleware© JiminnyDebugCommand.php app/Console/Commandsphp logging.php config© MatchActivityCrmData.php app/Jobs/CrmRateLimitException.php app/ExceptionsUnversioned Files 9 files.env.nikilocal app= env.other app@ CanAccessAiReportsTest.php tests/Unit/Policies© CreateMockAskJiminnyReportResultCommand.php app/Console/Commands/Re( favicon.ico publicE ids.txt appBraw sal_querv.sal app© SimulateWebhooksCommand.php app/Console/Commands/Crm/Hubspot'exception' => sexception->qetMessaqeo.'attempts = sthis->attemptso.if (Sexcention instanceof RateLimitException ll Sexcention instanceof \Illuminate\Queue\MaxAttemotsExceededException) ≤Loq::warnina('MatchActivitvcrmbatal Joolpermanently failed due to rate limiting'. Scontext)'}else {Log: :error('[MatchActivityCrmData) Job permanently failed after all retries', Scontext);M.WE8TOOK FILTERING IMPLEMENTATION.mo a00Tacts naccod: 80 (10 minutes acolhhl100% C47 • Mon 11 May 18:41:57ClientTestA SF [jiminny@localhost]4 HS_local [jiminny@localhost]A console [PROD]A console [EUiconsole [STAGINGI"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWM0Q.UfZEXDZyHz2mBUFdzdo2gTHEsOkX1 ~07-May-26 14:51:15 GMT: domain=.hubapi.com: Http0nly: Secure: SameSite=None"]."кeрoгс-1о"."?"url": "http:V/AWa.nel.cloudfLare.com/V/report|V/v4?s=NYALsVTP0fYm52qrsDgxYE4sd2RwRq15p5wHsmd=g<LZOYdxLx2B1XVpHmsKnS0%2BKVA5mFLJ2m/YRECD65Ho2BW2LYT206FM4%2lm"max age":604800*"]"success traction".0.olg"max age":6048002""Serven":"cloudflare"?>4"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",1 differenceTeirront vorcian'exception' => $exception->getMessage()'attempts = sthis->attemptso.// RateLimitException reaches failed() only when retryUntil() expires while the job is// still held in the "released" state by HandleHubspotRateLimit - the middleware neven// re-throws, so the exception is not the direct cause of the failure in normal flowif (Sexcention instanceof RateLimitException ll Sexcention instanceof \Tlluminate\Queue\MaxAttemotsExceededException) ≤Loa::warnina('[MatchActivitvCrmDatal Job permanently failed due to rate Limiting'. Scontext):}else {Log::error('[MatchActivityCrmData] Job permanently failed after all retries', Scontext);W Windsurf Teams 61:8 UTF-8 P 4 spaces ®...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20506
|
889
|
7
|
2026-05-11T15:42:00.999784+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778514120999_m2.jpg...
|
PhpStorm
|
faVsco.js – ClientTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
ClientTest
Run 'ClientTest'
Debug 'ClientTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
144
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Hubspot;
use GuzzleHttp\Psr7\Response;
use HubSpot\Client\Crm\Associations\Api\BatchApi;
use HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId;
use HubSpot\Client\Crm\Associations\Model\BatchResponsePublicAssociationMulti;
use HubSpot\Client\Crm\Associations\Model\PublicObjectId;
use HubSpot\Client\Crm\Companies\ApiException as CompanyApiException;
use HubSpot\Client\Crm\Contacts\ApiException as ContactApiException;
use HubSpot\Client\Crm\Deals\Api\BasicApi as DealsBasicApi;
use HubSpot\Client\Crm\Deals\ApiException as DealApiException;
use HubSpot\Client\Crm\Deals\Model\SimplePublicObjectWithAssociations as DealWithAssociations;
use HubSpot\Client\Crm\Pipelines\Api\PipelinesApi;
use HubSpot\Client\Crm\Pipelines\Model\CollectionResponsePipeline;
use HubSpot\Client\Crm\Pipelines\Model\Error;
use HubSpot\Client\Crm\Pipelines\Model\Pipeline;
use HubSpot\Client\Crm\Pipelines\Model\PipelineStage;
use HubSpot\Client\Crm\Properties\Api\CoreApi;
use HubSpot\Client\Crm\Properties\Model\Property;
use HubSpot\Discovery as HubSpotDiscovery;
use HubSpot\Discovery\Crm\Deals\Discovery as DealsDiscovery;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\SocialAccount;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\HubspotTokenManager;
use Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService;
use Jiminny\Services\SocialAccountService;
use League\OAuth2\Client\Token\AccessToken;
use Mockery;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use SevenShores\Hubspot\Endpoints\Engagements;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Factory;
use SevenShores\Hubspot\Http\Client as HubspotClient;
use SevenShores\Hubspot\Http\Response as HubspotResponse;
/**
* @runTestsInSeparateProcesses
*
* @preserveGlobalState disabled
*/
class ClientTest extends TestCase
{
private const string RESPONSE_TYPE_STAGE_FIELD = 'stage';
private const string RESPONSE_TYPE_PIPELINE_FIELD = 'pipeline';
private const string RESPONSE_TYPE_REGULAR_FIELD = 'regular';
/**
* @var Client&MockObject
*/
private Client $client;
private Configuration $config;
/**
* @var SocialAccountService&MockObject
*/
private SocialAccountService $socialAccountServiceMock;
/**
* @var HubspotPaginationService&MockObject
*/
private HubspotPaginationService $paginationServiceMock;
/**
* @var HubspotTokenManager&MockObject
*/
private HubspotTokenManager $tokenManagerMock;
/**
* @var CoreApi&MockObject
*/
private CoreApi $coreApiMock;
/**
* @var PipelinesApi&MockObject
*/
private PipelinesApi $pipelinesApiMock;
/**
* @var BatchApi&MockObject
*/
private BatchApi $associationsBatchApiMock;
/**
* @var DealsBasicApi&MockObject
*/
private DealsBasicApi $dealsBasicApiMock;
/**
* @var Engagements&MockObject
*/
private $engagementsMock;
/**
* @var HubspotClient&MockObject
*/
private HubspotClient $hubspotClientMock;
protected function setUp(): void
{
// Create mocks for dependencies
$this->socialAccountServiceMock = $this->createMock(SocialAccountService::class);
$this->paginationServiceMock = $this->createMock(HubspotPaginationService::class);
$this->tokenManagerMock = $this->createMock(HubspotTokenManager::class);
// Create a real Client instance with mocked dependencies
// Create a partial mock only for the methods we need to mock
$client = $this->createPartialMock(Client::class, ['getInstance', 'getNewInstance', 'makeRequest']);
$client->setLogger(new NullLogger());
// Inject the real dependencies using reflection
$reflection = new \ReflectionClass(Client::class);
$accountServiceProperty = $reflection->getProperty('accountService');
$accountServiceProperty->setAccessible(true);
$accountServiceProperty->setValue($client, $this->socialAccountServiceMock);
$paginationServiceProperty = $reflection->getProperty('paginationService');
$paginationServiceProperty->setAccessible(true);
$paginationServiceProperty->setValue($client, $this->paginationServiceMock);
$tokenManagerProperty = $reflection->getProperty('tokenManager');
$tokenManagerProperty->setAccessible(true);
$tokenManagerProperty->setValue($client, $this->tokenManagerMock);
$factoryMock = $this->createMock(Factory::class);
$hubspotClientMock = $this->createMock(HubspotClient::class);
$engagementsMock = $this->createMock(\SevenShores\Hubspot\Endpoints\Engagements::class);
$factoryMock->method('getClient')->willReturn($hubspotClientMock);
$factoryMock->method('__call')->with('engagements')->willReturn($engagementsMock);
$client->method('getInstance')->willReturn($factoryMock);
$discoveryMock = $this->createMock(HubSpotDiscovery\Discovery::class);
$crmMock = $this->createMock(HubSpotDiscovery\Crm\Discovery::class);
$associationsMock = $this->createMock(HubSpotDiscovery\Crm\Associations\Discovery::class);
$propertiesMock = $this->createMock(HubSpotDiscovery\Crm\Properties\Discovery::class);
$pipelinesMock = $this->createMock(HubSpotDiscovery\Crm\Pipelines\Discovery::class);
$coreApiMock = $this->createMock(CoreApi::class);
$pipelinesApiMock = $this->createMock(PipelinesApi::class);
$associationsBatchApiMock = $this->createMock(BatchApi::class);
$dealsBasicApiMock = $this->createMock(DealsBasicApi::class);
$propertiesMock->method('__call')->with('coreApi')->willReturn($coreApiMock);
$pipelinesMock->method('__call')->with('pipelinesApi')->willReturn($pipelinesApiMock);
$associationsMock->method('__call')->with('batchApi')->willReturn($associationsBatchApiMock);
$dealsDiscoveryMock = $this->createMock(DealsDiscovery::class);
$dealsDiscoveryMock->method('__call')->with('basicApi')->willReturn($dealsBasicApiMock);
$returnMap = ['properties' => $propertiesMock, 'pipelines' => $pipelinesMock, 'associations' => $associationsMock, 'deals' => $dealsDiscoveryMock];
$crmMock->method('__call')
->willReturnCallback(static fn (string $name) => $returnMap[$name])
;
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client->method('getNewInstance')->willReturn($discoveryMock);
$this->client = $client;
$this->config = $this->createMock(Configuration::class);
$this->client->setConfiguration($this->config);
$this->coreApiMock = $coreApiMock;
$this->pipelinesApiMock = $pipelinesApiMock;
$this->associationsBatchApiMock = $associationsBatchApiMock;
$this->dealsBasicApiMock = $dealsBasicApiMock;
$this->engagementsMock = $engagementsMock;
$this->hubspotClientMock = $hubspotClientMock;
}
public function testGetMinimumApiVersion(): void
{
$this->assertIsString($this->client->getMinimumApiVersion());
}
public function testGetInstance(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManager = $this->createMock(HubspotTokenManager::class);
$client = new Client($socialAccountService, $paginationService, $tokenManager);
$client->setBaseUrl('[URL_WITH_CREDENTIALS] The class CollectionResponsePipeline will be deprecated in the next Hubspot version
*/
$this->pipelinesApiMock
->method('getAll')
->with('deals')
->willReturn(new CollectionResponsePipeline([
'results' => [$this->generatePipeline()],
]))
;
$this->assertEquals(
[
['id' => 'foo', 'label' => 'bar'],
['id' => 'baz', 'label' => 'qux'],
],
$this->client->fetchOpportunityPipelineStages()
);
}
public function testFetchOpportunityPipelineStagesErrorResponse(): void
{
$this->pipelinesApiMock
->method('getAll')
->with('deals')
->willReturn(new Error(['message' => 'test error']))
;
$this->assertEmpty($this->client->fetchOpportunityPipelineStages());
}
#[\PHPUnit\Framework\Attributes\DataProvider('meetingOutcomeFieldProvider')]
public function testFetchMeetingOutcomeFieldOptions(string $fieldId, string $expectedEndpoint): void
{
$field = new Field(['crm_provider_id' => $fieldId]);
$this->hubspotClientMock
->expects($this->once())
->method('request')
->with('GET', $expectedEndpoint)
->willReturn($this->generateHubSpotResponse(
[
'options' => [
['value' => 'option_1', 'label' => 'Option 1', 'displayOrder' => 0],
['value' => 'option_2', 'label' => 'Option 2', 'displayOrder' => 1],
['value' => 'option_3', 'label' => 'Option 3', 'displayOrder' => 2],
],
]
))
;
$this->assertEquals(
[
['id' => 'option_1', 'value' => 'option_1', 'label' => 'Option 1', 'display_order' => 0],
['id' => 'option_2', 'value' => 'option_2', 'label' => 'Option 2', 'display_order' => 1],
['id' => 'option_3', 'value' => 'option_3', 'label' => 'Option 3', 'display_order' => 2],
],
$this->client->fetchMeetingOutcomeFieldOptions($field)
);
}
public static function meetingOutcomeFieldProvider(): array
{
return [
'meeting outcome field' => [
'meetingOutcome',
'[URL_WITH_CREDENTIALS] The class CollectionResponsePipeline will be deprecated in the next Hubspot version
*/
$pipelineStagesResponse = new CollectionResponsePipeline([
'results' => [$this->generatePipeline()],
]);
$this->pipelinesApiMock
->method('getAll')
->willReturn($pipelineStagesResponse);
}
if ($type === self::RESPONSE_TYPE_PIPELINE_FIELD) {
$field->method('isStageField')->willReturn(false);
$field->method('isPipelineField')->willReturn(true);
$pipelineResponse = $this->generateHubSpotResponse([
'results' => [
['id' => '123', 'label' => 'Sales'],
['id' => 'default', 'label' => 'CS'],
],
]);
$this->client
->method('makeRequest')
->with('/crm/v3/pipelines/deals')
->willReturn($pipelineResponse);
}
$this->assertEquals(
$responses[$type],
$this->client->fetchOpportunityFieldOptions($field)
);
}
public static function opportunityFieldOptionsProvider(): array
{
return [
'stage field' => [
'field' => new Field(['crm_provider_id' => 'dealstage']),
'type' => self::RESPONSE_TYPE_STAGE_FIELD,
],
'pipeline field' => [
'field' => new Field(['crm_provider_id' => 'pipeline']),
'type' => self::RESPONSE_TYPE_PIPELINE_FIELD,
],
'regular field' => [
'field' => new Field(['crm_provider_id' => 'some_property']),
'type' => self::RESPONSE_TYPE_REGULAR_FIELD,
],
];
}
private function generateHubSpotResponse(array $data): HubspotResponse
{
return new HubspotResponse(new Response(200, [], json_encode($data)));
}
private function generateProperty(): Property
{
return new Property([
'name' => 'some_property',
'options' => [
[
'label' => 'label_1',
'value' => 'value_1',
],
[
'label' => 'label_2',
'value' => 'value_2',
],
],
]);
}
private function generatePipeline(): Pipeline
{
return new Pipeline(['stages' => [
new PipelineStage(['id' => 'foo', 'label' => 'bar']),
new PipelineStage(['id' => 'baz', 'label' => 'qux']),
]]);
}
public function testFetchOpportunityPipelines(): void
{
$this->client
->method('makeRequest')
->with('/crm/v3/pipelines/deals')
->willReturn($this->generateHubSpotResponse([
'results' => [
['id' => 'id_1', 'label' => 'Option 1', 'displayOrder' => 0],
['id' => 'id_2', 'label' => 'Option 2', 'displayOrder' => 1],
['id' => 'id_3', 'label' => 'Option 3', 'displayOrder' => 2],
],
]));
$this->assertEquals(
[
['id' => 'id_1', 'label' => 'Option 1'],
['id' => 'id_2', 'label' => 'Option 2'],
['id' => 'id_3', 'label' => 'Option 3'],
],
$this->client->fetchOpportunityPipelines()
);
}
public function testGetPaginatedData(): void
{
$expectedResults = [
['id' => 'id_1', 'properties' => []],
['id' => 'id_2', 'properties' => []],
['id' => 'id_3', 'properties' => []],
];
// Mock the pagination service to return a generator and modify reference parameters
$this->paginationServiceMock
->expects($this->once())
->method('getPaginatedDataGenerator')
->with(
$this->client,
['payload_key' => 'payload_value'],
'foobar',
0,
$this->anything(),
$this->anything()
)
->willReturnCallback(function ($client, $payload, $type, $offset, &$total, &$lastRecordId) use ($expectedResults) {
$total = 3;
$lastRecordId = 'id_3';
foreach ($expectedResults as $result) {
yield $result;
}
});
$this->assertEquals(
[
'results' => $expectedResults,
'total' => 3,
'last_record' => 'id_3',
],
$this->client->getPaginatedData(['payload_key' => 'payload_value'], 'foobar')
);
}
public function testGetAssociationsData(): void
{
$ids = ['1', '2', '3'];
$fromObject = 'deals';
$toObject = 'contacts';
$responseResults = [];
foreach ($ids as $id) {
$from = new PublicObjectId();
$from->setId($id);
$to1 = new PublicObjectId();
$to1->setId('contact_' . $id . '_1');
$to2 = new PublicObjectId();
$to2->setId('contact_' . $id . '_2');
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to1, $to2]);
$responseResults[] = $result;
}
$batchResponse = new BatchResponsePublicAssociationMulti();
$batchResponse->setResults($responseResults);
$this->associationsBatchApiMock->expects($this->once())
->method('read')
->with(
$this->equalTo($fromObject),
$this->equalTo($toObject),
$this->callback(function (BatchInputPublicObjectId $batchInput) use ($ids) {
$inputIds = array_map(
fn ($input) => $input->getId(),
$batchInput->getInputs()
);
return $inputIds === $ids;
})
)
->willReturn($batchResponse);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$expectedResult = [
'1' => ['contact_1_1', 'contact_1_2'],
'2' => ['contact_2_1', 'contact_2_2'],
'3' => ['contact_3_1', 'contact_3_2'],
];
$this->assertEquals($expectedResult, $result);
}
public function testGetAssociationsDataHandlesException(): void
{
$ids = ['1', '2', '3'];
$fromObject = 'deals';
$toObject = 'contacts';
$exception = new \Exception('API Error');
$this->associationsBatchApiMock->expects($this->once())
->method('read')
->with(
$this->equalTo($fromObject),
$this->equalTo($toObject),
$this->callback(function ($batchInput) use ($ids) {
return $batchInput instanceof \HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId
&& count($batchInput->getInputs()) === count($ids);
})
)
->willThrowException($exception);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with(
'[Hubspot] Failed to fetch associations',
[
'from_object' => $fromObject,
'to_object' => $toObject,
'reason' => 'API Error',
]
);
$this->client->setLogger($loggerMock);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$this->assertEmpty($result);
$this->assertIsArray($result);
}
public function testGetAssociationsDataWithLargeDataSet(): void
{
$ids = array_map(fn ($i) => (string) $i, range(1, 2500)); // More than 1000 items
$fromObject = 'deals';
$toObject = 'contacts';
$firstBatchResponse = new BatchResponsePublicAssociationMulti();
$firstBatchResults = array_map(function ($id) {
$from = new PublicObjectId();
$from->setId($id);
$to = new PublicObjectId();
$to->setId('contact_' . $id);
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to]);
return $result;
}, array_slice($ids, 0, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));
$firstBatchResponse->setResults($firstBatchResults);
$secondBatchResponse = new BatchResponsePublicAssociationMulti();
$secondBatchResults = array_map(function ($id) {
$from = new PublicObjectId();
$from->setId($id);
$to = new PublicObjectId();
$to->setId('contact_' . $id);
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to]);
return $result;
}, array_slice($ids, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));
$secondBatchResponse->setResults($secondBatchResults);
$this->associationsBatchApiMock->expects($this->exactly(3))
->method('read')
->willReturnOnConsecutiveCalls($firstBatchResponse, $secondBatchResponse);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$this->assertCount(2500, $result);
$this->assertArrayHasKey('1', $result);
$this->assertArrayHasKey('2500', $result);
$this->assertEquals(['contact_1'], $result['1']);
$this->assertEquals(['contact_2500'], $result['2500']);
}
public function testGetContactByEmailSuccess(): void
{
$email = '[EMAIL]';
$fields = ['firstname', 'lastname', 'email'];
$contactMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$contactMock->method('getId')->willReturn('12345');
$contactMock->method('getProperties')->willReturn([
'firstname' => 'John',
'lastname' => 'Doe',
'email' => '[EMAIL]',
]);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, 'firstname,lastname,email', null, false, 'email')
->willReturn($contactMock);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new NullLogger());
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([
'id' => '12345',
'properties' => [
'firstname' => 'John',
'lastname' => 'Doe',
'email' => '[EMAIL]',
],
], $result);
}
public function testGetContactByEmailWithEmptyFields(): void
{
$email = '[EMAIL]';
$fields = [];
$contactMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$contactMock->method('getId')->willReturn('12345');
$contactMock->method('getProperties')->willReturn(['email' => '[EMAIL]']);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, '', null, false, 'email')
->willReturn($contactMock);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new NullLogger());
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([
'id' => '12345',
'properties' => ['email' => '[EMAIL]'],
], $result);
}
public function testGetContactByEmailApiException(): void
{
$email = '[EMAIL]';
$fields = ['firstname', 'lastname'];
$exception = new \HubSpot\Client\Crm\Contacts\ApiException('Contact not found', 404);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, 'firstname,lastname', null, false, 'email')
->willThrowException($exception);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('info')
->with(
'[Hubspot] Failed to fetch contact',
[
'email' => $email,
'reason' => 'Contact not found',
]
);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger($loggerMock);
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([], $result);
}
public function testGetOpportunityById(): void
{
$opportunityId = '12345';
$expectedProperties = [
'dealname' => 'Test Opportunity',
'amount' => '1000.00',
'closedate' => '2024-12-31T23:59:59.999Z',
'dealstage' => 'presentationscheduled',
'pipeline' => 'default',
];
$mockHubspotOpportunity = $this->createMock(DealWithAssociations::class);
$mockHubspotOpportunity->method('getProperties')->willReturn((object) $expectedProperties);
$mockHubspotOpportunity->method('getId')->willReturn($opportunityId);
$now = new \DateTimeImmutable();
$mockHubspotOpportunity->method('getCreatedAt')->willReturn($now);
$mockHubspotOpportunity->method('getUpdatedAt')->willReturn($now);
$mockHubspotOpportunity->method('getArchived')->willReturn(false);
$this->dealsBasicApiMock
->expects($this->once())
->method('getById')
->willReturn($mockHubspotOpportunity);
// Assuming Client::getOpportunityById processes the SimplePublicObject and returns an associative array.
// The structure might be like: ['id' => ..., 'properties' => [...], 'createdAt' => ..., ...]
// Adjust assertions below based on the actual return structure of your method.
$result = $this->client->getOpportunityById($opportunityId, ['test', 'test']);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertEquals($opportunityId, $result['id']);
}
public function testGetContactById(): void
{
$crmId = 'contact-123';
$fields = ['firstname', 'lastname'];
$expectedProperties = ['firstname' => 'John', 'lastname' => 'Doe'];
$mockContact = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$mockContact->method('getId')->willReturn($crmId);
$mockContact->method('getProperties')->willReturn((object) $expectedProperties);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($crmId, 'firstname,lastname')
->willReturn($mockContact);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new \Psr\Log\NullLogger());
$result = $client->getContactById($crmId, $fields);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('properties', $result);
$this->assertEquals($crmId, $result['id']);
$this->assertEquals((object) $expectedProperties, $result['properties']);
}
public function testGetAccountById(): void
{
$crmId = 'account-123';
$fields = ['name', 'industry'];
$expectedProperties = ['name' => 'Acme Corp', 'industry' => 'Technology'];
$mockCompany = $this->createMock(\HubSpot\Client\Crm\Companies\Model\SimplePublicObjectWithAssociations::class);
$mockCompany->method('getId')->willReturn($crmId);
$mockCompany->method('getProperties')->willReturn((object) $expectedProperties);
$companiesApiMock = $this->createMock(\HubSpot\Client\Crm\Companies\Api\BasicApi::class);
$companiesApiMock->expects($this->once())
->method('getById')
->with($crmId, 'name,industry')
->willReturn($mockCompany);
$companiesDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Companies\Discovery::class);
$companiesDiscoveryMock->method('__call')->with('basicApi')->willReturn($companiesApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($companiesDiscoveryMock) {
if ($name === 'companies') {
return $companiesDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new \Psr\Log\NullLogger());
$result = $client->getAccountById($crmId, $fields);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('properties', $result);
$this->assertEquals($crmId, $result['id']);
$this->assertEquals((object) $expectedProperties, $result['properties']);
}
public function testEnsureValidTokenWithNoTokenUpdate(): void
{
$socialAccountMock = $this->createMock(SocialAccount::class);
// Set up OAuth account
$reflection = new \ReflectionClass($this->client);
$oauthAccountProperty = $reflection->getProperty('oauthAccount');
$oauthAccountProperty->setAccessible(true);
$oauthAccountProperty->setValue($this->client, $socialAccountMock);
$originalToken = 'original_token';
$accessTokenProperty = $reflection->getProperty('accessToken');
$accessTokenProperty->setAccessible(true);
$accessTokenProperty->setValue($this->client, $originalToken);
// Mock token manager to return null (no refresh needed)
$this->tokenManagerMock
->expects($this->once())
->method('ensureValidToken')
->with($socialAccountMock)
->willReturn(null);
// Call ensureValidToken
$this->client->ensureValidToken();
// Verify access token was not changed
$this->assertEquals($originalToken, $accessTokenProperty->getValue($this->client));
}
public function testGetPaginatedDataGeneratorDelegatesToPaginationService(): void
{
$payload = ['filters' => []];
$type = 'contacts';
$offset = 0;
$total = 0;
$lastRecordId = null;
$expectedResults = [
['id' => 'id_1', 'properties' => []],
['id' => 'id_2', 'properties' => []],
];
// Mock the pagination service to return a generator
$this->paginationServiceMock
->expects($this->once())
->method('getPaginatedDataGenerator')
->with(
$this->client,
$payload,
$type,
$offset,
$this->anything(),
$this->anything()
)
->willReturnCallback(function () use ($expectedResults) {
foreach ($expectedResults as $result) {
yield $result;
}
});
// Execute the pagination
$results = [];
foreach ($this->client->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastRecordId) as $result) {
$results[] = $result;
}
$this->assertCount(2, $results);
$this->assertEquals('id_1', $results[0]['id']);
$this->assertEquals('id_2', $results[1]['id']);
}
public function testEnsureValidTokenDelegatesToTokenManager(): void
{
$socialAccountMock = $this->createMock(SocialAccount::class);
// Set up OAuth account
$reflection = new \ReflectionClass($this->client);
$oauthAccountProperty = $reflection->getProperty('oauthAccount');
$oauthAccountProperty->setAccessible(true);
$oauthAccountProperty->setValue($this->client, $socialAccountMock);
// Mock token manager to return new token
$this->tokenManagerMock
->expects($this->once())
->method('ensureValidToken')
->with($socialAccountMock)
->willReturn('new_access_token');
// Call ensureValidToken
$this->client->ensureValidToken();
// Verify access token was updated
$accessTokenProperty = $reflection->getProperty('accessToken');
$accessTokenProperty->setAccessible(true);
$this->assertEquals('new_access_token', $accessTokenProperty->getValue($this->client));
}
public function testGetOwnersArchivedWithValidResponse(): void
{
$responseData = [
'results' => [
[
'id' => '123',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'John',
'lastName' => 'Doe',
'userId' => 456,
'userIdIncludingInactive' => 789,
'createdAt' => '2023-01-01T12:00:00Z',
'updatedAt' => '2023-01-02T12:00:00Z',
'archived' => true,
],
],
];
// Create a mock response object
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn($responseData);
// Set up the client to return our test data
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=true'
)
->willReturn($response);
// Call the method
$result = $this->client->getOwnersArchived(true);
// Assert the results
$this->assertCount(1, $result);
$this->assertEquals('123', $result[0]->getId());
$this->assertEquals('[EMAIL]', $result[0]->getEmail());
$this->assertEquals('John Doe', $result[0]->getFullName());
$this->assertTrue($result[0]->isArchived());
}
public function testGetOwnersArchivedWithEmptyResponse(): void
{
// Create a mock response object with empty results
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn(['results' => []]);
// Set up the client to return empty results
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=false'
)
->willReturn($response);
// Call the method
$result = $this->client->getOwnersArchived(false);
// Assert the results
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithInvalidResponse(): void
{
// Create a mock response that will throw an exception when toArray is called
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willThrowException(new \InvalidArgumentException('Invalid JSON'));
// Set up the client to return the problematic response
$this->client->method('makeRequest')
->willReturn($response);
// Mock the logger to expect an error message
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with($this->stringContains('Failed to fetch owners'));
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
// Call the method and expect an empty array on error
$result = $this->client->getOwnersArchived(true);
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithHttpError(): void
{
// Set up the client to throw an exception
$this->client->method('makeRequest')
->willThrowException(new \Exception('HTTP Error'));
// Mock the logger to expect an error message
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with($this->stringContains('Failed to fetch owners'));
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
// Call the method and expect an empty array on error
$result = $this->client->getOwnersArchived(false);
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithOwnerCreationException(): void
{
$responseData = [
'results' => [
[
'id' => '123',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'John',
'lastName' => 'Doe',
'userId' => 456,
'userIdIncludingInactive' => 789,
'createdAt' => '2023-01-01T12:00:00Z',
'updatedAt' => '2023-01-02T12:00:00Z',
'archived' => false,
],
[
'id' => '456',
'email' => '[EMAIL]',
'type' => 'PERSON',
'createdAt' => 'invalid-date-format',
],
[
'id' => '789',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'Jane',
'lastName' => 'Smith',
'userId' => 999,
'userIdIncludingInactive' => 888,
'createdAt' => '2023-01-03T12:00:00Z',
'updatedAt' => '2023-01-04T12:00:00Z',
'archived' => false,
],
],
];
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn($responseData);
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=false'
)
->willReturn($response);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with(
'[HubSpot] Failed to process owner data',
$this->callback(function ($context) {
return isset($context['result']) &&
isset($context['error']) &&
$context['result']['id'] === '456' &&
$context['result']['email'] === '[EMAIL]' &&
str_contains($context['error'], 'invalid-date-format');
})
);
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
$result = $this->client->getOwnersArchived(false);
$this->assertIsArray($result);
$this->assertCount(2, $result);
$this->assertEquals('123', $result[0]->getId());
$this->assertEquals('[EMAIL]', $result[0]->getEmail());
$this->assertEquals('789', $result[1]->getId());
$this->assertEquals('[EMAIL]', $result[1]->getEmail());
}
public function testMakeRequestWithGetMethod(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManager = $this->createMock(HubspotTokenManager::class);
$psrResponse = new Response(200, [], json_encode(['status' => 'success']));
$expectedResponse = new HubspotResponse($psrResponse);
$hubspotClientMock = $this->createMock(HubspotClient::class);
$hubspotClientMock->expects($this->once())
->method('request')
->with(
'GET',
'https://api.hubapi.com/crm/v3/objects/contacts',
[],
null,
true
)
->willReturn($expectedResponse);
$factoryMock = $this->createMock(Factory::class);
$factoryMock->method('getClient')->willReturn($hubspotClientMock);
$client = $this->getMockBuilder(Client::class)
->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])
->onlyMethods(['getInstance'])
->getMock();
$client->method('getInstance')->willReturn($factoryMock);
$client->setAccessToken(new AccessToken(['access_token' => 'test_token']));
$result = $client->makeRequest('/crm/v3/objects/contacts', 'GET');
$this->assertSame($expectedResponse, $result);
}
public function testMakeRequestWithGetMethodAndQueryString(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManag...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8597075,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ClientTest","depth":6,"bounds":{"left":0.875,"top":0.019952115,"width":0.04055851,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ClientTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ClientTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.50265956,"top":0.17478053,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"144","depth":4,"bounds":{"left":0.5142952,"top":0.17478053,"width":0.011968086,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":4,"bounds":{"left":0.52825797,"top":0.17478053,"width":0.008976064,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.53889626,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.5462101,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Hubspot;\n\nuse GuzzleHttp\\Psr7\\Response;\nuse HubSpot\\Client\\Crm\\Associations\\Api\\BatchApi;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\BatchResponsePublicAssociationMulti;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\PublicObjectId;\nuse HubSpot\\Client\\Crm\\Companies\\ApiException as CompanyApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\ApiException as ContactApiException;\nuse HubSpot\\Client\\Crm\\Deals\\Api\\BasicApi as DealsBasicApi;\nuse HubSpot\\Client\\Crm\\Deals\\ApiException as DealApiException;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectWithAssociations as DealWithAssociations;\nuse HubSpot\\Client\\Crm\\Pipelines\\Api\\PipelinesApi;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\CollectionResponsePipeline;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Error;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Pipeline;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\PipelineStage;\nuse HubSpot\\Client\\Crm\\Properties\\Api\\CoreApi;\nuse HubSpot\\Client\\Crm\\Properties\\Model\\Property;\nuse HubSpot\\Discovery as HubSpotDiscovery;\nuse HubSpot\\Discovery\\Crm\\Deals\\Discovery as DealsDiscovery;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\HubspotTokenManager;\nuse Jiminny\\Services\\Crm\\Hubspot\\Pagination\\HubspotPaginationService;\nuse Jiminny\\Services\\SocialAccountService;\nuse League\\OAuth2\\Client\\Token\\AccessToken;\nuse Mockery;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse PHPUnit\\Framework\\TestCase;\nuse Psr\\Log\\NullLogger;\nuse SevenShores\\Hubspot\\Endpoints\\Engagements;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Factory;\nuse SevenShores\\Hubspot\\Http\\Client as HubspotClient;\nuse SevenShores\\Hubspot\\Http\\Response as HubspotResponse;\n\n/**\n * @runTestsInSeparateProcesses\n *\n * @preserveGlobalState disabled\n */\nclass ClientTest extends TestCase\n{\n private const string RESPONSE_TYPE_STAGE_FIELD = 'stage';\n private const string RESPONSE_TYPE_PIPELINE_FIELD = 'pipeline';\n private const string RESPONSE_TYPE_REGULAR_FIELD = 'regular';\n /**\n * @var Client&MockObject\n */\n private Client $client;\n private Configuration $config;\n\n /**\n * @var SocialAccountService&MockObject\n */\n private SocialAccountService $socialAccountServiceMock;\n\n /**\n * @var HubspotPaginationService&MockObject\n */\n private HubspotPaginationService $paginationServiceMock;\n\n /**\n * @var HubspotTokenManager&MockObject\n */\n private HubspotTokenManager $tokenManagerMock;\n\n /**\n * @var CoreApi&MockObject\n */\n private CoreApi $coreApiMock;\n\n /**\n * @var PipelinesApi&MockObject\n */\n private PipelinesApi $pipelinesApiMock;\n\n /**\n * @var BatchApi&MockObject\n */\n private BatchApi $associationsBatchApiMock;\n\n /**\n * @var DealsBasicApi&MockObject\n */\n private DealsBasicApi $dealsBasicApiMock;\n\n /**\n * @var Engagements&MockObject\n */\n private $engagementsMock;\n\n /**\n * @var HubspotClient&MockObject\n */\n private HubspotClient $hubspotClientMock;\n\n protected function setUp(): void\n {\n // Create mocks for dependencies\n $this->socialAccountServiceMock = $this->createMock(SocialAccountService::class);\n $this->paginationServiceMock = $this->createMock(HubspotPaginationService::class);\n $this->tokenManagerMock = $this->createMock(HubspotTokenManager::class);\n\n // Create a real Client instance with mocked dependencies\n // Create a partial mock only for the methods we need to mock\n $client = $this->createPartialMock(Client::class, ['getInstance', 'getNewInstance', 'makeRequest']);\n $client->setLogger(new NullLogger());\n\n // Inject the real dependencies using reflection\n $reflection = new \\ReflectionClass(Client::class);\n\n $accountServiceProperty = $reflection->getProperty('accountService');\n $accountServiceProperty->setAccessible(true);\n $accountServiceProperty->setValue($client, $this->socialAccountServiceMock);\n\n $paginationServiceProperty = $reflection->getProperty('paginationService');\n $paginationServiceProperty->setAccessible(true);\n $paginationServiceProperty->setValue($client, $this->paginationServiceMock);\n\n $tokenManagerProperty = $reflection->getProperty('tokenManager');\n $tokenManagerProperty->setAccessible(true);\n $tokenManagerProperty->setValue($client, $this->tokenManagerMock);\n $factoryMock = $this->createMock(Factory::class);\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $engagementsMock = $this->createMock(\\SevenShores\\Hubspot\\Endpoints\\Engagements::class);\n\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n $factoryMock->method('__call')->with('engagements')->willReturn($engagementsMock);\n\n $client->method('getInstance')->willReturn($factoryMock);\n\n $discoveryMock = $this->createMock(HubSpotDiscovery\\Discovery::class);\n $crmMock = $this->createMock(HubSpotDiscovery\\Crm\\Discovery::class);\n $associationsMock = $this->createMock(HubSpotDiscovery\\Crm\\Associations\\Discovery::class);\n $propertiesMock = $this->createMock(HubSpotDiscovery\\Crm\\Properties\\Discovery::class);\n $pipelinesMock = $this->createMock(HubSpotDiscovery\\Crm\\Pipelines\\Discovery::class);\n $coreApiMock = $this->createMock(CoreApi::class);\n $pipelinesApiMock = $this->createMock(PipelinesApi::class);\n $associationsBatchApiMock = $this->createMock(BatchApi::class);\n $dealsBasicApiMock = $this->createMock(DealsBasicApi::class);\n\n $propertiesMock->method('__call')->with('coreApi')->willReturn($coreApiMock);\n $pipelinesMock->method('__call')->with('pipelinesApi')->willReturn($pipelinesApiMock);\n $associationsMock->method('__call')->with('batchApi')->willReturn($associationsBatchApiMock);\n $dealsDiscoveryMock = $this->createMock(DealsDiscovery::class);\n $dealsDiscoveryMock->method('__call')->with('basicApi')->willReturn($dealsBasicApiMock);\n\n $returnMap = ['properties' => $propertiesMock, 'pipelines' => $pipelinesMock, 'associations' => $associationsMock, 'deals' => $dealsDiscoveryMock];\n $crmMock->method('__call')\n ->willReturnCallback(static fn (string $name) => $returnMap[$name])\n ;\n\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n\n $this->client = $client;\n $this->config = $this->createMock(Configuration::class);\n $this->client->setConfiguration($this->config);\n $this->coreApiMock = $coreApiMock;\n $this->pipelinesApiMock = $pipelinesApiMock;\n $this->associationsBatchApiMock = $associationsBatchApiMock;\n $this->dealsBasicApiMock = $dealsBasicApiMock;\n $this->engagementsMock = $engagementsMock;\n $this->hubspotClientMock = $hubspotClientMock;\n }\n\n public function testGetMinimumApiVersion(): void\n {\n $this->assertIsString($this->client->getMinimumApiVersion());\n }\n\n public function testGetInstance(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $client = new Client($socialAccountService, $paginationService, $tokenManager);\n $client->setBaseUrl('https://example.com');\n $client->setAccessToken(new AccessToken(['access_token' => 'foo']));\n $factory = $client->getInstance();\n\n $this->assertEquals('foo', $factory->getClient()->key);\n }\n\n public function testGetNewInstance(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $client = new Client($socialAccountService, $paginationService, $tokenManager);\n $client->setAccessToken(new AccessToken(['access_token' => 'foo']));\n\n $factory = $client->getNewInstance();\n $token = $factory->auth()->oAuth()->accessTokensApi()->getConfig()->getAccessToken();\n $this->assertEquals('foo', $token);\n }\n\n public function testFetchProperty(): void\n {\n $this->coreApiMock->method('getByName')->willReturn($this->generateProperty());\n\n $this->assertEquals(\n 'some_property',\n $this->client->fetchProperty('foo', 'test')['name']\n );\n }\n\n public function testFetchPropertyOptions(): void\n {\n $this->coreApiMock->method('getByName')->willReturn($this->generateProperty());\n\n $this->assertEquals(\n [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n $this->client->fetchPropertyOptions('foo', 'test')\n );\n }\n\n public function testFetchCallDispositions(): void\n {\n $this->engagementsMock\n ->method('getCallDispositions')\n ->willReturn($this->generateHubSpotResponse([\n [\n 'id' => 1,\n 'label' => 'foo',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'bar',\n 'deleted' => false,\n ],\n ]))\n ;\n\n $this->assertEquals(\n [\n [\n 'id' => 1,\n 'label' => 'foo',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'bar',\n 'deleted' => false,\n ],\n ],\n $this->client->fetchCallDispositions()\n );\n }\n\n public function testFetchDispositionFieldOptions(): void\n {\n $this->engagementsMock->method('getCallDispositions')\n ->willReturn($this->generateHubSpotResponse(\n [\n [\n 'id' => 1,\n 'label' => 'Label 1',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'Label 2',\n 'deleted' => true,\n ],\n ]\n ))\n ;\n\n $this->assertEquals(\n [\n [\n 'value' => 1,\n 'id' => 1,\n 'label' => 'Label 1',\n ],\n ],\n $this->client->fetchDispositionFieldOptions()\n );\n }\n\n public function testFetchOpportunityPipelineStages(): void\n {\n /**\n * @TODO: The class CollectionResponsePipeline will be deprecated in the next Hubspot version\n */\n $this->pipelinesApiMock\n ->method('getAll')\n ->with('deals')\n ->willReturn(new CollectionResponsePipeline([\n 'results' => [$this->generatePipeline()],\n ]))\n ;\n\n $this->assertEquals(\n [\n ['id' => 'foo', 'label' => 'bar'],\n ['id' => 'baz', 'label' => 'qux'],\n ],\n $this->client->fetchOpportunityPipelineStages()\n );\n }\n\n public function testFetchOpportunityPipelineStagesErrorResponse(): void\n {\n $this->pipelinesApiMock\n ->method('getAll')\n ->with('deals')\n ->willReturn(new Error(['message' => 'test error']))\n ;\n\n $this->assertEmpty($this->client->fetchOpportunityPipelineStages());\n }\n\n #[\\PHPUnit\\Framework\\Attributes\\DataProvider('meetingOutcomeFieldProvider')]\n public function testFetchMeetingOutcomeFieldOptions(string $fieldId, string $expectedEndpoint): void\n {\n $field = new Field(['crm_provider_id' => $fieldId]);\n\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->with('GET', $expectedEndpoint)\n ->willReturn($this->generateHubSpotResponse(\n [\n 'options' => [\n ['value' => 'option_1', 'label' => 'Option 1', 'displayOrder' => 0],\n ['value' => 'option_2', 'label' => 'Option 2', 'displayOrder' => 1],\n ['value' => 'option_3', 'label' => 'Option 3', 'displayOrder' => 2],\n ],\n ]\n ))\n ;\n\n $this->assertEquals(\n [\n ['id' => 'option_1', 'value' => 'option_1', 'label' => 'Option 1', 'display_order' => 0],\n ['id' => 'option_2', 'value' => 'option_2', 'label' => 'Option 2', 'display_order' => 1],\n ['id' => 'option_3', 'value' => 'option_3', 'label' => 'Option 3', 'display_order' => 2],\n ],\n $this->client->fetchMeetingOutcomeFieldOptions($field)\n );\n }\n\n public static function meetingOutcomeFieldProvider(): array\n {\n return [\n 'meeting outcome field' => [\n 'meetingOutcome',\n 'https://api.hubapi.com/crm/v3/properties/meeting/hs_meeting_outcome',\n ],\n 'activity type field' => [\n 'foobar',\n 'https://api.hubapi.com/crm/v3/properties/call/hs_activity_type',\n ],\n ];\n }\n\n #[\\PHPUnit\\Framework\\Attributes\\DataProvider('opportunityFieldOptionsProvider')]\n public function testFetchOpportunityFieldOptions(Field $field, string $type): void\n {\n $responses = [\n self::RESPONSE_TYPE_STAGE_FIELD => [\n ['id' => 'foo', 'label' => 'bar'],\n ['id' => 'baz', 'label' => 'qux'],\n ],\n self::RESPONSE_TYPE_PIPELINE_FIELD => [\n ['id' => '123', 'label' => 'Sales'],\n ['id' => 'default', 'label' => 'CS'],\n ],\n self::RESPONSE_TYPE_REGULAR_FIELD => [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n ];\n\n $field = $this->createMock(Field::class);\n\n if ($type === self::RESPONSE_TYPE_REGULAR_FIELD) {\n $field->method('isStageField')->willReturn(false);\n $field->method('isPipelineField')->willReturn(false);\n $propertyResponse = $this->generateProperty();\n $this->coreApiMock->method('getByName')->willReturn($propertyResponse);\n }\n\n if ($type === self::RESPONSE_TYPE_STAGE_FIELD) {\n $field->method('isStageField')->willReturn(true);\n /**\n * @TODO: The class CollectionResponsePipeline will be deprecated in the next Hubspot version\n */\n\n $pipelineStagesResponse = new CollectionResponsePipeline([\n 'results' => [$this->generatePipeline()],\n ]);\n $this->pipelinesApiMock\n ->method('getAll')\n ->willReturn($pipelineStagesResponse);\n }\n\n if ($type === self::RESPONSE_TYPE_PIPELINE_FIELD) {\n $field->method('isStageField')->willReturn(false);\n $field->method('isPipelineField')->willReturn(true);\n $pipelineResponse = $this->generateHubSpotResponse([\n 'results' => [\n ['id' => '123', 'label' => 'Sales'],\n ['id' => 'default', 'label' => 'CS'],\n ],\n ]);\n $this->client\n ->method('makeRequest')\n ->with('/crm/v3/pipelines/deals')\n ->willReturn($pipelineResponse);\n }\n\n $this->assertEquals(\n $responses[$type],\n $this->client->fetchOpportunityFieldOptions($field)\n );\n }\n\n public static function opportunityFieldOptionsProvider(): array\n {\n return [\n 'stage field' => [\n 'field' => new Field(['crm_provider_id' => 'dealstage']),\n 'type' => self::RESPONSE_TYPE_STAGE_FIELD,\n ],\n 'pipeline field' => [\n 'field' => new Field(['crm_provider_id' => 'pipeline']),\n 'type' => self::RESPONSE_TYPE_PIPELINE_FIELD,\n ],\n 'regular field' => [\n 'field' => new Field(['crm_provider_id' => 'some_property']),\n 'type' => self::RESPONSE_TYPE_REGULAR_FIELD,\n ],\n ];\n }\n\n private function generateHubSpotResponse(array $data): HubspotResponse\n {\n return new HubspotResponse(new Response(200, [], json_encode($data)));\n }\n\n private function generateProperty(): Property\n {\n return new Property([\n 'name' => 'some_property',\n 'options' => [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n ]);\n }\n\n private function generatePipeline(): Pipeline\n {\n return new Pipeline(['stages' => [\n new PipelineStage(['id' => 'foo', 'label' => 'bar']),\n new PipelineStage(['id' => 'baz', 'label' => 'qux']),\n ]]);\n }\n\n public function testFetchOpportunityPipelines(): void\n {\n $this->client\n ->method('makeRequest')\n ->with('/crm/v3/pipelines/deals')\n ->willReturn($this->generateHubSpotResponse([\n 'results' => [\n ['id' => 'id_1', 'label' => 'Option 1', 'displayOrder' => 0],\n ['id' => 'id_2', 'label' => 'Option 2', 'displayOrder' => 1],\n ['id' => 'id_3', 'label' => 'Option 3', 'displayOrder' => 2],\n ],\n ]));\n\n $this->assertEquals(\n [\n ['id' => 'id_1', 'label' => 'Option 1'],\n ['id' => 'id_2', 'label' => 'Option 2'],\n ['id' => 'id_3', 'label' => 'Option 3'],\n ],\n $this->client->fetchOpportunityPipelines()\n );\n }\n\n public function testGetPaginatedData(): void\n {\n $expectedResults = [\n ['id' => 'id_1', 'properties' => []],\n ['id' => 'id_2', 'properties' => []],\n ['id' => 'id_3', 'properties' => []],\n ];\n\n // Mock the pagination service to return a generator and modify reference parameters\n $this->paginationServiceMock\n ->expects($this->once())\n ->method('getPaginatedDataGenerator')\n ->with(\n $this->client,\n ['payload_key' => 'payload_value'],\n 'foobar',\n 0,\n $this->anything(),\n $this->anything()\n )\n ->willReturnCallback(function ($client, $payload, $type, $offset, &$total, &$lastRecordId) use ($expectedResults) {\n $total = 3;\n $lastRecordId = 'id_3';\n foreach ($expectedResults as $result) {\n yield $result;\n }\n });\n\n $this->assertEquals(\n [\n 'results' => $expectedResults,\n 'total' => 3,\n 'last_record' => 'id_3',\n ],\n $this->client->getPaginatedData(['payload_key' => 'payload_value'], 'foobar')\n );\n }\n\n public function testGetAssociationsData(): void\n {\n $ids = ['1', '2', '3'];\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $responseResults = [];\n foreach ($ids as $id) {\n $from = new PublicObjectId();\n $from->setId($id);\n\n $to1 = new PublicObjectId();\n $to1->setId('contact_' . $id . '_1');\n $to2 = new PublicObjectId();\n $to2->setId('contact_' . $id . '_2');\n\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to1, $to2]);\n\n $responseResults[] = $result;\n }\n\n $batchResponse = new BatchResponsePublicAssociationMulti();\n $batchResponse->setResults($responseResults);\n\n $this->associationsBatchApiMock->expects($this->once())\n ->method('read')\n ->with(\n $this->equalTo($fromObject),\n $this->equalTo($toObject),\n $this->callback(function (BatchInputPublicObjectId $batchInput) use ($ids) {\n $inputIds = array_map(\n fn ($input) => $input->getId(),\n $batchInput->getInputs()\n );\n\n return $inputIds === $ids;\n })\n )\n ->willReturn($batchResponse);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $expectedResult = [\n '1' => ['contact_1_1', 'contact_1_2'],\n '2' => ['contact_2_1', 'contact_2_2'],\n '3' => ['contact_3_1', 'contact_3_2'],\n ];\n\n $this->assertEquals($expectedResult, $result);\n }\n\n public function testGetAssociationsDataHandlesException(): void\n {\n $ids = ['1', '2', '3'];\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $exception = new \\Exception('API Error');\n\n $this->associationsBatchApiMock->expects($this->once())\n ->method('read')\n ->with(\n $this->equalTo($fromObject),\n $this->equalTo($toObject),\n $this->callback(function ($batchInput) use ($ids) {\n return $batchInput instanceof \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId\n && count($batchInput->getInputs()) === count($ids);\n })\n )\n ->willThrowException($exception);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with(\n '[Hubspot] Failed to fetch associations',\n [\n 'from_object' => $fromObject,\n 'to_object' => $toObject,\n 'reason' => 'API Error',\n ]\n );\n\n $this->client->setLogger($loggerMock);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $this->assertEmpty($result);\n $this->assertIsArray($result);\n }\n\n public function testGetAssociationsDataWithLargeDataSet(): void\n {\n $ids = array_map(fn ($i) => (string) $i, range(1, 2500)); // More than 1000 items\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $firstBatchResponse = new BatchResponsePublicAssociationMulti();\n $firstBatchResults = array_map(function ($id) {\n $from = new PublicObjectId();\n $from->setId($id);\n $to = new PublicObjectId();\n $to->setId('contact_' . $id);\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to]);\n\n return $result;\n }, array_slice($ids, 0, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));\n $firstBatchResponse->setResults($firstBatchResults);\n\n $secondBatchResponse = new BatchResponsePublicAssociationMulti();\n $secondBatchResults = array_map(function ($id) {\n $from = new PublicObjectId();\n $from->setId($id);\n $to = new PublicObjectId();\n $to->setId('contact_' . $id);\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to]);\n\n return $result;\n }, array_slice($ids, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));\n $secondBatchResponse->setResults($secondBatchResults);\n\n $this->associationsBatchApiMock->expects($this->exactly(3))\n ->method('read')\n ->willReturnOnConsecutiveCalls($firstBatchResponse, $secondBatchResponse);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $this->assertCount(2500, $result);\n $this->assertArrayHasKey('1', $result);\n $this->assertArrayHasKey('2500', $result);\n $this->assertEquals(['contact_1'], $result['1']);\n $this->assertEquals(['contact_2500'], $result['2500']);\n }\n\n public function testGetContactByEmailSuccess(): void\n {\n $email = 'test@example.com';\n $fields = ['firstname', 'lastname', 'email'];\n\n $contactMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $contactMock->method('getId')->willReturn('12345');\n $contactMock->method('getProperties')->willReturn([\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n 'email' => 'test@example.com',\n ]);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, 'firstname,lastname,email', null, false, 'email')\n ->willReturn($contactMock);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new NullLogger());\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([\n 'id' => '12345',\n 'properties' => [\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n 'email' => 'test@example.com',\n ],\n ], $result);\n }\n\n public function testGetContactByEmailWithEmptyFields(): void\n {\n $email = 'test@example.com';\n $fields = [];\n\n $contactMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $contactMock->method('getId')->willReturn('12345');\n $contactMock->method('getProperties')->willReturn(['email' => 'test@example.com']);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, '', null, false, 'email')\n ->willReturn($contactMock);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new NullLogger());\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([\n 'id' => '12345',\n 'properties' => ['email' => 'test@example.com'],\n ], $result);\n }\n\n public function testGetContactByEmailApiException(): void\n {\n $email = 'nonexistent@example.com';\n $fields = ['firstname', 'lastname'];\n\n $exception = new \\HubSpot\\Client\\Crm\\Contacts\\ApiException('Contact not found', 404);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, 'firstname,lastname', null, false, 'email')\n ->willThrowException($exception);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('info')\n ->with(\n '[Hubspot] Failed to fetch contact',\n [\n 'email' => $email,\n 'reason' => 'Contact not found',\n ]\n );\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger($loggerMock);\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([], $result);\n }\n\n public function testGetOpportunityById(): void\n {\n $opportunityId = '12345';\n $expectedProperties = [\n 'dealname' => 'Test Opportunity',\n 'amount' => '1000.00',\n 'closedate' => '2024-12-31T23:59:59.999Z',\n 'dealstage' => 'presentationscheduled',\n 'pipeline' => 'default',\n ];\n\n $mockHubspotOpportunity = $this->createMock(DealWithAssociations::class);\n $mockHubspotOpportunity->method('getProperties')->willReturn((object) $expectedProperties);\n $mockHubspotOpportunity->method('getId')->willReturn($opportunityId);\n $now = new \\DateTimeImmutable();\n $mockHubspotOpportunity->method('getCreatedAt')->willReturn($now);\n $mockHubspotOpportunity->method('getUpdatedAt')->willReturn($now);\n $mockHubspotOpportunity->method('getArchived')->willReturn(false);\n\n $this->dealsBasicApiMock\n ->expects($this->once())\n ->method('getById')\n ->willReturn($mockHubspotOpportunity);\n\n // Assuming Client::getOpportunityById processes the SimplePublicObject and returns an associative array.\n // The structure might be like: ['id' => ..., 'properties' => [...], 'createdAt' => ..., ...]\n // Adjust assertions below based on the actual return structure of your method.\n $result = $this->client->getOpportunityById($opportunityId, ['test', 'test']);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertEquals($opportunityId, $result['id']);\n }\n\n public function testGetContactById(): void\n {\n $crmId = 'contact-123';\n $fields = ['firstname', 'lastname'];\n $expectedProperties = ['firstname' => 'John', 'lastname' => 'Doe'];\n\n $mockContact = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $mockContact->method('getId')->willReturn($crmId);\n $mockContact->method('getProperties')->willReturn((object) $expectedProperties);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($crmId, 'firstname,lastname')\n ->willReturn($mockContact);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new \\Psr\\Log\\NullLogger());\n\n $result = $client->getContactById($crmId, $fields);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertArrayHasKey('properties', $result);\n $this->assertEquals($crmId, $result['id']);\n $this->assertEquals((object) $expectedProperties, $result['properties']);\n }\n\n public function testGetAccountById(): void\n {\n $crmId = 'account-123';\n $fields = ['name', 'industry'];\n $expectedProperties = ['name' => 'Acme Corp', 'industry' => 'Technology'];\n\n $mockCompany = $this->createMock(\\HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectWithAssociations::class);\n $mockCompany->method('getId')->willReturn($crmId);\n $mockCompany->method('getProperties')->willReturn((object) $expectedProperties);\n\n $companiesApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Companies\\Api\\BasicApi::class);\n $companiesApiMock->expects($this->once())\n ->method('getById')\n ->with($crmId, 'name,industry')\n ->willReturn($mockCompany);\n\n $companiesDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Companies\\Discovery::class);\n $companiesDiscoveryMock->method('__call')->with('basicApi')->willReturn($companiesApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($companiesDiscoveryMock) {\n if ($name === 'companies') {\n return $companiesDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new \\Psr\\Log\\NullLogger());\n\n $result = $client->getAccountById($crmId, $fields);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertArrayHasKey('properties', $result);\n $this->assertEquals($crmId, $result['id']);\n $this->assertEquals((object) $expectedProperties, $result['properties']);\n }\n\n public function testEnsureValidTokenWithNoTokenUpdate(): void\n {\n $socialAccountMock = $this->createMock(SocialAccount::class);\n\n // Set up OAuth account\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, $socialAccountMock);\n\n $originalToken = 'original_token';\n $accessTokenProperty = $reflection->getProperty('accessToken');\n $accessTokenProperty->setAccessible(true);\n $accessTokenProperty->setValue($this->client, $originalToken);\n\n // Mock token manager to return null (no refresh needed)\n $this->tokenManagerMock\n ->expects($this->once())\n ->method('ensureValidToken')\n ->with($socialAccountMock)\n ->willReturn(null);\n\n // Call ensureValidToken\n $this->client->ensureValidToken();\n\n // Verify access token was not changed\n $this->assertEquals($originalToken, $accessTokenProperty->getValue($this->client));\n }\n\n\n\n\n\n\n public function testGetPaginatedDataGeneratorDelegatesToPaginationService(): void\n {\n $payload = ['filters' => []];\n $type = 'contacts';\n $offset = 0;\n $total = 0;\n $lastRecordId = null;\n\n $expectedResults = [\n ['id' => 'id_1', 'properties' => []],\n ['id' => 'id_2', 'properties' => []],\n ];\n\n // Mock the pagination service to return a generator\n $this->paginationServiceMock\n ->expects($this->once())\n ->method('getPaginatedDataGenerator')\n ->with(\n $this->client,\n $payload,\n $type,\n $offset,\n $this->anything(),\n $this->anything()\n )\n ->willReturnCallback(function () use ($expectedResults) {\n foreach ($expectedResults as $result) {\n yield $result;\n }\n });\n\n // Execute the pagination\n $results = [];\n foreach ($this->client->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastRecordId) as $result) {\n $results[] = $result;\n }\n\n $this->assertCount(2, $results);\n $this->assertEquals('id_1', $results[0]['id']);\n $this->assertEquals('id_2', $results[1]['id']);\n }\n\n public function testEnsureValidTokenDelegatesToTokenManager(): void\n {\n $socialAccountMock = $this->createMock(SocialAccount::class);\n\n // Set up OAuth account\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, $socialAccountMock);\n\n // Mock token manager to return new token\n $this->tokenManagerMock\n ->expects($this->once())\n ->method('ensureValidToken')\n ->with($socialAccountMock)\n ->willReturn('new_access_token');\n\n // Call ensureValidToken\n $this->client->ensureValidToken();\n\n // Verify access token was updated\n $accessTokenProperty = $reflection->getProperty('accessToken');\n $accessTokenProperty->setAccessible(true);\n $this->assertEquals('new_access_token', $accessTokenProperty->getValue($this->client));\n }\n\n public function testGetOwnersArchivedWithValidResponse(): void\n {\n $responseData = [\n 'results' => [\n [\n 'id' => '123',\n 'email' => 'owner1@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'John',\n 'lastName' => 'Doe',\n 'userId' => 456,\n 'userIdIncludingInactive' => 789,\n 'createdAt' => '2023-01-01T12:00:00Z',\n 'updatedAt' => '2023-01-02T12:00:00Z',\n 'archived' => true,\n ],\n ],\n ];\n\n // Create a mock response object\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn($responseData);\n\n // Set up the client to return our test data\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=true'\n )\n ->willReturn($response);\n\n // Call the method\n $result = $this->client->getOwnersArchived(true);\n\n // Assert the results\n $this->assertCount(1, $result);\n $this->assertEquals('123', $result[0]->getId());\n $this->assertEquals('owner1@example.com', $result[0]->getEmail());\n $this->assertEquals('John Doe', $result[0]->getFullName());\n $this->assertTrue($result[0]->isArchived());\n }\n\n public function testGetOwnersArchivedWithEmptyResponse(): void\n {\n // Create a mock response object with empty results\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn(['results' => []]);\n\n // Set up the client to return empty results\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=false'\n )\n ->willReturn($response);\n\n // Call the method\n $result = $this->client->getOwnersArchived(false);\n\n // Assert the results\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithInvalidResponse(): void\n {\n // Create a mock response that will throw an exception when toArray is called\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willThrowException(new \\InvalidArgumentException('Invalid JSON'));\n\n // Set up the client to return the problematic response\n $this->client->method('makeRequest')\n ->willReturn($response);\n\n // Mock the logger to expect an error message\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with($this->stringContains('Failed to fetch owners'));\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n // Call the method and expect an empty array on error\n $result = $this->client->getOwnersArchived(true);\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithHttpError(): void\n {\n // Set up the client to throw an exception\n $this->client->method('makeRequest')\n ->willThrowException(new \\Exception('HTTP Error'));\n\n // Mock the logger to expect an error message\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with($this->stringContains('Failed to fetch owners'));\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n // Call the method and expect an empty array on error\n $result = $this->client->getOwnersArchived(false);\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithOwnerCreationException(): void\n {\n $responseData = [\n 'results' => [\n [\n 'id' => '123',\n 'email' => 'valid@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'John',\n 'lastName' => 'Doe',\n 'userId' => 456,\n 'userIdIncludingInactive' => 789,\n 'createdAt' => '2023-01-01T12:00:00Z',\n 'updatedAt' => '2023-01-02T12:00:00Z',\n 'archived' => false,\n ],\n [\n 'id' => '456',\n 'email' => 'invalid@example.com',\n 'type' => 'PERSON',\n 'createdAt' => 'invalid-date-format',\n ],\n [\n 'id' => '789',\n 'email' => 'another@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'Jane',\n 'lastName' => 'Smith',\n 'userId' => 999,\n 'userIdIncludingInactive' => 888,\n 'createdAt' => '2023-01-03T12:00:00Z',\n 'updatedAt' => '2023-01-04T12:00:00Z',\n 'archived' => false,\n ],\n ],\n ];\n\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn($responseData);\n\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=false'\n )\n ->willReturn($response);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with(\n '[HubSpot] Failed to process owner data',\n $this->callback(function ($context) {\n return isset($context['result']) &&\n isset($context['error']) &&\n $context['result']['id'] === '456' &&\n $context['result']['email'] === 'invalid@example.com' &&\n str_contains($context['error'], 'invalid-date-format');\n })\n );\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n $result = $this->client->getOwnersArchived(false);\n\n $this->assertIsArray($result);\n $this->assertCount(2, $result);\n $this->assertEquals('123', $result[0]->getId());\n $this->assertEquals('valid@example.com', $result[0]->getEmail());\n $this->assertEquals('789', $result[1]->getId());\n $this->assertEquals('another@example.com', $result[1]->getEmail());\n }\n\n public function testMakeRequestWithGetMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'GET',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n [],\n null,\n true\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'GET');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithGetMethodAndQueryString(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'GET',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n [],\n 'limit=10&offset=0',\n true\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'GET', [], 'limit=10&offset=0');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPostMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'POST',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'POST', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPutMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'firstname' => 'Jane',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345', 'updated' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'PUT',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'PUT', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPatchMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'email' => 'newemail@example.com',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345', 'patched' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'PATCH',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'PATCH', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithDeleteMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['deleted' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'DELETE',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => []]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'DELETE');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithEmptyPayload(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'ok']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'POST',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n ['json' => []]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'POST', []);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestPrependsBaseUrl(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n $this->anything(),\n 'https://api.hubapi.com/test/endpoint',\n $this->anything()\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $client->makeRequest('/test/endpoint', 'GET');\n }\n\n public function testGetOpportunitiesByIds(): void\n {\n $crmIds = ['deal1', 'deal2', 'deal3'];\n $fields = ['dealname', 'amount'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getOpportunitiesByIds'));\n }\n\n public function testGetCompaniesByIds(): void\n {\n $crmIds = ['company1', 'company2'];\n $fields = ['name', 'industry'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getCompaniesByIds'));\n }\n\n public function testGetContactsByIds(): void\n {\n $crmIds = ['contact1', 'contact2'];\n $fields = ['firstname', 'lastname'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getContactsByIds'));\n }\n\n public function testBatchReadObjectsEmptyIds(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $result = $method->invokeArgs($this->client, ['deals', [], ['dealname']]);\n\n $this->assertEquals([], $result);\n }\n\n public function testBatchReadObjectsExceedsBatchSize(): void\n {\n $crmIds = array_fill(0, 101, 'deal_id'); // 101 IDs, exceeds limit of 100\n\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $this->expectException(\\InvalidArgumentException::class);\n $this->expectExceptionMessage('Batch size cannot exceed 100 deals');\n\n $method->invokeArgs($this->client, ['deals', $crmIds, ['dealname']]);\n }\n\n public function testBatchReadObjectsUnsupportedObjectType(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $this->expectException(\\Jiminny\\Exceptions\\CrmException::class);\n $this->expectExceptionMessage('Failed to batch fetch unsupported');\n\n $method->invokeArgs($this->client, ['unsupported', ['id1'], ['field1']]);\n }\n\n public function testIsUnauthorizedExceptionWithBadRequest401(): void\n {\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\BadRequest('Unauthorized', 401);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithBadRequestNon401(): void\n {\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\BadRequest('Bad Request', 400);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertFalse($result);\n }\n\n public function testIsUnauthorizedExceptionWithGuzzleRequestException(): void\n {\n $response = new Response(401, [], 'Unauthorized');\n $exception = new \\GuzzleHttp\\Exception\\RequestException('Unauthorized', new \\GuzzleHttp\\Psr7\\Request('GET', 'test'), $response);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithGuzzleClientException(): void\n {\n $exception = new \\GuzzleHttp\\Exception\\ClientException('Unauthorized', new \\GuzzleHttp\\Psr7\\Request('GET', 'test'), new Response(401));\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithStringMatching(): void\n {\n $exception = new \\Exception('HTTP 401 Unauthorized error occurred');\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithNonUnauthorized(): void\n {\n $exception = new \\Exception('Some other error');\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertFalse($result);\n }\n\n public function testEnsureValidTokenWithNullOauthAccount(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, null);\n\n // Should not throw exception and not call token manager\n $this->tokenManagerMock->expects($this->never())->method('ensureValidToken');\n\n $this->client->ensureValidToken();\n\n $this->assertTrue(true); // Test passes if no exception is thrown\n }\n\n public function testGetConfig(): void\n {\n $result = $this->client->getConfig();\n\n $this->assertSame($this->config, $result);\n }\n\n public function testGetOwners(): void\n {\n // Test that the method exists and can be called\n $this->assertTrue(method_exists($this->client, 'getOwners'));\n\n // Since getOwners has complex HubSpot API dependencies,\n // we'll just verify the method exists for now\n $this->assertIsCallable([$this->client, 'getOwners']);\n }\n\n public function testCreateMeeting(): void\n {\n $payload = [\n 'properties' => [\n 'hs_meeting_title' => 'Test Meeting',\n 'hs_meeting_start_time' => '2024-01-01T10:00:00Z',\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['id' => 'meeting123']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with('/crm/v3/objects/meetings', 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->createMeeting($payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testUpdateMeeting(): void\n {\n $meetingId = 'meeting123';\n $payload = [\n 'properties' => [\n 'hs_meeting_title' => 'Updated Meeting',\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['id' => $meetingId, 'updated' => true]);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v3/objects/meetings/{$meetingId}\", 'PATCH', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->updateMeeting($meetingId, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testAddAssociations(): void\n {\n $objectType = 'deals';\n $associationType = 'contacts';\n $payload = [\n 'inputs' => [\n ['from' => ['id' => 'deal1'], 'to' => ['id' => 'contact1']],\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['status' => 'success']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v4/associations/{$objectType}/{$associationType}/batch/create\", 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->addAssociations($objectType, $associationType, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testRemoveAssociations(): void\n {\n $objectType = 'deals';\n $associationType = 'contacts';\n $payload = [\n 'inputs' => [\n ['from' => ['id' => 'deal1'], 'to' => ['id' => 'contact1']],\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['status' => 'success']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v4/associations/{$objectType}/{$associationType}/batch/archive\", 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->removeAssociations($objectType, $associationType, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n // -------------------------------------------------------------------------\n // search() / executeRequest() tests\n // -------------------------------------------------------------------------\n\n public function testSearchReturnsDecodedArrayOnSuccess(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n\n $this->config->method('getId')->willReturn(42);\n\n $payload = ['filters' => []];\n $responseData = ['results' => [['id' => '1']], 'total' => 1, 'paging' => []];\n $hubspotResponse = new HubspotResponse(new Response(200, [], json_encode($responseData)));\n\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->with('POST', Client::BASE_URL . '/crm/v3/objects/contacts/search', ['json' => $payload])\n ->willReturn($hubspotResponse);\n\n $result = $this->client->search('contacts', $payload);\n\n $this->assertSame($responseData, $result);\n\n Mockery::close();\n }\n\n public function testSearchThrowsRateLimitExceptionWhenCircuitBreakerActive(): void\n {\n $futureTimestamp = (string) (time() + 30);\n\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn($futureTimestamp);\n\n $this->config->method('getId')->willReturn(42);\n\n $this->hubspotClientMock->expects($this->never())->method('request');\n\n $this->expectException(RateLimitException::class);\n $this->expectExceptionMessage('Hubspot rate limit (cached circuit-breaker)');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchThrowsRateLimitExceptionAndSetsNxOnFresh429(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n // NX + TTL option array — exact TTL depends on parseRetryAfter, verified separately\n $redisMock->shouldReceive('set')\n ->once()\n ->with(\n 'hubspot:ratelimit:config:42',\n Mockery::type('string'),\n Mockery::on(fn ($opts) => is_array($opts) && in_array('nx', $opts, true))\n );\n\n $this->config->method('getId')->willReturn(42);\n\n $badRequest = new BadRequest('Rate limit exceeded', 429);\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->willThrowException($badRequest);\n\n $this->expectException(RateLimitException::class);\n $this->expectExceptionMessage('Hubspot returned 429');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchPropagatesNonRateLimitException(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n\n $this->config->method('getId')->willReturn(42);\n\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\HubspotException('Server error', 500);\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->willThrowException($exception);\n\n $this->expectException(\\SevenShores\\Hubspot\\Exceptions\\HubspotException::class);\n $this->expectExceptionMessage('Server error');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchCircuitBreakerRetryAfterComputedFromStoredTimestamp(): void\n {\n $remaining = 45;\n $futureTimestamp = (string) (time() + $remaining);\n\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn($futureTimestamp);\n\n $this->config->method('getId')->willReturn(1);\n\n try {\n $this->client->search('deals', []);\n $this->fail('Expected RateLimitException');\n } catch (RateLimitException $e) {\n $this->assertGreaterThanOrEqual(1, $e->getRetryAfter());\n $this->assertLessThanOrEqual($remaining, $e->getRetryAfter());\n } finally {\n Mockery::close();\n }\n }\n\n // -------------------------------------------------------------------------\n // isHubspotRateLimit() tests (private method — tested via reflection)\n // -------------------------------------------------------------------------\n\n private function callIsHubspotRateLimit(\\Throwable $e): bool\n {\n $method = new \\ReflectionMethod($this->client, 'isHubspotRateLimit');\n $method->setAccessible(true);\n\n return $method->invoke($this->client, $e);\n }\n\n public function testIsHubspotRateLimitReturnsTrueForBadRequest429(): void\n {\n $e = new BadRequest('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForDealApiException429(): void\n {\n $e = new DealApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForContactApiException429(): void\n {\n $e = new ContactApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForCompanyApiException429(): void\n {\n $e = new CompanyApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForGuzzleRequestException429(): void\n {\n $request = new \\GuzzleHttp\\Psr7\\Request('POST', 'https://api.hubapi.com/test');\n $response = new \\GuzzleHttp\\Psr7\\Response(429);\n $e = new \\GuzzleHttp\\Exception\\RequestException('Too Many Requests', $request, $response);\n\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsFalseForBadRequestNon429(): void\n {\n $e = new BadRequest('Bad Request', 400);\n $this->assertFalse($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsFalseForGenericException(): void\n {\n $e = new \\RuntimeException('Something went wrong');\n $this->assertFalse($this->callIsHubspotRateLimit($e));\n }\n\n // -------------------------------------------------------------------------\n // parseRetryAfter() tests (private method — tested via reflection)\n // -------------------------------------------------------------------------\n\n private function callParseRetryAfter(\\Throwable $e): int\n {\n $method = new \\ReflectionMethod($this->client, 'parseRetryAfter');\n $method->setAccessible(true);\n\n return $method->invoke($this->client, $e);\n }\n\n public function testParseRetryAfterReadsRetryAfterHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => ['30']]);\n $this->assertSame(30, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReadsLowercaseRetryAfterHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['retry-after' => ['15']]);\n $this->assertSame(15, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterHandlesScalarHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => '60']);\n $this->assertSame(60, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsDailyLimitDelay(): void\n {\n $e = new BadRequest('Daily rate limit exceeded');\n $this->assertSame(600, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsTenSecondlyDelay(): void\n {\n $e = new BadRequest('Ten secondly rate limit exceeded');\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsSecondlyDelay(): void\n {\n $e = new BadRequest('Secondly rate limit exceeded');\n $this->assertSame(1, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsDefaultWhenNoHint(): void\n {\n $e = new BadRequest('Rate limit exceeded');\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterIgnoresNonNumericHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => ['not-a-number']]);\n // Falls through to message parsing; \"Too Many Requests\" has no known keyword → default 10\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Hubspot;\n\nuse GuzzleHttp\\Psr7\\Response;\nuse HubSpot\\Client\\Crm\\Associations\\Api\\BatchApi;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\BatchResponsePublicAssociationMulti;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\PublicObjectId;\nuse HubSpot\\Client\\Crm\\Companies\\ApiException as CompanyApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\ApiException as ContactApiException;\nuse HubSpot\\Client\\Crm\\Deals\\Api\\BasicApi as DealsBasicApi;\nuse HubSpot\\Client\\Crm\\Deals\\ApiException as DealApiException;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectWithAssociations as DealWithAssociations;\nuse HubSpot\\Client\\Crm\\Pipelines\\Api\\PipelinesApi;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\CollectionResponsePipeline;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Error;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Pipeline;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\PipelineStage;\nuse HubSpot\\Client\\Crm\\Properties\\Api\\CoreApi;\nuse HubSpot\\Client\\Crm\\Properties\\Model\\Property;\nuse HubSpot\\Discovery as HubSpotDiscovery;\nuse HubSpot\\Discovery\\Crm\\Deals\\Discovery as DealsDiscovery;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\HubspotTokenManager;\nuse Jiminny\\Services\\Crm\\Hubspot\\Pagination\\HubspotPaginationService;\nuse Jiminny\\Services\\SocialAccountService;\nuse League\\OAuth2\\Client\\Token\\AccessToken;\nuse Mockery;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse PHPUnit\\Framework\\TestCase;\nuse Psr\\Log\\NullLogger;\nuse SevenShores\\Hubspot\\Endpoints\\Engagements;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Factory;\nuse SevenShores\\Hubspot\\Http\\Client as HubspotClient;\nuse SevenShores\\Hubspot\\Http\\Response as HubspotResponse;\n\n/**\n * @runTestsInSeparateProcesses\n *\n * @preserveGlobalState disabled\n */\nclass ClientTest extends TestCase\n{\n private const string RESPONSE_TYPE_STAGE_FIELD = 'stage';\n private const string RESPONSE_TYPE_PIPELINE_FIELD = 'pipeline';\n private const string RESPONSE_TYPE_REGULAR_FIELD = 'regular';\n /**\n * @var Client&MockObject\n */\n private Client $client;\n private Configuration $config;\n\n /**\n * @var SocialAccountService&MockObject\n */\n private SocialAccountService $socialAccountServiceMock;\n\n /**\n * @var HubspotPaginationService&MockObject\n */\n private HubspotPaginationService $paginationServiceMock;\n\n /**\n * @var HubspotTokenManager&MockObject\n */\n private HubspotTokenManager $tokenManagerMock;\n\n /**\n * @var CoreApi&MockObject\n */\n private CoreApi $coreApiMock;\n\n /**\n * @var PipelinesApi&MockObject\n */\n private PipelinesApi $pipelinesApiMock;\n\n /**\n * @var BatchApi&MockObject\n */\n private BatchApi $associationsBatchApiMock;\n\n /**\n * @var DealsBasicApi&MockObject\n */\n private DealsBasicApi $dealsBasicApiMock;\n\n /**\n * @var Engagements&MockObject\n */\n private $engagementsMock;\n\n /**\n * @var HubspotClient&MockObject\n */\n private HubspotClient $hubspotClientMock;\n\n protected function setUp(): void\n {\n // Create mocks for dependencies\n $this->socialAccountServiceMock = $this->createMock(SocialAccountService::class);\n $this->paginationServiceMock = $this->createMock(HubspotPaginationService::class);\n $this->tokenManagerMock = $this->createMock(HubspotTokenManager::class);\n\n // Create a real Client instance with mocked dependencies\n // Create a partial mock only for the methods we need to mock\n $client = $this->createPartialMock(Client::class, ['getInstance', 'getNewInstance', 'makeRequest']);\n $client->setLogger(new NullLogger());\n\n // Inject the real dependencies using reflection\n $reflection = new \\ReflectionClass(Client::class);\n\n $accountServiceProperty = $reflection->getProperty('accountService');\n $accountServiceProperty->setAccessible(true);\n $accountServiceProperty->setValue($client, $this->socialAccountServiceMock);\n\n $paginationServiceProperty = $reflection->getProperty('paginationService');\n $paginationServiceProperty->setAccessible(true);\n $paginationServiceProperty->setValue($client, $this->paginationServiceMock);\n\n $tokenManagerProperty = $reflection->getProperty('tokenManager');\n $tokenManagerProperty->setAccessible(true);\n $tokenManagerProperty->setValue($client, $this->tokenManagerMock);\n $factoryMock = $this->createMock(Factory::class);\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $engagementsMock = $this->createMock(\\SevenShores\\Hubspot\\Endpoints\\Engagements::class);\n\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n $factoryMock->method('__call')->with('engagements')->willReturn($engagementsMock);\n\n $client->method('getInstance')->willReturn($factoryMock);\n\n $discoveryMock = $this->createMock(HubSpotDiscovery\\Discovery::class);\n $crmMock = $this->createMock(HubSpotDiscovery\\Crm\\Discovery::class);\n $associationsMock = $this->createMock(HubSpotDiscovery\\Crm\\Associations\\Discovery::class);\n $propertiesMock = $this->createMock(HubSpotDiscovery\\Crm\\Properties\\Discovery::class);\n $pipelinesMock = $this->createMock(HubSpotDiscovery\\Crm\\Pipelines\\Discovery::class);\n $coreApiMock = $this->createMock(CoreApi::class);\n $pipelinesApiMock = $this->createMock(PipelinesApi::class);\n $associationsBatchApiMock = $this->createMock(BatchApi::class);\n $dealsBasicApiMock = $this->createMock(DealsBasicApi::class);\n\n $propertiesMock->method('__call')->with('coreApi')->willReturn($coreApiMock);\n $pipelinesMock->method('__call')->with('pipelinesApi')->willReturn($pipelinesApiMock);\n $associationsMock->method('__call')->with('batchApi')->willReturn($associationsBatchApiMock);\n $dealsDiscoveryMock = $this->createMock(DealsDiscovery::class);\n $dealsDiscoveryMock->method('__call')->with('basicApi')->willReturn($dealsBasicApiMock);\n\n $returnMap = ['properties' => $propertiesMock, 'pipelines' => $pipelinesMock, 'associations' => $associationsMock, 'deals' => $dealsDiscoveryMock];\n $crmMock->method('__call')\n ->willReturnCallback(static fn (string $name) => $returnMap[$name])\n ;\n\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n\n $this->client = $client;\n $this->config = $this->createMock(Configuration::class);\n $this->client->setConfiguration($this->config);\n $this->coreApiMock = $coreApiMock;\n $this->pipelinesApiMock = $pipelinesApiMock;\n $this->associationsBatchApiMock = $associationsBatchApiMock;\n $this->dealsBasicApiMock = $dealsBasicApiMock;\n $this->engagementsMock = $engagementsMock;\n $this->hubspotClientMock = $hubspotClientMock;\n }\n\n public function testGetMinimumApiVersion(): void\n {\n $this->assertIsString($this->client->getMinimumApiVersion());\n }\n\n public function testGetInstance(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $client = new Client($socialAccountService, $paginationService, $tokenManager);\n $client->setBaseUrl('https://example.com');\n $client->setAccessToken(new AccessToken(['access_token' => 'foo']));\n $factory = $client->getInstance();\n\n $this->assertEquals('foo', $factory->getClient()->key);\n }\n\n public function testGetNewInstance(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $client = new Client($socialAccountService, $paginationService, $tokenManager);\n $client->setAccessToken(new AccessToken(['access_token' => 'foo']));\n\n $factory = $client->getNewInstance();\n $token = $factory->auth()->oAuth()->accessTokensApi()->getConfig()->getAccessToken();\n $this->assertEquals('foo', $token);\n }\n\n public function testFetchProperty(): void\n {\n $this->coreApiMock->method('getByName')->willReturn($this->generateProperty());\n\n $this->assertEquals(\n 'some_property',\n $this->client->fetchProperty('foo', 'test')['name']\n );\n }\n\n public function testFetchPropertyOptions(): void\n {\n $this->coreApiMock->method('getByName')->willReturn($this->generateProperty());\n\n $this->assertEquals(\n [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n $this->client->fetchPropertyOptions('foo', 'test')\n );\n }\n\n public function testFetchCallDispositions(): void\n {\n $this->engagementsMock\n ->method('getCallDispositions')\n ->willReturn($this->generateHubSpotResponse([\n [\n 'id' => 1,\n 'label' => 'foo',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'bar',\n 'deleted' => false,\n ],\n ]))\n ;\n\n $this->assertEquals(\n [\n [\n 'id' => 1,\n 'label' => 'foo',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'bar',\n 'deleted' => false,\n ],\n ],\n $this->client->fetchCallDispositions()\n );\n }\n\n public function testFetchDispositionFieldOptions(): void\n {\n $this->engagementsMock->method('getCallDispositions')\n ->willReturn($this->generateHubSpotResponse(\n [\n [\n 'id' => 1,\n 'label' => 'Label 1',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'Label 2',\n 'deleted' => true,\n ],\n ]\n ))\n ;\n\n $this->assertEquals(\n [\n [\n 'value' => 1,\n 'id' => 1,\n 'label' => 'Label 1',\n ],\n ],\n $this->client->fetchDispositionFieldOptions()\n );\n }\n\n public function testFetchOpportunityPipelineStages(): void\n {\n /**\n * @TODO: The class CollectionResponsePipeline will be deprecated in the next Hubspot version\n */\n $this->pipelinesApiMock\n ->method('getAll')\n ->with('deals')\n ->willReturn(new CollectionResponsePipeline([\n 'results' => [$this->generatePipeline()],\n ]))\n ;\n\n $this->assertEquals(\n [\n ['id' => 'foo', 'label' => 'bar'],\n ['id' => 'baz', 'label' => 'qux'],\n ],\n $this->client->fetchOpportunityPipelineStages()\n );\n }\n\n public function testFetchOpportunityPipelineStagesErrorResponse(): void\n {\n $this->pipelinesApiMock\n ->method('getAll')\n ->with('deals')\n ->willReturn(new Error(['message' => 'test error']))\n ;\n\n $this->assertEmpty($this->client->fetchOpportunityPipelineStages());\n }\n\n #[\\PHPUnit\\Framework\\Attributes\\DataProvider('meetingOutcomeFieldProvider')]\n public function testFetchMeetingOutcomeFieldOptions(string $fieldId, string $expectedEndpoint): void\n {\n $field = new Field(['crm_provider_id' => $fieldId]);\n\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->with('GET', $expectedEndpoint)\n ->willReturn($this->generateHubSpotResponse(\n [\n 'options' => [\n ['value' => 'option_1', 'label' => 'Option 1', 'displayOrder' => 0],\n ['value' => 'option_2', 'label' => 'Option 2', 'displayOrder' => 1],\n ['value' => 'option_3', 'label' => 'Option 3', 'displayOrder' => 2],\n ],\n ]\n ))\n ;\n\n $this->assertEquals(\n [\n ['id' => 'option_1', 'value' => 'option_1', 'label' => 'Option 1', 'display_order' => 0],\n ['id' => 'option_2', 'value' => 'option_2', 'label' => 'Option 2', 'display_order' => 1],\n ['id' => 'option_3', 'value' => 'option_3', 'label' => 'Option 3', 'display_order' => 2],\n ],\n $this->client->fetchMeetingOutcomeFieldOptions($field)\n );\n }\n\n public static function meetingOutcomeFieldProvider(): array\n {\n return [\n 'meeting outcome field' => [\n 'meetingOutcome',\n 'https://api.hubapi.com/crm/v3/properties/meeting/hs_meeting_outcome',\n ],\n 'activity type field' => [\n 'foobar',\n 'https://api.hubapi.com/crm/v3/properties/call/hs_activity_type',\n ],\n ];\n }\n\n #[\\PHPUnit\\Framework\\Attributes\\DataProvider('opportunityFieldOptionsProvider')]\n public function testFetchOpportunityFieldOptions(Field $field, string $type): void\n {\n $responses = [\n self::RESPONSE_TYPE_STAGE_FIELD => [\n ['id' => 'foo', 'label' => 'bar'],\n ['id' => 'baz', 'label' => 'qux'],\n ],\n self::RESPONSE_TYPE_PIPELINE_FIELD => [\n ['id' => '123', 'label' => 'Sales'],\n ['id' => 'default', 'label' => 'CS'],\n ],\n self::RESPONSE_TYPE_REGULAR_FIELD => [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n ];\n\n $field = $this->createMock(Field::class);\n\n if ($type === self::RESPONSE_TYPE_REGULAR_FIELD) {\n $field->method('isStageField')->willReturn(false);\n $field->method('isPipelineField')->willReturn(false);\n $propertyResponse = $this->generateProperty();\n $this->coreApiMock->method('getByName')->willReturn($propertyResponse);\n }\n\n if ($type === self::RESPONSE_TYPE_STAGE_FIELD) {\n $field->method('isStageField')->willReturn(true);\n /**\n * @TODO: The class CollectionResponsePipeline will be deprecated in the next Hubspot version\n */\n\n $pipelineStagesResponse = new CollectionResponsePipeline([\n 'results' => [$this->generatePipeline()],\n ]);\n $this->pipelinesApiMock\n ->method('getAll')\n ->willReturn($pipelineStagesResponse);\n }\n\n if ($type === self::RESPONSE_TYPE_PIPELINE_FIELD) {\n $field->method('isStageField')->willReturn(false);\n $field->method('isPipelineField')->willReturn(true);\n $pipelineResponse = $this->generateHubSpotResponse([\n 'results' => [\n ['id' => '123', 'label' => 'Sales'],\n ['id' => 'default', 'label' => 'CS'],\n ],\n ]);\n $this->client\n ->method('makeRequest')\n ->with('/crm/v3/pipelines/deals')\n ->willReturn($pipelineResponse);\n }\n\n $this->assertEquals(\n $responses[$type],\n $this->client->fetchOpportunityFieldOptions($field)\n );\n }\n\n public static function opportunityFieldOptionsProvider(): array\n {\n return [\n 'stage field' => [\n 'field' => new Field(['crm_provider_id' => 'dealstage']),\n 'type' => self::RESPONSE_TYPE_STAGE_FIELD,\n ],\n 'pipeline field' => [\n 'field' => new Field(['crm_provider_id' => 'pipeline']),\n 'type' => self::RESPONSE_TYPE_PIPELINE_FIELD,\n ],\n 'regular field' => [\n 'field' => new Field(['crm_provider_id' => 'some_property']),\n 'type' => self::RESPONSE_TYPE_REGULAR_FIELD,\n ],\n ];\n }\n\n private function generateHubSpotResponse(array $data): HubspotResponse\n {\n return new HubspotResponse(new Response(200, [], json_encode($data)));\n }\n\n private function generateProperty(): Property\n {\n return new Property([\n 'name' => 'some_property',\n 'options' => [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n ]);\n }\n\n private function generatePipeline(): Pipeline\n {\n return new Pipeline(['stages' => [\n new PipelineStage(['id' => 'foo', 'label' => 'bar']),\n new PipelineStage(['id' => 'baz', 'label' => 'qux']),\n ]]);\n }\n\n public function testFetchOpportunityPipelines(): void\n {\n $this->client\n ->method('makeRequest')\n ->with('/crm/v3/pipelines/deals')\n ->willReturn($this->generateHubSpotResponse([\n 'results' => [\n ['id' => 'id_1', 'label' => 'Option 1', 'displayOrder' => 0],\n ['id' => 'id_2', 'label' => 'Option 2', 'displayOrder' => 1],\n ['id' => 'id_3', 'label' => 'Option 3', 'displayOrder' => 2],\n ],\n ]));\n\n $this->assertEquals(\n [\n ['id' => 'id_1', 'label' => 'Option 1'],\n ['id' => 'id_2', 'label' => 'Option 2'],\n ['id' => 'id_3', 'label' => 'Option 3'],\n ],\n $this->client->fetchOpportunityPipelines()\n );\n }\n\n public function testGetPaginatedData(): void\n {\n $expectedResults = [\n ['id' => 'id_1', 'properties' => []],\n ['id' => 'id_2', 'properties' => []],\n ['id' => 'id_3', 'properties' => []],\n ];\n\n // Mock the pagination service to return a generator and modify reference parameters\n $this->paginationServiceMock\n ->expects($this->once())\n ->method('getPaginatedDataGenerator')\n ->with(\n $this->client,\n ['payload_key' => 'payload_value'],\n 'foobar',\n 0,\n $this->anything(),\n $this->anything()\n )\n ->willReturnCallback(function ($client, $payload, $type, $offset, &$total, &$lastRecordId) use ($expectedResults) {\n $total = 3;\n $lastRecordId = 'id_3';\n foreach ($expectedResults as $result) {\n yield $result;\n }\n });\n\n $this->assertEquals(\n [\n 'results' => $expectedResults,\n 'total' => 3,\n 'last_record' => 'id_3',\n ],\n $this->client->getPaginatedData(['payload_key' => 'payload_value'], 'foobar')\n );\n }\n\n public function testGetAssociationsData(): void\n {\n $ids = ['1', '2', '3'];\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $responseResults = [];\n foreach ($ids as $id) {\n $from = new PublicObjectId();\n $from->setId($id);\n\n $to1 = new PublicObjectId();\n $to1->setId('contact_' . $id . '_1');\n $to2 = new PublicObjectId();\n $to2->setId('contact_' . $id . '_2');\n\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to1, $to2]);\n\n $responseResults[] = $result;\n }\n\n $batchResponse = new BatchResponsePublicAssociationMulti();\n $batchResponse->setResults($responseResults);\n\n $this->associationsBatchApiMock->expects($this->once())\n ->method('read')\n ->with(\n $this->equalTo($fromObject),\n $this->equalTo($toObject),\n $this->callback(function (BatchInputPublicObjectId $batchInput) use ($ids) {\n $inputIds = array_map(\n fn ($input) => $input->getId(),\n $batchInput->getInputs()\n );\n\n return $inputIds === $ids;\n })\n )\n ->willReturn($batchResponse);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $expectedResult = [\n '1' => ['contact_1_1', 'contact_1_2'],\n '2' => ['contact_2_1', 'contact_2_2'],\n '3' => ['contact_3_1', 'contact_3_2'],\n ];\n\n $this->assertEquals($expectedResult, $result);\n }\n\n public function testGetAssociationsDataHandlesException(): void\n {\n $ids = ['1', '2', '3'];\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $exception = new \\Exception('API Error');\n\n $this->associationsBatchApiMock->expects($this->once())\n ->method('read')\n ->with(\n $this->equalTo($fromObject),\n $this->equalTo($toObject),\n $this->callback(function ($batchInput) use ($ids) {\n return $batchInput instanceof \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId\n && count($batchInput->getInputs()) === count($ids);\n })\n )\n ->willThrowException($exception);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with(\n '[Hubspot] Failed to fetch associations',\n [\n 'from_object' => $fromObject,\n 'to_object' => $toObject,\n 'reason' => 'API Error',\n ]\n );\n\n $this->client->setLogger($loggerMock);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $this->assertEmpty($result);\n $this->assertIsArray($result);\n }\n\n public function testGetAssociationsDataWithLargeDataSet(): void\n {\n $ids = array_map(fn ($i) => (string) $i, range(1, 2500)); // More than 1000 items\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $firstBatchResponse = new BatchResponsePublicAssociationMulti();\n $firstBatchResults = array_map(function ($id) {\n $from = new PublicObjectId();\n $from->setId($id);\n $to = new PublicObjectId();\n $to->setId('contact_' . $id);\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to]);\n\n return $result;\n }, array_slice($ids, 0, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));\n $firstBatchResponse->setResults($firstBatchResults);\n\n $secondBatchResponse = new BatchResponsePublicAssociationMulti();\n $secondBatchResults = array_map(function ($id) {\n $from = new PublicObjectId();\n $from->setId($id);\n $to = new PublicObjectId();\n $to->setId('contact_' . $id);\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to]);\n\n return $result;\n }, array_slice($ids, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));\n $secondBatchResponse->setResults($secondBatchResults);\n\n $this->associationsBatchApiMock->expects($this->exactly(3))\n ->method('read')\n ->willReturnOnConsecutiveCalls($firstBatchResponse, $secondBatchResponse);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $this->assertCount(2500, $result);\n $this->assertArrayHasKey('1', $result);\n $this->assertArrayHasKey('2500', $result);\n $this->assertEquals(['contact_1'], $result['1']);\n $this->assertEquals(['contact_2500'], $result['2500']);\n }\n\n public function testGetContactByEmailSuccess(): void\n {\n $email = 'test@example.com';\n $fields = ['firstname', 'lastname', 'email'];\n\n $contactMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $contactMock->method('getId')->willReturn('12345');\n $contactMock->method('getProperties')->willReturn([\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n 'email' => 'test@example.com',\n ]);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, 'firstname,lastname,email', null, false, 'email')\n ->willReturn($contactMock);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new NullLogger());\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([\n 'id' => '12345',\n 'properties' => [\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n 'email' => 'test@example.com',\n ],\n ], $result);\n }\n\n public function testGetContactByEmailWithEmptyFields(): void\n {\n $email = 'test@example.com';\n $fields = [];\n\n $contactMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $contactMock->method('getId')->willReturn('12345');\n $contactMock->method('getProperties')->willReturn(['email' => 'test@example.com']);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, '', null, false, 'email')\n ->willReturn($contactMock);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new NullLogger());\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([\n 'id' => '12345',\n 'properties' => ['email' => 'test@example.com'],\n ], $result);\n }\n\n public function testGetContactByEmailApiException(): void\n {\n $email = 'nonexistent@example.com';\n $fields = ['firstname', 'lastname'];\n\n $exception = new \\HubSpot\\Client\\Crm\\Contacts\\ApiException('Contact not found', 404);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, 'firstname,lastname', null, false, 'email')\n ->willThrowException($exception);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('info')\n ->with(\n '[Hubspot] Failed to fetch contact',\n [\n 'email' => $email,\n 'reason' => 'Contact not found',\n ]\n );\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger($loggerMock);\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([], $result);\n }\n\n public function testGetOpportunityById(): void\n {\n $opportunityId = '12345';\n $expectedProperties = [\n 'dealname' => 'Test Opportunity',\n 'amount' => '1000.00',\n 'closedate' => '2024-12-31T23:59:59.999Z',\n 'dealstage' => 'presentationscheduled',\n 'pipeline' => 'default',\n ];\n\n $mockHubspotOpportunity = $this->createMock(DealWithAssociations::class);\n $mockHubspotOpportunity->method('getProperties')->willReturn((object) $expectedProperties);\n $mockHubspotOpportunity->method('getId')->willReturn($opportunityId);\n $now = new \\DateTimeImmutable();\n $mockHubspotOpportunity->method('getCreatedAt')->willReturn($now);\n $mockHubspotOpportunity->method('getUpdatedAt')->willReturn($now);\n $mockHubspotOpportunity->method('getArchived')->willReturn(false);\n\n $this->dealsBasicApiMock\n ->expects($this->once())\n ->method('getById')\n ->willReturn($mockHubspotOpportunity);\n\n // Assuming Client::getOpportunityById processes the SimplePublicObject and returns an associative array.\n // The structure might be like: ['id' => ..., 'properties' => [...], 'createdAt' => ..., ...]\n // Adjust assertions below based on the actual return structure of your method.\n $result = $this->client->getOpportunityById($opportunityId, ['test', 'test']);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertEquals($opportunityId, $result['id']);\n }\n\n public function testGetContactById(): void\n {\n $crmId = 'contact-123';\n $fields = ['firstname', 'lastname'];\n $expectedProperties = ['firstname' => 'John', 'lastname' => 'Doe'];\n\n $mockContact = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $mockContact->method('getId')->willReturn($crmId);\n $mockContact->method('getProperties')->willReturn((object) $expectedProperties);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($crmId, 'firstname,lastname')\n ->willReturn($mockContact);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new \\Psr\\Log\\NullLogger());\n\n $result = $client->getContactById($crmId, $fields);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertArrayHasKey('properties', $result);\n $this->assertEquals($crmId, $result['id']);\n $this->assertEquals((object) $expectedProperties, $result['properties']);\n }\n\n public function testGetAccountById(): void\n {\n $crmId = 'account-123';\n $fields = ['name', 'industry'];\n $expectedProperties = ['name' => 'Acme Corp', 'industry' => 'Technology'];\n\n $mockCompany = $this->createMock(\\HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectWithAssociations::class);\n $mockCompany->method('getId')->willReturn($crmId);\n $mockCompany->method('getProperties')->willReturn((object) $expectedProperties);\n\n $companiesApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Companies\\Api\\BasicApi::class);\n $companiesApiMock->expects($this->once())\n ->method('getById')\n ->with($crmId, 'name,industry')\n ->willReturn($mockCompany);\n\n $companiesDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Companies\\Discovery::class);\n $companiesDiscoveryMock->method('__call')->with('basicApi')->willReturn($companiesApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($companiesDiscoveryMock) {\n if ($name === 'companies') {\n return $companiesDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new \\Psr\\Log\\NullLogger());\n\n $result = $client->getAccountById($crmId, $fields);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertArrayHasKey('properties', $result);\n $this->assertEquals($crmId, $result['id']);\n $this->assertEquals((object) $expectedProperties, $result['properties']);\n }\n\n public function testEnsureValidTokenWithNoTokenUpdate(): void\n {\n $socialAccountMock = $this->createMock(SocialAccount::class);\n\n // Set up OAuth account\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, $socialAccountMock);\n\n $originalToken = 'original_token';\n $accessTokenProperty = $reflection->getProperty('accessToken');\n $accessTokenProperty->setAccessible(true);\n $accessTokenProperty->setValue($this->client, $originalToken);\n\n // Mock token manager to return null (no refresh needed)\n $this->tokenManagerMock\n ->expects($this->once())\n ->method('ensureValidToken')\n ->with($socialAccountMock)\n ->willReturn(null);\n\n // Call ensureValidToken\n $this->client->ensureValidToken();\n\n // Verify access token was not changed\n $this->assertEquals($originalToken, $accessTokenProperty->getValue($this->client));\n }\n\n\n\n\n\n\n public function testGetPaginatedDataGeneratorDelegatesToPaginationService(): void\n {\n $payload = ['filters' => []];\n $type = 'contacts';\n $offset = 0;\n $total = 0;\n $lastRecordId = null;\n\n $expectedResults = [\n ['id' => 'id_1', 'properties' => []],\n ['id' => 'id_2', 'properties' => []],\n ];\n\n // Mock the pagination service to return a generator\n $this->paginationServiceMock\n ->expects($this->once())\n ->method('getPaginatedDataGenerator')\n ->with(\n $this->client,\n $payload,\n $type,\n $offset,\n $this->anything(),\n $this->anything()\n )\n ->willReturnCallback(function () use ($expectedResults) {\n foreach ($expectedResults as $result) {\n yield $result;\n }\n });\n\n // Execute the pagination\n $results = [];\n foreach ($this->client->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastRecordId) as $result) {\n $results[] = $result;\n }\n\n $this->assertCount(2, $results);\n $this->assertEquals('id_1', $results[0]['id']);\n $this->assertEquals('id_2', $results[1]['id']);\n }\n\n public function testEnsureValidTokenDelegatesToTokenManager(): void\n {\n $socialAccountMock = $this->createMock(SocialAccount::class);\n\n // Set up OAuth account\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, $socialAccountMock);\n\n // Mock token manager to return new token\n $this->tokenManagerMock\n ->expects($this->once())\n ->method('ensureValidToken')\n ->with($socialAccountMock)\n ->willReturn('new_access_token');\n\n // Call ensureValidToken\n $this->client->ensureValidToken();\n\n // Verify access token was updated\n $accessTokenProperty = $reflection->getProperty('accessToken');\n $accessTokenProperty->setAccessible(true);\n $this->assertEquals('new_access_token', $accessTokenProperty->getValue($this->client));\n }\n\n public function testGetOwnersArchivedWithValidResponse(): void\n {\n $responseData = [\n 'results' => [\n [\n 'id' => '123',\n 'email' => 'owner1@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'John',\n 'lastName' => 'Doe',\n 'userId' => 456,\n 'userIdIncludingInactive' => 789,\n 'createdAt' => '2023-01-01T12:00:00Z',\n 'updatedAt' => '2023-01-02T12:00:00Z',\n 'archived' => true,\n ],\n ],\n ];\n\n // Create a mock response object\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn($responseData);\n\n // Set up the client to return our test data\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=true'\n )\n ->willReturn($response);\n\n // Call the method\n $result = $this->client->getOwnersArchived(true);\n\n // Assert the results\n $this->assertCount(1, $result);\n $this->assertEquals('123', $result[0]->getId());\n $this->assertEquals('owner1@example.com', $result[0]->getEmail());\n $this->assertEquals('John Doe', $result[0]->getFullName());\n $this->assertTrue($result[0]->isArchived());\n }\n\n public function testGetOwnersArchivedWithEmptyResponse(): void\n {\n // Create a mock response object with empty results\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn(['results' => []]);\n\n // Set up the client to return empty results\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=false'\n )\n ->willReturn($response);\n\n // Call the method\n $result = $this->client->getOwnersArchived(false);\n\n // Assert the results\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithInvalidResponse(): void\n {\n // Create a mock response that will throw an exception when toArray is called\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willThrowException(new \\InvalidArgumentException('Invalid JSON'));\n\n // Set up the client to return the problematic response\n $this->client->method('makeRequest')\n ->willReturn($response);\n\n // Mock the logger to expect an error message\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with($this->stringContains('Failed to fetch owners'));\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n // Call the method and expect an empty array on error\n $result = $this->client->getOwnersArchived(true);\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithHttpError(): void\n {\n // Set up the client to throw an exception\n $this->client->method('makeRequest')\n ->willThrowException(new \\Exception('HTTP Error'));\n\n // Mock the logger to expect an error message\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with($this->stringContains('Failed to fetch owners'));\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n // Call the method and expect an empty array on error\n $result = $this->client->getOwnersArchived(false);\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithOwnerCreationException(): void\n {\n $responseData = [\n 'results' => [\n [\n 'id' => '123',\n 'email' => 'valid@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'John',\n 'lastName' => 'Doe',\n 'userId' => 456,\n 'userIdIncludingInactive' => 789,\n 'createdAt' => '2023-01-01T12:00:00Z',\n 'updatedAt' => '2023-01-02T12:00:00Z',\n 'archived' => false,\n ],\n [\n 'id' => '456',\n 'email' => 'invalid@example.com',\n 'type' => 'PERSON',\n 'createdAt' => 'invalid-date-format',\n ],\n [\n 'id' => '789',\n 'email' => 'another@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'Jane',\n 'lastName' => 'Smith',\n 'userId' => 999,\n 'userIdIncludingInactive' => 888,\n 'createdAt' => '2023-01-03T12:00:00Z',\n 'updatedAt' => '2023-01-04T12:00:00Z',\n 'archived' => false,\n ],\n ],\n ];\n\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn($responseData);\n\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=false'\n )\n ->willReturn($response);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with(\n '[HubSpot] Failed to process owner data',\n $this->callback(function ($context) {\n return isset($context['result']) &&\n isset($context['error']) &&\n $context['result']['id'] === '456' &&\n $context['result']['email'] === 'invalid@example.com' &&\n str_contains($context['error'], 'invalid-date-format');\n })\n );\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n $result = $this->client->getOwnersArchived(false);\n\n $this->assertIsArray($result);\n $this->assertCount(2, $result);\n $this->assertEquals('123', $result[0]->getId());\n $this->assertEquals('valid@example.com', $result[0]->getEmail());\n $this->assertEquals('789', $result[1]->getId());\n $this->assertEquals('another@example.com', $result[1]->getEmail());\n }\n\n public function testMakeRequestWithGetMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'GET',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n [],\n null,\n true\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'GET');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithGetMethodAndQueryString(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'GET',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n [],\n 'limit=10&offset=0',\n true\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'GET', [], 'limit=10&offset=0');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPostMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'POST',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'POST', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPutMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'firstname' => 'Jane',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345', 'updated' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'PUT',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'PUT', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPatchMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'email' => 'newemail@example.com',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345', 'patched' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'PATCH',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'PATCH', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithDeleteMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['deleted' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'DELETE',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => []]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'DELETE');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithEmptyPayload(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'ok']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'POST',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n ['json' => []]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'POST', []);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestPrependsBaseUrl(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n $this->anything(),\n 'https://api.hubapi.com/test/endpoint',\n $this->anything()\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $client->makeRequest('/test/endpoint', 'GET');\n }\n\n public function testGetOpportunitiesByIds(): void\n {\n $crmIds = ['deal1', 'deal2', 'deal3'];\n $fields = ['dealname', 'amount'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getOpportunitiesByIds'));\n }\n\n public function testGetCompaniesByIds(): void\n {\n $crmIds = ['company1', 'company2'];\n $fields = ['name', 'industry'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getCompaniesByIds'));\n }\n\n public function testGetContactsByIds(): void\n {\n $crmIds = ['contact1', 'contact2'];\n $fields = ['firstname', 'lastname'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getContactsByIds'));\n }\n\n public function testBatchReadObjectsEmptyIds(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $result = $method->invokeArgs($this->client, ['deals', [], ['dealname']]);\n\n $this->assertEquals([], $result);\n }\n\n public function testBatchReadObjectsExceedsBatchSize(): void\n {\n $crmIds = array_fill(0, 101, 'deal_id'); // 101 IDs, exceeds limit of 100\n\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $this->expectException(\\InvalidArgumentException::class);\n $this->expectExceptionMessage('Batch size cannot exceed 100 deals');\n\n $method->invokeArgs($this->client, ['deals', $crmIds, ['dealname']]);\n }\n\n public function testBatchReadObjectsUnsupportedObjectType(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $this->expectException(\\Jiminny\\Exceptions\\CrmException::class);\n $this->expectExceptionMessage('Failed to batch fetch unsupported');\n\n $method->invokeArgs($this->client, ['unsupported', ['id1'], ['field1']]);\n }\n\n public function testIsUnauthorizedExceptionWithBadRequest401(): void\n {\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\BadRequest('Unauthorized', 401);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithBadRequestNon401(): void\n {\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\BadRequest('Bad Request', 400);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertFalse($result);\n }\n\n public function testIsUnauthorizedExceptionWithGuzzleRequestException(): void\n {\n $response = new Response(401, [], 'Unauthorized');\n $exception = new \\GuzzleHttp\\Exception\\RequestException('Unauthorized', new \\GuzzleHttp\\Psr7\\Request('GET', 'test'), $response);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithGuzzleClientException(): void\n {\n $exception = new \\GuzzleHttp\\Exception\\ClientException('Unauthorized', new \\GuzzleHttp\\Psr7\\Request('GET', 'test'), new Response(401));\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithStringMatching(): void\n {\n $exception = new \\Exception('HTTP 401 Unauthorized error occurred');\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithNonUnauthorized(): void\n {\n $exception = new \\Exception('Some other error');\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertFalse($result);\n }\n\n public function testEnsureValidTokenWithNullOauthAccount(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, null);\n\n // Should not throw exception and not call token manager\n $this->tokenManagerMock->expects($this->never())->method('ensureValidToken');\n\n $this->client->ensureValidToken();\n\n $this->assertTrue(true); // Test passes if no exception is thrown\n }\n\n public function testGetConfig(): void\n {\n $result = $this->client->getConfig();\n\n $this->assertSame($this->config, $result);\n }\n\n public function testGetOwners(): void\n {\n // Test that the method exists and can be called\n $this->assertTrue(method_exists($this->client, 'getOwners'));\n\n // Since getOwners has complex HubSpot API dependencies,\n // we'll just verify the method exists for now\n $this->assertIsCallable([$this->client, 'getOwners']);\n }\n\n public function testCreateMeeting(): void\n {\n $payload = [\n 'properties' => [\n 'hs_meeting_title' => 'Test Meeting',\n 'hs_meeting_start_time' => '2024-01-01T10:00:00Z',\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['id' => 'meeting123']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with('/crm/v3/objects/meetings', 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->createMeeting($payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testUpdateMeeting(): void\n {\n $meetingId = 'meeting123';\n $payload = [\n 'properties' => [\n 'hs_meeting_title' => 'Updated Meeting',\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['id' => $meetingId, 'updated' => true]);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v3/objects/meetings/{$meetingId}\", 'PATCH', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->updateMeeting($meetingId, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testAddAssociations(): void\n {\n $objectType = 'deals';\n $associationType = 'contacts';\n $payload = [\n 'inputs' => [\n ['from' => ['id' => 'deal1'], 'to' => ['id' => 'contact1']],\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['status' => 'success']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v4/associations/{$objectType}/{$associationType}/batch/create\", 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->addAssociations($objectType, $associationType, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testRemoveAssociations(): void\n {\n $objectType = 'deals';\n $associationType = 'contacts';\n $payload = [\n 'inputs' => [\n ['from' => ['id' => 'deal1'], 'to' => ['id' => 'contact1']],\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['status' => 'success']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v4/associations/{$objectType}/{$associationType}/batch/archive\", 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->removeAssociations($objectType, $associationType, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n // -------------------------------------------------------------------------\n // search() / executeRequest() tests\n // -------------------------------------------------------------------------\n\n public function testSearchReturnsDecodedArrayOnSuccess(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n\n $this->config->method('getId')->willReturn(42);\n\n $payload = ['filters' => []];\n $responseData = ['results' => [['id' => '1']], 'total' => 1, 'paging' => []];\n $hubspotResponse = new HubspotResponse(new Response(200, [], json_encode($responseData)));\n\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->with('POST', Client::BASE_URL . '/crm/v3/objects/contacts/search', ['json' => $payload])\n ->willReturn($hubspotResponse);\n\n $result = $this->client->search('contacts', $payload);\n\n $this->assertSame($responseData, $result);\n\n Mockery::close();\n }\n\n public function testSearchThrowsRateLimitExceptionWhenCircuitBreakerActive(): void\n {\n $futureTimestamp = (string) (time() + 30);\n\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn($futureTimestamp);\n\n $this->config->method('getId')->willReturn(42);\n\n $this->hubspotClientMock->expects($this->never())->method('request');\n\n $this->expectException(RateLimitException::class);\n $this->expectExceptionMessage('Hubspot rate limit (cached circuit-breaker)');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchThrowsRateLimitExceptionAndSetsNxOnFresh429(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n // NX + TTL option array — exact TTL depends on parseRetryAfter, verified separately\n $redisMock->shouldReceive('set')\n ->once()\n ->with(\n 'hubspot:ratelimit:config:42',\n Mockery::type('string'),\n Mockery::on(fn ($opts) => is_array($opts) && in_array('nx', $opts, true))\n );\n\n $this->config->method('getId')->willReturn(42);\n\n $badRequest = new BadRequest('Rate limit exceeded', 429);\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->willThrowException($badRequest);\n\n $this->expectException(RateLimitException::class);\n $this->expectExceptionMessage('Hubspot returned 429');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchPropagatesNonRateLimitException(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n\n $this->config->method('getId')->willReturn(42);\n\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\HubspotException('Server error', 500);\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->willThrowException($exception);\n\n $this->expectException(\\SevenShores\\Hubspot\\Exceptions\\HubspotException::class);\n $this->expectExceptionMessage('Server error');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchCircuitBreakerRetryAfterComputedFromStoredTimestamp(): void\n {\n $remaining = 45;\n $futureTimestamp = (string) (time() + $remaining);\n\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn($futureTimestamp);\n\n $this->config->method('getId')->willReturn(1);\n\n try {\n $this->client->search('deals', []);\n $this->fail('Expected RateLimitException');\n } catch (RateLimitException $e) {\n $this->assertGreaterThanOrEqual(1, $e->getRetryAfter());\n $this->assertLessThanOrEqual($remaining, $e->getRetryAfter());\n } finally {\n Mockery::close();\n }\n }\n\n // -------------------------------------------------------------------------\n // isHubspotRateLimit() tests (private method — tested via reflection)\n // -------------------------------------------------------------------------\n\n private function callIsHubspotRateLimit(\\Throwable $e): bool\n {\n $method = new \\ReflectionMethod($this->client, 'isHubspotRateLimit');\n $method->setAccessible(true);\n\n return $method->invoke($this->client, $e);\n }\n\n public function testIsHubspotRateLimitReturnsTrueForBadRequest429(): void\n {\n $e = new BadRequest('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForDealApiException429(): void\n {\n $e = new DealApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForContactApiException429(): void\n {\n $e = new ContactApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForCompanyApiException429(): void\n {\n $e = new CompanyApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForGuzzleRequestException429(): void\n {\n $request = new \\GuzzleHttp\\Psr7\\Request('POST', 'https://api.hubapi.com/test');\n $response = new \\GuzzleHttp\\Psr7\\Response(429);\n $e = new \\GuzzleHttp\\Exception\\RequestException('Too Many Requests', $request, $response);\n\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsFalseForBadRequestNon429(): void\n {\n $e = new BadRequest('Bad Request', 400);\n $this->assertFalse($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsFalseForGenericException(): void\n {\n $e = new \\RuntimeException('Something went wrong');\n $this->assertFalse($this->callIsHubspotRateLimit($e));\n }\n\n // -------------------------------------------------------------------------\n // parseRetryAfter() tests (private method — tested via reflection)\n // -------------------------------------------------------------------------\n\n private function callParseRetryAfter(\\Throwable $e): int\n {\n $method = new \\ReflectionMethod($this->client, 'parseRetryAfter');\n $method->setAccessible(true);\n\n return $method->invoke($this->client, $e);\n }\n\n public function testParseRetryAfterReadsRetryAfterHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => ['30']]);\n $this->assertSame(30, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReadsLowercaseRetryAfterHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['retry-after' => ['15']]);\n $this->assertSame(15, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterHandlesScalarHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => '60']);\n $this->assertSame(60, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsDailyLimitDelay(): void\n {\n $e = new BadRequest('Daily rate limit exceeded');\n $this->assertSame(600, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsTenSecondlyDelay(): void\n {\n $e = new BadRequest('Ten secondly rate limit exceeded');\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsSecondlyDelay(): void\n {\n $e = new BadRequest('Secondly rate limit exceeded');\n $this->assertSame(1, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsDefaultWhenNoHint(): void\n {\n $e = new BadRequest('Rate limit exceeded');\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterIgnoresNonNumericHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => ['not-a-number']]);\n // Falls through to message parsing; \"Too Many Requests\" has no known keyword → default 10\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.96276593,"top":0.07581804,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.9740692,"top":0.074221864,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.98138297,"top":0.074221864,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
4682894321548166980
|
4446428687123393012
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
ClientTest
Run 'ClientTest'
Debug 'ClientTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
144
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Hubspot;
use GuzzleHttp\Psr7\Response;
use HubSpot\Client\Crm\Associations\Api\BatchApi;
use HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId;
use HubSpot\Client\Crm\Associations\Model\BatchResponsePublicAssociationMulti;
use HubSpot\Client\Crm\Associations\Model\PublicObjectId;
use HubSpot\Client\Crm\Companies\ApiException as CompanyApiException;
use HubSpot\Client\Crm\Contacts\ApiException as ContactApiException;
use HubSpot\Client\Crm\Deals\Api\BasicApi as DealsBasicApi;
use HubSpot\Client\Crm\Deals\ApiException as DealApiException;
use HubSpot\Client\Crm\Deals\Model\SimplePublicObjectWithAssociations as DealWithAssociations;
use HubSpot\Client\Crm\Pipelines\Api\PipelinesApi;
use HubSpot\Client\Crm\Pipelines\Model\CollectionResponsePipeline;
use HubSpot\Client\Crm\Pipelines\Model\Error;
use HubSpot\Client\Crm\Pipelines\Model\Pipeline;
use HubSpot\Client\Crm\Pipelines\Model\PipelineStage;
use HubSpot\Client\Crm\Properties\Api\CoreApi;
use HubSpot\Client\Crm\Properties\Model\Property;
use HubSpot\Discovery as HubSpotDiscovery;
use HubSpot\Discovery\Crm\Deals\Discovery as DealsDiscovery;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\SocialAccount;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\HubspotTokenManager;
use Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService;
use Jiminny\Services\SocialAccountService;
use League\OAuth2\Client\Token\AccessToken;
use Mockery;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use SevenShores\Hubspot\Endpoints\Engagements;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Factory;
use SevenShores\Hubspot\Http\Client as HubspotClient;
use SevenShores\Hubspot\Http\Response as HubspotResponse;
/**
* @runTestsInSeparateProcesses
*
* @preserveGlobalState disabled
*/
class ClientTest extends TestCase
{
private const string RESPONSE_TYPE_STAGE_FIELD = 'stage';
private const string RESPONSE_TYPE_PIPELINE_FIELD = 'pipeline';
private const string RESPONSE_TYPE_REGULAR_FIELD = 'regular';
/**
* @var Client&MockObject
*/
private Client $client;
private Configuration $config;
/**
* @var SocialAccountService&MockObject
*/
private SocialAccountService $socialAccountServiceMock;
/**
* @var HubspotPaginationService&MockObject
*/
private HubspotPaginationService $paginationServiceMock;
/**
* @var HubspotTokenManager&MockObject
*/
private HubspotTokenManager $tokenManagerMock;
/**
* @var CoreApi&MockObject
*/
private CoreApi $coreApiMock;
/**
* @var PipelinesApi&MockObject
*/
private PipelinesApi $pipelinesApiMock;
/**
* @var BatchApi&MockObject
*/
private BatchApi $associationsBatchApiMock;
/**
* @var DealsBasicApi&MockObject
*/
private DealsBasicApi $dealsBasicApiMock;
/**
* @var Engagements&MockObject
*/
private $engagementsMock;
/**
* @var HubspotClient&MockObject
*/
private HubspotClient $hubspotClientMock;
protected function setUp(): void
{
// Create mocks for dependencies
$this->socialAccountServiceMock = $this->createMock(SocialAccountService::class);
$this->paginationServiceMock = $this->createMock(HubspotPaginationService::class);
$this->tokenManagerMock = $this->createMock(HubspotTokenManager::class);
// Create a real Client instance with mocked dependencies
// Create a partial mock only for the methods we need to mock
$client = $this->createPartialMock(Client::class, ['getInstance', 'getNewInstance', 'makeRequest']);
$client->setLogger(new NullLogger());
// Inject the real dependencies using reflection
$reflection = new \ReflectionClass(Client::class);
$accountServiceProperty = $reflection->getProperty('accountService');
$accountServiceProperty->setAccessible(true);
$accountServiceProperty->setValue($client, $this->socialAccountServiceMock);
$paginationServiceProperty = $reflection->getProperty('paginationService');
$paginationServiceProperty->setAccessible(true);
$paginationServiceProperty->setValue($client, $this->paginationServiceMock);
$tokenManagerProperty = $reflection->getProperty('tokenManager');
$tokenManagerProperty->setAccessible(true);
$tokenManagerProperty->setValue($client, $this->tokenManagerMock);
$factoryMock = $this->createMock(Factory::class);
$hubspotClientMock = $this->createMock(HubspotClient::class);
$engagementsMock = $this->createMock(\SevenShores\Hubspot\Endpoints\Engagements::class);
$factoryMock->method('getClient')->willReturn($hubspotClientMock);
$factoryMock->method('__call')->with('engagements')->willReturn($engagementsMock);
$client->method('getInstance')->willReturn($factoryMock);
$discoveryMock = $this->createMock(HubSpotDiscovery\Discovery::class);
$crmMock = $this->createMock(HubSpotDiscovery\Crm\Discovery::class);
$associationsMock = $this->createMock(HubSpotDiscovery\Crm\Associations\Discovery::class);
$propertiesMock = $this->createMock(HubSpotDiscovery\Crm\Properties\Discovery::class);
$pipelinesMock = $this->createMock(HubSpotDiscovery\Crm\Pipelines\Discovery::class);
$coreApiMock = $this->createMock(CoreApi::class);
$pipelinesApiMock = $this->createMock(PipelinesApi::class);
$associationsBatchApiMock = $this->createMock(BatchApi::class);
$dealsBasicApiMock = $this->createMock(DealsBasicApi::class);
$propertiesMock->method('__call')->with('coreApi')->willReturn($coreApiMock);
$pipelinesMock->method('__call')->with('pipelinesApi')->willReturn($pipelinesApiMock);
$associationsMock->method('__call')->with('batchApi')->willReturn($associationsBatchApiMock);
$dealsDiscoveryMock = $this->createMock(DealsDiscovery::class);
$dealsDiscoveryMock->method('__call')->with('basicApi')->willReturn($dealsBasicApiMock);
$returnMap = ['properties' => $propertiesMock, 'pipelines' => $pipelinesMock, 'associations' => $associationsMock, 'deals' => $dealsDiscoveryMock];
$crmMock->method('__call')
->willReturnCallback(static fn (string $name) => $returnMap[$name])
;
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client->method('getNewInstance')->willReturn($discoveryMock);
$this->client = $client;
$this->config = $this->createMock(Configuration::class);
$this->client->setConfiguration($this->config);
$this->coreApiMock = $coreApiMock;
$this->pipelinesApiMock = $pipelinesApiMock;
$this->associationsBatchApiMock = $associationsBatchApiMock;
$this->dealsBasicApiMock = $dealsBasicApiMock;
$this->engagementsMock = $engagementsMock;
$this->hubspotClientMock = $hubspotClientMock;
}
public function testGetMinimumApiVersion(): void
{
$this->assertIsString($this->client->getMinimumApiVersion());
}
public function testGetInstance(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManager = $this->createMock(HubspotTokenManager::class);
$client = new Client($socialAccountService, $paginationService, $tokenManager);
$client->setBaseUrl('[URL_WITH_CREDENTIALS] The class CollectionResponsePipeline will be deprecated in the next Hubspot version
*/
$this->pipelinesApiMock
->method('getAll')
->with('deals')
->willReturn(new CollectionResponsePipeline([
'results' => [$this->generatePipeline()],
]))
;
$this->assertEquals(
[
['id' => 'foo', 'label' => 'bar'],
['id' => 'baz', 'label' => 'qux'],
],
$this->client->fetchOpportunityPipelineStages()
);
}
public function testFetchOpportunityPipelineStagesErrorResponse(): void
{
$this->pipelinesApiMock
->method('getAll')
->with('deals')
->willReturn(new Error(['message' => 'test error']))
;
$this->assertEmpty($this->client->fetchOpportunityPipelineStages());
}
#[\PHPUnit\Framework\Attributes\DataProvider('meetingOutcomeFieldProvider')]
public function testFetchMeetingOutcomeFieldOptions(string $fieldId, string $expectedEndpoint): void
{
$field = new Field(['crm_provider_id' => $fieldId]);
$this->hubspotClientMock
->expects($this->once())
->method('request')
->with('GET', $expectedEndpoint)
->willReturn($this->generateHubSpotResponse(
[
'options' => [
['value' => 'option_1', 'label' => 'Option 1', 'displayOrder' => 0],
['value' => 'option_2', 'label' => 'Option 2', 'displayOrder' => 1],
['value' => 'option_3', 'label' => 'Option 3', 'displayOrder' => 2],
],
]
))
;
$this->assertEquals(
[
['id' => 'option_1', 'value' => 'option_1', 'label' => 'Option 1', 'display_order' => 0],
['id' => 'option_2', 'value' => 'option_2', 'label' => 'Option 2', 'display_order' => 1],
['id' => 'option_3', 'value' => 'option_3', 'label' => 'Option 3', 'display_order' => 2],
],
$this->client->fetchMeetingOutcomeFieldOptions($field)
);
}
public static function meetingOutcomeFieldProvider(): array
{
return [
'meeting outcome field' => [
'meetingOutcome',
'[URL_WITH_CREDENTIALS] The class CollectionResponsePipeline will be deprecated in the next Hubspot version
*/
$pipelineStagesResponse = new CollectionResponsePipeline([
'results' => [$this->generatePipeline()],
]);
$this->pipelinesApiMock
->method('getAll')
->willReturn($pipelineStagesResponse);
}
if ($type === self::RESPONSE_TYPE_PIPELINE_FIELD) {
$field->method('isStageField')->willReturn(false);
$field->method('isPipelineField')->willReturn(true);
$pipelineResponse = $this->generateHubSpotResponse([
'results' => [
['id' => '123', 'label' => 'Sales'],
['id' => 'default', 'label' => 'CS'],
],
]);
$this->client
->method('makeRequest')
->with('/crm/v3/pipelines/deals')
->willReturn($pipelineResponse);
}
$this->assertEquals(
$responses[$type],
$this->client->fetchOpportunityFieldOptions($field)
);
}
public static function opportunityFieldOptionsProvider(): array
{
return [
'stage field' => [
'field' => new Field(['crm_provider_id' => 'dealstage']),
'type' => self::RESPONSE_TYPE_STAGE_FIELD,
],
'pipeline field' => [
'field' => new Field(['crm_provider_id' => 'pipeline']),
'type' => self::RESPONSE_TYPE_PIPELINE_FIELD,
],
'regular field' => [
'field' => new Field(['crm_provider_id' => 'some_property']),
'type' => self::RESPONSE_TYPE_REGULAR_FIELD,
],
];
}
private function generateHubSpotResponse(array $data): HubspotResponse
{
return new HubspotResponse(new Response(200, [], json_encode($data)));
}
private function generateProperty(): Property
{
return new Property([
'name' => 'some_property',
'options' => [
[
'label' => 'label_1',
'value' => 'value_1',
],
[
'label' => 'label_2',
'value' => 'value_2',
],
],
]);
}
private function generatePipeline(): Pipeline
{
return new Pipeline(['stages' => [
new PipelineStage(['id' => 'foo', 'label' => 'bar']),
new PipelineStage(['id' => 'baz', 'label' => 'qux']),
]]);
}
public function testFetchOpportunityPipelines(): void
{
$this->client
->method('makeRequest')
->with('/crm/v3/pipelines/deals')
->willReturn($this->generateHubSpotResponse([
'results' => [
['id' => 'id_1', 'label' => 'Option 1', 'displayOrder' => 0],
['id' => 'id_2', 'label' => 'Option 2', 'displayOrder' => 1],
['id' => 'id_3', 'label' => 'Option 3', 'displayOrder' => 2],
],
]));
$this->assertEquals(
[
['id' => 'id_1', 'label' => 'Option 1'],
['id' => 'id_2', 'label' => 'Option 2'],
['id' => 'id_3', 'label' => 'Option 3'],
],
$this->client->fetchOpportunityPipelines()
);
}
public function testGetPaginatedData(): void
{
$expectedResults = [
['id' => 'id_1', 'properties' => []],
['id' => 'id_2', 'properties' => []],
['id' => 'id_3', 'properties' => []],
];
// Mock the pagination service to return a generator and modify reference parameters
$this->paginationServiceMock
->expects($this->once())
->method('getPaginatedDataGenerator')
->with(
$this->client,
['payload_key' => 'payload_value'],
'foobar',
0,
$this->anything(),
$this->anything()
)
->willReturnCallback(function ($client, $payload, $type, $offset, &$total, &$lastRecordId) use ($expectedResults) {
$total = 3;
$lastRecordId = 'id_3';
foreach ($expectedResults as $result) {
yield $result;
}
});
$this->assertEquals(
[
'results' => $expectedResults,
'total' => 3,
'last_record' => 'id_3',
],
$this->client->getPaginatedData(['payload_key' => 'payload_value'], 'foobar')
);
}
public function testGetAssociationsData(): void
{
$ids = ['1', '2', '3'];
$fromObject = 'deals';
$toObject = 'contacts';
$responseResults = [];
foreach ($ids as $id) {
$from = new PublicObjectId();
$from->setId($id);
$to1 = new PublicObjectId();
$to1->setId('contact_' . $id . '_1');
$to2 = new PublicObjectId();
$to2->setId('contact_' . $id . '_2');
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to1, $to2]);
$responseResults[] = $result;
}
$batchResponse = new BatchResponsePublicAssociationMulti();
$batchResponse->setResults($responseResults);
$this->associationsBatchApiMock->expects($this->once())
->method('read')
->with(
$this->equalTo($fromObject),
$this->equalTo($toObject),
$this->callback(function (BatchInputPublicObjectId $batchInput) use ($ids) {
$inputIds = array_map(
fn ($input) => $input->getId(),
$batchInput->getInputs()
);
return $inputIds === $ids;
})
)
->willReturn($batchResponse);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$expectedResult = [
'1' => ['contact_1_1', 'contact_1_2'],
'2' => ['contact_2_1', 'contact_2_2'],
'3' => ['contact_3_1', 'contact_3_2'],
];
$this->assertEquals($expectedResult, $result);
}
public function testGetAssociationsDataHandlesException(): void
{
$ids = ['1', '2', '3'];
$fromObject = 'deals';
$toObject = 'contacts';
$exception = new \Exception('API Error');
$this->associationsBatchApiMock->expects($this->once())
->method('read')
->with(
$this->equalTo($fromObject),
$this->equalTo($toObject),
$this->callback(function ($batchInput) use ($ids) {
return $batchInput instanceof \HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId
&& count($batchInput->getInputs()) === count($ids);
})
)
->willThrowException($exception);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with(
'[Hubspot] Failed to fetch associations',
[
'from_object' => $fromObject,
'to_object' => $toObject,
'reason' => 'API Error',
]
);
$this->client->setLogger($loggerMock);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$this->assertEmpty($result);
$this->assertIsArray($result);
}
public function testGetAssociationsDataWithLargeDataSet(): void
{
$ids = array_map(fn ($i) => (string) $i, range(1, 2500)); // More than 1000 items
$fromObject = 'deals';
$toObject = 'contacts';
$firstBatchResponse = new BatchResponsePublicAssociationMulti();
$firstBatchResults = array_map(function ($id) {
$from = new PublicObjectId();
$from->setId($id);
$to = new PublicObjectId();
$to->setId('contact_' . $id);
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to]);
return $result;
}, array_slice($ids, 0, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));
$firstBatchResponse->setResults($firstBatchResults);
$secondBatchResponse = new BatchResponsePublicAssociationMulti();
$secondBatchResults = array_map(function ($id) {
$from = new PublicObjectId();
$from->setId($id);
$to = new PublicObjectId();
$to->setId('contact_' . $id);
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to]);
return $result;
}, array_slice($ids, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));
$secondBatchResponse->setResults($secondBatchResults);
$this->associationsBatchApiMock->expects($this->exactly(3))
->method('read')
->willReturnOnConsecutiveCalls($firstBatchResponse, $secondBatchResponse);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$this->assertCount(2500, $result);
$this->assertArrayHasKey('1', $result);
$this->assertArrayHasKey('2500', $result);
$this->assertEquals(['contact_1'], $result['1']);
$this->assertEquals(['contact_2500'], $result['2500']);
}
public function testGetContactByEmailSuccess(): void
{
$email = '[EMAIL]';
$fields = ['firstname', 'lastname', 'email'];
$contactMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$contactMock->method('getId')->willReturn('12345');
$contactMock->method('getProperties')->willReturn([
'firstname' => 'John',
'lastname' => 'Doe',
'email' => '[EMAIL]',
]);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, 'firstname,lastname,email', null, false, 'email')
->willReturn($contactMock);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new NullLogger());
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([
'id' => '12345',
'properties' => [
'firstname' => 'John',
'lastname' => 'Doe',
'email' => '[EMAIL]',
],
], $result);
}
public function testGetContactByEmailWithEmptyFields(): void
{
$email = '[EMAIL]';
$fields = [];
$contactMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$contactMock->method('getId')->willReturn('12345');
$contactMock->method('getProperties')->willReturn(['email' => '[EMAIL]']);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, '', null, false, 'email')
->willReturn($contactMock);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new NullLogger());
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([
'id' => '12345',
'properties' => ['email' => '[EMAIL]'],
], $result);
}
public function testGetContactByEmailApiException(): void
{
$email = '[EMAIL]';
$fields = ['firstname', 'lastname'];
$exception = new \HubSpot\Client\Crm\Contacts\ApiException('Contact not found', 404);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, 'firstname,lastname', null, false, 'email')
->willThrowException($exception);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('info')
->with(
'[Hubspot] Failed to fetch contact',
[
'email' => $email,
'reason' => 'Contact not found',
]
);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger($loggerMock);
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([], $result);
}
public function testGetOpportunityById(): void
{
$opportunityId = '12345';
$expectedProperties = [
'dealname' => 'Test Opportunity',
'amount' => '1000.00',
'closedate' => '2024-12-31T23:59:59.999Z',
'dealstage' => 'presentationscheduled',
'pipeline' => 'default',
];
$mockHubspotOpportunity = $this->createMock(DealWithAssociations::class);
$mockHubspotOpportunity->method('getProperties')->willReturn((object) $expectedProperties);
$mockHubspotOpportunity->method('getId')->willReturn($opportunityId);
$now = new \DateTimeImmutable();
$mockHubspotOpportunity->method('getCreatedAt')->willReturn($now);
$mockHubspotOpportunity->method('getUpdatedAt')->willReturn($now);
$mockHubspotOpportunity->method('getArchived')->willReturn(false);
$this->dealsBasicApiMock
->expects($this->once())
->method('getById')
->willReturn($mockHubspotOpportunity);
// Assuming Client::getOpportunityById processes the SimplePublicObject and returns an associative array.
// The structure might be like: ['id' => ..., 'properties' => [...], 'createdAt' => ..., ...]
// Adjust assertions below based on the actual return structure of your method.
$result = $this->client->getOpportunityById($opportunityId, ['test', 'test']);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertEquals($opportunityId, $result['id']);
}
public function testGetContactById(): void
{
$crmId = 'contact-123';
$fields = ['firstname', 'lastname'];
$expectedProperties = ['firstname' => 'John', 'lastname' => 'Doe'];
$mockContact = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$mockContact->method('getId')->willReturn($crmId);
$mockContact->method('getProperties')->willReturn((object) $expectedProperties);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($crmId, 'firstname,lastname')
->willReturn($mockContact);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new \Psr\Log\NullLogger());
$result = $client->getContactById($crmId, $fields);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('properties', $result);
$this->assertEquals($crmId, $result['id']);
$this->assertEquals((object) $expectedProperties, $result['properties']);
}
public function testGetAccountById(): void
{
$crmId = 'account-123';
$fields = ['name', 'industry'];
$expectedProperties = ['name' => 'Acme Corp', 'industry' => 'Technology'];
$mockCompany = $this->createMock(\HubSpot\Client\Crm\Companies\Model\SimplePublicObjectWithAssociations::class);
$mockCompany->method('getId')->willReturn($crmId);
$mockCompany->method('getProperties')->willReturn((object) $expectedProperties);
$companiesApiMock = $this->createMock(\HubSpot\Client\Crm\Companies\Api\BasicApi::class);
$companiesApiMock->expects($this->once())
->method('getById')
->with($crmId, 'name,industry')
->willReturn($mockCompany);
$companiesDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Companies\Discovery::class);
$companiesDiscoveryMock->method('__call')->with('basicApi')->willReturn($companiesApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($companiesDiscoveryMock) {
if ($name === 'companies') {
return $companiesDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new \Psr\Log\NullLogger());
$result = $client->getAccountById($crmId, $fields);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('properties', $result);
$this->assertEquals($crmId, $result['id']);
$this->assertEquals((object) $expectedProperties, $result['properties']);
}
public function testEnsureValidTokenWithNoTokenUpdate(): void
{
$socialAccountMock = $this->createMock(SocialAccount::class);
// Set up OAuth account
$reflection = new \ReflectionClass($this->client);
$oauthAccountProperty = $reflection->getProperty('oauthAccount');
$oauthAccountProperty->setAccessible(true);
$oauthAccountProperty->setValue($this->client, $socialAccountMock);
$originalToken = 'original_token';
$accessTokenProperty = $reflection->getProperty('accessToken');
$accessTokenProperty->setAccessible(true);
$accessTokenProperty->setValue($this->client, $originalToken);
// Mock token manager to return null (no refresh needed)
$this->tokenManagerMock
->expects($this->once())
->method('ensureValidToken')
->with($socialAccountMock)
->willReturn(null);
// Call ensureValidToken
$this->client->ensureValidToken();
// Verify access token was not changed
$this->assertEquals($originalToken, $accessTokenProperty->getValue($this->client));
}
public function testGetPaginatedDataGeneratorDelegatesToPaginationService(): void
{
$payload = ['filters' => []];
$type = 'contacts';
$offset = 0;
$total = 0;
$lastRecordId = null;
$expectedResults = [
['id' => 'id_1', 'properties' => []],
['id' => 'id_2', 'properties' => []],
];
// Mock the pagination service to return a generator
$this->paginationServiceMock
->expects($this->once())
->method('getPaginatedDataGenerator')
->with(
$this->client,
$payload,
$type,
$offset,
$this->anything(),
$this->anything()
)
->willReturnCallback(function () use ($expectedResults) {
foreach ($expectedResults as $result) {
yield $result;
}
});
// Execute the pagination
$results = [];
foreach ($this->client->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastRecordId) as $result) {
$results[] = $result;
}
$this->assertCount(2, $results);
$this->assertEquals('id_1', $results[0]['id']);
$this->assertEquals('id_2', $results[1]['id']);
}
public function testEnsureValidTokenDelegatesToTokenManager(): void
{
$socialAccountMock = $this->createMock(SocialAccount::class);
// Set up OAuth account
$reflection = new \ReflectionClass($this->client);
$oauthAccountProperty = $reflection->getProperty('oauthAccount');
$oauthAccountProperty->setAccessible(true);
$oauthAccountProperty->setValue($this->client, $socialAccountMock);
// Mock token manager to return new token
$this->tokenManagerMock
->expects($this->once())
->method('ensureValidToken')
->with($socialAccountMock)
->willReturn('new_access_token');
// Call ensureValidToken
$this->client->ensureValidToken();
// Verify access token was updated
$accessTokenProperty = $reflection->getProperty('accessToken');
$accessTokenProperty->setAccessible(true);
$this->assertEquals('new_access_token', $accessTokenProperty->getValue($this->client));
}
public function testGetOwnersArchivedWithValidResponse(): void
{
$responseData = [
'results' => [
[
'id' => '123',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'John',
'lastName' => 'Doe',
'userId' => 456,
'userIdIncludingInactive' => 789,
'createdAt' => '2023-01-01T12:00:00Z',
'updatedAt' => '2023-01-02T12:00:00Z',
'archived' => true,
],
],
];
// Create a mock response object
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn($responseData);
// Set up the client to return our test data
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=true'
)
->willReturn($response);
// Call the method
$result = $this->client->getOwnersArchived(true);
// Assert the results
$this->assertCount(1, $result);
$this->assertEquals('123', $result[0]->getId());
$this->assertEquals('[EMAIL]', $result[0]->getEmail());
$this->assertEquals('John Doe', $result[0]->getFullName());
$this->assertTrue($result[0]->isArchived());
}
public function testGetOwnersArchivedWithEmptyResponse(): void
{
// Create a mock response object with empty results
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn(['results' => []]);
// Set up the client to return empty results
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=false'
)
->willReturn($response);
// Call the method
$result = $this->client->getOwnersArchived(false);
// Assert the results
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithInvalidResponse(): void
{
// Create a mock response that will throw an exception when toArray is called
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willThrowException(new \InvalidArgumentException('Invalid JSON'));
// Set up the client to return the problematic response
$this->client->method('makeRequest')
->willReturn($response);
// Mock the logger to expect an error message
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with($this->stringContains('Failed to fetch owners'));
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
// Call the method and expect an empty array on error
$result = $this->client->getOwnersArchived(true);
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithHttpError(): void
{
// Set up the client to throw an exception
$this->client->method('makeRequest')
->willThrowException(new \Exception('HTTP Error'));
// Mock the logger to expect an error message
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with($this->stringContains('Failed to fetch owners'));
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
// Call the method and expect an empty array on error
$result = $this->client->getOwnersArchived(false);
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithOwnerCreationException(): void
{
$responseData = [
'results' => [
[
'id' => '123',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'John',
'lastName' => 'Doe',
'userId' => 456,
'userIdIncludingInactive' => 789,
'createdAt' => '2023-01-01T12:00:00Z',
'updatedAt' => '2023-01-02T12:00:00Z',
'archived' => false,
],
[
'id' => '456',
'email' => '[EMAIL]',
'type' => 'PERSON',
'createdAt' => 'invalid-date-format',
],
[
'id' => '789',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'Jane',
'lastName' => 'Smith',
'userId' => 999,
'userIdIncludingInactive' => 888,
'createdAt' => '2023-01-03T12:00:00Z',
'updatedAt' => '2023-01-04T12:00:00Z',
'archived' => false,
],
],
];
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn($responseData);
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=false'
)
->willReturn($response);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with(
'[HubSpot] Failed to process owner data',
$this->callback(function ($context) {
return isset($context['result']) &&
isset($context['error']) &&
$context['result']['id'] === '456' &&
$context['result']['email'] === '[EMAIL]' &&
str_contains($context['error'], 'invalid-date-format');
})
);
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
$result = $this->client->getOwnersArchived(false);
$this->assertIsArray($result);
$this->assertCount(2, $result);
$this->assertEquals('123', $result[0]->getId());
$this->assertEquals('[EMAIL]', $result[0]->getEmail());
$this->assertEquals('789', $result[1]->getId());
$this->assertEquals('[EMAIL]', $result[1]->getEmail());
}
public function testMakeRequestWithGetMethod(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManager = $this->createMock(HubspotTokenManager::class);
$psrResponse = new Response(200, [], json_encode(['status' => 'success']));
$expectedResponse = new HubspotResponse($psrResponse);
$hubspotClientMock = $this->createMock(HubspotClient::class);
$hubspotClientMock->expects($this->once())
->method('request')
->with(
'GET',
'https://api.hubapi.com/crm/v3/objects/contacts',
[],
null,
true
)
->willReturn($expectedResponse);
$factoryMock = $this->createMock(Factory::class);
$factoryMock->method('getClient')->willReturn($hubspotClientMock);
$client = $this->getMockBuilder(Client::class)
->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])
->onlyMethods(['getInstance'])
->getMock();
$client->method('getInstance')->willReturn($factoryMock);
$client->setAccessToken(new AccessToken(['access_token' => 'test_token']));
$result = $client->makeRequest('/crm/v3/objects/contacts', 'GET');
$this->assertSame($expectedResponse, $result);
}
public function testMakeRequestWithGetMethodAndQueryString(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManag...
|
20505
|
NULL
|
NULL
|
NULL
|
|
20526
|
891
|
4
|
2026-05-11T15:46:22.127453+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778514382127_m2.jpg...
|
PhpStorm
|
faVsco.js – Client.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
ClientTest
Run 'ClientTest'
Debug 'ClientTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
2
66
2
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use HubSpot\Client\Crm\Deals\ApiException as DealApiException;
use HubSpot\Client\Crm\Contacts\ApiException as ContactApiException;
use HubSpot\Client\Crm\Companies\ApiException as CompanyApiException;
use HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations as ContactsWithAssociations;
use HubSpot\Client\Crm\Companies\Model\SimplePublicObjectWithAssociations as CompaniesWithAssociations;
use HubSpot\Client\Crm\Deals\Model\SimplePublicObjectWithAssociations as DealWithAssociations;
use HubSpot\Client\Crm\Objects\Model\SimplePublicObjectInput;
use HubSpot\Client\Crm\Objects\Model\SimplePublicObjectWithAssociations as ObjectWithAssociations;
use HubSpot\Client\Crm\Pipelines\Model\Error;
use HubSpot\Client\Crm\Pipelines\Model\PipelineStage;
use HubSpot\Client\Crm\Properties\Model\Property;
use HubSpot\Discovery\Discovery;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Crm\Field;
use Jiminny\Services\Crm\BaseClient;
use Jiminny\Services\Crm\Hubspot\DTO\Response\Owner;
use Jiminny\Services\SocialAccountService;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
use SevenShores\Hubspot\Factory;
use SevenShores\Hubspot\Http\Response;
use Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService;
use Illuminate\Support\Facades\Redis;
use Throwable;
/**
* @phpstan-type CrmFieldOption array{id:string, label:string, value?:string}
*/
class Client extends BaseClient implements HubspotClientInterface
{
public const string MIN_API_VERSION = '2';
public const string BASE_URL = '[URL_WITH_CREDENTIALS] T
*
* @param callable(): T $apiCall The API call to execute
*
* @throws RateLimitException When rate limit is hit or cached rate limit is active
*
* @return T The result of the API call
*/
private function executeRequest(callable $apiCall)
{
$cacheKey = $this->getRateLimitCacheKey();
$cachedExpiresAt = Redis::get($cacheKey);
if (is_string($cachedExpiresAt) && is_numeric($cachedExpiresAt)) {
$remaining = max(1, (int) $cachedExpiresAt - time());
throw new RateLimitException(
'Hubspot rate limit (cached circuit-breaker)',
$remaining,
);
}
try {
return $apiCall();
} catch (Throwable $e) {
if ($this->isHubspotRateLimit($e)) {
$retryAfter = $this->parseRetryAfter($e);
// NX: only the first job to receive a 429 in this burst sets the key.
// Subsequent 429s in the same burst leave the TTL untouched so the
// window is not reset by every concurrent job hitting the limit.
Redis::set($cacheKey, (string) (time() + $retryAfter), ['nx', 'ex' => $retryAfter]);
$this->log->warning('[Hubspot] Received 429 from API', [
'team_id' => $this->config->team_id,
'config_id' => $this->config->getId(),
'retry_after' => $retryAfter,
'reason' => $e->getMessage(),
]);
throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);
}
throw $e;
}
}
private function getRateLimitCacheKey(): string
{
return sprintf('hubspot:ratelimit:config:%d', $this->config->getId());
}
private function isHubspotRateLimit(Throwable $e): bool
{
if ($e instanceof BadRequest
|| $e instanceof DealApiException
|| $e instanceof ContactApiException
|| $e instanceof CompanyApiException
|| $e instanceof \GuzzleHttp\Exception\RequestException
) {
return (int) $e->getCode() === 429;
}
return false;
}
private function parseRetryAfter(Throwable $e): int
{
if (method_exists($e, 'getResponseHeaders')) {
$headers = $e->getResponseHeaders() ?: [];
$value = $headers['Retry-After'] ?? $headers['retry-after'] ?? null;
if (is_array($value)) {
$value = $value[0] ?? null;
}
if (is_numeric($value)) {
return (int) $value;
}
}
$message = strtolower($e->getMessage());
if (str_contains($message, 'daily')) {
return 600;
}
if (str_contains($message, 'ten secondly')) {
return 10;
}
if (str_contains($message, 'secondly')) {
return 1;
}
$this->log->warning('[Hubspot] No retry-after header or known message, using default', [
'exception_class' => get_class($e),
'message' => $message,
]);
return 10;
}
public function getMinimumApiVersion(): string
{
return self::MIN_API_VERSION;
}
public function getInstance(): Factory
{
return new Factory([
'key' => $this->accessToken,
'oauth2' => true,
'base_url' => $this->baseUrl,
]);
}
public function getNewInstance(): Discovery
{
return \HubSpot\Factory::createWithAccessToken($this->accessToken);
}
/**
* Secondly and daily limits for Hubspot API
*
* Product Tier: Free & Starter | Professional & Enterprise | API add-on (any tier)
* Burst: 100/10 seconds | 150/10 seconds | 200/10 seconds
* Daily: 250,000 | 500,000 | 1,000,000
*
* Official documentation states: The search endpoints are rate limited to five requests per second.
* Since with 5 RPS were still hitting secondly rate limits we lowered it to 4
*/
public function getPaginatedData(array $payload, string $type, int $offset = 0): array
{
$total = 0;
$lastId = null;
$rows = [];
foreach ($this->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastId) as $row) {
$rows[] = $row;
}
return ['results' => $rows, 'total' => $total, 'last_record' => $lastId];
}
/**
* @throws HubspotException
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
*/
public function getPaginatedDataGenerator(
array $payload,
string $type,
int $offset = 0,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
return $this->paginationService->getPaginatedDataGenerator(
$this,
$payload,
$type,
$offset,
$total,
$lastRecordId
);
}
/**
* Execute a search request against HubSpot CRM objects with rate limiting.
*
* @param string $objectType The object type ('deals', 'companies', 'contacts', 'calls')
* @param array<string, mixed> $payload The search payload with filters, sorts, properties, etc.
*
* @throws RateLimitException When rate limit is hit
* @throws HubspotException On API errors
*
* @return array The search response with 'results', 'total', 'paging' keys
*/
public function search(string $objectType, array $payload): array
{
$endpoint = self::BASE_URL . "/crm/v3/objects/{$objectType}/search";
return $this->executeRequest(function () use ($endpoint, $payload) {
$response = $this->getInstance()->getClient()->request('POST', $endpoint, ['json' => $payload]);
return $response->toArray();
});
}
/**
* @throws DealApiException
* @throws CrmException
*/
public function getOpportunityById(string $crmId, array $fields): array
{
try {
$deal = $this->getNewInstance()->crm()->deals()->basicApi()->getById(
$crmId,
implode(',', $fields),
'companies,contacts'
);
} catch (DealApiException $e) {
$this->log->info('[Hubspot] Failed to fetch opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $deal instanceof DealWithAssociations) {
throw new CrmException('Deal not found');
}
return [
'id' => $deal->getId(),
'properties' => $deal->getProperties(),
'associations' => $deal->getAssociations(),
];
}
/**
* Generic batch read method for HubSpot objects
*
* @param string $objectType The object type ('deals', 'companies', 'contacts')
* @param array<string> $crmIds Array of HubSpot object IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with object data
*/
private function batchReadObjects(string $objectType, array $crmIds, array $fields): array
{
if (empty($crmIds)) {
return [];
}
$this->validateBatchSize($objectType, $crmIds);
$this->ensureValidToken();
try {
$batchConfig = $this->createBatchConfiguration($objectType);
$batchReadRequest = $this->prepareBatchRequest($batchConfig, $crmIds, $fields);
$response = $this->executeRequest(fn () => $batchConfig['api']->read($batchReadRequest));
$this->validateApiResponse($response, $objectType);
$results = $this->processApiResults($response);
$this->logBatchResults($objectType, $crmIds, $results);
return $results;
} catch (RateLimitException $e) {
throw $e;
} catch (\Throwable $e) {
$this->handleBatchError($e, $objectType, $crmIds);
}
}
private function validateBatchSize(string $objectType, array $crmIds): void
{
if (count($crmIds) > 100) {
throw new \InvalidArgumentException("Batch size cannot exceed 100 {$objectType}");
}
}
private function createBatchConfiguration(string $objectType): array
{
$configurations = [
'deals' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Deals\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Deals\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->deals()->batchApi(),
],
'companies' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Companies\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Companies\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->companies()->batchApi(),
],
'contacts' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Contacts\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->contacts()->batchApi(),
],
];
if (! isset($configurations[$objectType])) {
throw new \InvalidArgumentException("Unsupported object type: {$objectType}");
}
return $configurations[$objectType];
}
private function prepareBatchRequest(array $batchConfig, array $crmIds, array $fields): object
{
$batchReadRequest = $batchConfig['batchReadRequest'];
$inputClass = $batchConfig['inputClass'];
$inputs = array_map(function ($crmId) use ($inputClass) {
$input = new $inputClass();
$input->setId($crmId);
return $input;
}, $crmIds);
$batchReadRequest->setInputs($inputs);
$batchReadRequest->setProperties($fields);
return $batchReadRequest;
}
private function validateApiResponse($response, string $objectType): void
{
if (! $response) {
throw new CrmException("HubSpot API returned null response for {$objectType} batch read");
}
}
private function processApiResults($response): array
{
$results = [];
$responseResults = $response->getResults();
if ($responseResults) {
foreach ($responseResults as $object) {
if ($object && $object->getId()) {
$results[$object->getId()] = [
'id' => $object->getId(),
'properties' => $object->getProperties() ?: [],
];
}
}
}
return $results;
}
private function logBatchResults(string $objectType, array $crmIds, array $results): void
{
$this->log->info("[HubSpot] Batch fetched {$objectType}", [
'requested_count' => count($crmIds),
'returned_count' => count($results),
'crm_ids' => $crmIds,
]);
}
private function handleBatchError(\Throwable $e, string $objectType, array $crmIds): void
{
$errorMessage = $e->getMessage() ?: 'Unknown error';
$errorTrace = $e->getTraceAsString() ?: 'No trace available';
$this->log->error("[HubSpot] Failed to batch fetch {$objectType}", [
'crm_ids' => $crmIds,
'error' => $errorMessage,
'trace' => $errorTrace,
]);
throw new CrmException("Failed to batch fetch {$objectType}: " . $errorMessage);
}
/**
* Batch read multiple opportunities by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot deal IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with opportunity data
*/
public function getOpportunitiesByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('deals', $crmIds, $fields);
}
/**
* Batch read multiple companies by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot company IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with company data
*/
public function getCompaniesByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('companies', $crmIds, $fields);
}
/**
* Batch read multiple contacts by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot contact IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with contact data
*/
public function getContactsByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('contacts', $crmIds, $fields);
}
/**
* @throws CompanyApiException
* @throws CrmException
*/
public function getAccountById(string $crmId, array $fields): array
{
try {
$company = $this->getNewInstance()->crm()->companies()->basicApi()->getById(
$crmId,
implode(',', $fields),
);
} catch (CompanyApiException $e) {
$this->log->info('[Hubspot] Failed to fetch account', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $company instanceof CompaniesWithAssociations) {
throw new CrmException('Account not found');
}
return [
'id' => $company->getId(),
'properties' => $company->getProperties(),
];
}
/**
* @throws ContactApiException
* @throws CrmException
*/
public function getContactById(string $crmId, array $fields): array
{
try {
$contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(
$crmId,
implode(',', $fields)
);
} catch (ContactApiException $e) {
$this->log->info('[Hubspot] Failed to fetch contact', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $contact instanceof ContactsWithAssociations) {
throw new CrmException('Contact not found');
}
return [
'id' => $contact->getId(),
'properties' => $contact->getProperties(),
];
}
/**
* This is email search request that Hubspot offers as GET (more generous quota)
*/
public function getContactByEmail(string $email, array $fields = []): array
{
try {
$contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(
$email,
implode(',', $fields),
null,
false,
'email'
);
return [
'id' => $contact->getId(),
'properties' => $contact->getProperties(),
];
} catch (ContactApiException $e) {
$this->log->info('[Hubspot] Failed to fetch contact', [
'email' => $email,
'reason' => $e->getMessage(),
]);
return [];
}
}
/**
* @throws CrmException
*/
public function fetchProperty(string $objectType, string $propertyId): Property
{
$result = $this->getNewInstance()->crm()->properties()->coreApi()->getByName($objectType, $propertyId);
if (! $result instanceof Property) {
$this->log->error('[Hubspot] Failed to fetch property', [
'object_type' => $objectType,
'property_id' => $propertyId,
'reason' => $result->getMessage(),
]);
throw new CrmException('Failed to fetch property');
}
return $result;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchPropertyOptions(string $objectType, string $propertyId): array
{
/** @var array<CrmFieldOption> */
return $this->fetchProperty($objectType, $propertyId)->getOptions();
}
/**
* @return array<array{id:string, label:string, deleted:bool}>
*/
public function fetchCallDispositions(): array
{
/** @var Response $response */
$response = $this->getInstance()->engagements()->getCallDispositions();
/**
* @var array<array{
* id:string,
* label:string,
* deleted: bool
* }>
*/
return $response->toArray();
}
/**
* @return array<CrmFieldOption>
*/
public function fetchOpportunityPipelineStages(): array
{
$stages = [];
$apiResponse = $this->getNewInstance()->crm()->pipelines()->pipelinesApi()->getAll('deals');
if ($apiResponse instanceof Error) {
$this->log->error('[Hubspot] Failed to fetch opportunity pipelines', [
'reason' => $apiResponse->getMessage(),
]);
return [];
}
foreach ($apiResponse->getResults() as $pipeline) {
$pipelineStages = array_map(
static function (PipelineStage $stage) {
return [
'id' => $stage->getId(),
'label' => $stage->getLabel(),
];
},
$pipeline->getStages()
);
$stages = array_merge($stages, $pipelineStages);
}
return $stages;
}
public function fetchOpportunityPipelines(): array
{
$pipelines = [];
try {
$apiResponse = $this->makeRequest('/crm/v3/pipelines/deals');
} catch (\Exception $e) {
$this->log->info('[Hubspot] Failed to fetch opportunity pipelines', [
'reason' => $e->getMessage(),
]);
return [];
}
$response = $apiResponse->toArray();
foreach ($response['results'] as $pipeline) {
$pipelines[] = [
'id' => $pipeline['id'],
'label' => $pipeline['label'],
];
}
return $pipelines;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchMeetingOutcomeFieldOptions(Field $field): array
{
return $field->getCrmProviderId() === 'meetingOutcome'
? $this->fetchMeetingOutcomeTypes()
: $this->fetchCallActivityTypes();
}
public function fetchMeetingOutcomeTypes(): array
{
return $this->extractMeetingTypeOptions(
'[URL_WITH_CREDENTIALS] Response $response */
$response = $this->getInstance()
->getClient()
->request('GET', $endpoint);
/**
* @var array<array{
* value: string,
* label: string,
* displayOrder: int
* }> $optionData
*/
$optionData = $response->toArray()['options'] ?? [];
$options = [];
foreach ($optionData as $item) {
$options[] = [
'id' => $item['value'],
'value' => $item['value'],
'label' => $item['label'],
'display_order' => $item['displayOrder'],
];
}
return $options;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchDispositionFieldOptions(): array
{
$options = [];
$dispositions = $this->fetchCallDispositions();
foreach ($dispositions as $disposition) {
if ($disposition['deleted'] !== false) {
continue;
}
$option['value'] = $disposition['id'];
$option['id'] = $disposition['id'];
$option['label'] = $disposition['label'];
$options[] = $option;
}
return $options;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchOpportunityFieldOptions(Field $field): array
{
if ($field->isStageField()) {
return $this->fetchOpportunityPipelineStages();
}
if ($field->isPipelineField()) {
return $this->fetchOpportunityPipelines();
}
return $this->fetchPropertyOptions('deals', $field->getCrmProviderId());
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function makeRequest(string $endpoint, $method = 'GET', $payload = [], ?string $queryString = null)
{
$endpoint = self::BASE_URL . $endpoint;
if ($method === 'GET') {
return $this->getInstance()->getClient()?->request(
method: $method,
endpoint: $endpoint,
query_string: $queryString
);
} else {
return $this->getInstance()->getClient()->request($method, $endpoint, [
'json' => ($payload),
]);
}
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function createMeeting(array $payload): Response
{
$endpoint = '/crm/v3/objects/meetings';
return $this->makeRequest($endpoint, 'POST', $payload);
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function updateMeeting(string $meetingId, array $payload): Response
{
$endpoint = '/crm/v3/objects/meetings/' . $meetingId;
return $this->makeRequest($endpoint, 'PATCH', $payload);
}
/**
* @throws \Exception
*/
public function createNote(
string $body,
string $ownerId,
int $timestamp,
string $objectId,
NoteObject $noteObject
): ?string {
try {
$noteInput = new SimplePublicObjectInput([
'properties' => [
'hs_note_body' => $body,
'hubspot_owner_id' => $ownerId,
'hs_timestamp' => $timestamp,
],
]);
// Create note
$note = $this->getNewInstance()->crm()->objects()->basicApi()->create('note', $noteInput);
$this->getNewInstance()->crm()->objects()->associationsApi()->create(
'note',
$note->getId(),
$this->getNoteObject($noteObject),
$objectId,
$this->getNoteAssociationType($noteObject),
);
return $note->getId();
} catch (\Exception $e) {
$this->log->error('[Hubspot] Failed to create note', [
'objectId' => $objectId,
'noteObject' => $noteObject->getObjectType(),
'reason' => $e->getMessage(),
]);
\Sentry::captureException($e);
}
return null;
}
public function updateEngagement(string $objectId, array $engagement, array $metadata): void
{
$this->getInstance()->engagements()->update($objectId, $engagement, $metadata);
}
public function getEngagementData(string $engagementId): array
{
$engagement = $this->getInstance()->engagements()->get($engagementId);
return $engagement->toArray();
}
public function createEngagement(array $engagement, array $associations, array $metadata): Response
{
return $this->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
}
public function isUnauthorizedException(\Exception $e): bool
{
// Check for specific HubSpot API exception types first
if ($e instanceof BadRequest) {
// BadRequest can contain 401 status codes
return $e->getCode() === 401;
}
// Check for HTTP client exceptions with status codes
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
$response = $e->getResponse();
if ($response !== null) {
return $response->getStatusCode() === 401;
}
}
// Check for Guzzle HTTP exceptions
if ($e instanceof \GuzzleHttp\Exception\ClientException) {
return $e->getCode() === 401;
}
// Fallback to string matching as last resort, but be more specific
$message = strtolower($e->getMessage());
return str_contains($message, '401 unauthorized') ||
str_contains($message, 'http 401') ||
str_contains($message, 'status code 401') ||
(preg_match('/\b401\b/', $message) && str_contains($message, 'unauthorized'));
}
/**
* Validates and refreshes the access token if needed before API requests.
* This ensures long-running processes don't fail due to token expiration.
*
* @throws SocialAccountTokenInvalidException
*/
public function ensureValidToken(): void
{
if ($this->oauthAccount === null) {
return;
}
$newToken = $this->tokenManager->ensureValidToken($this->oauthAccount);
if ($newToken !== null) {
$this->accessToken = $newToken;
}
}
public function getConfig()
{
return $this->config;
}
// returns only active (archived=false)
public function getOwners(): array
{
return $this->getNewInstance()->crm()->owners()->getAll();
}
/**
* @param bool $archived
*
* @return array<Owner>|[]
*/
public function getOwnersArchived(bool $archived = true): array
{
$endpoint = '/crm/v3/owners';
$queryParams = [
'archived' => $archived ? 'true' : 'false',
];
$queryString = http_build_query($queryParams);
$owners = [];
try {
$response = $this->makeRequest(endpoint: $endpoint, queryString: $queryString);
$responseData = $response?->toArray();
foreach ($responseData['results'] as $result) {
try {
$owners[] = Owner::create($result);
} catch (Throwable $e) {
$this->log->error('[HubSpot] Failed to process owner data', [
'result' => $result,
'error' => $e->getMessage(),
]);
continue;
}
}
} catch (Throwable $e) {
$this->log->error('HubSpot] Failed to fetch owners', [
'archived' => $archived,
'error' => $e->getMessage(),
]);
return [];
}
return $owners;
}
public function getMeeting(string $engagementId): ObjectWithAssociations
{
return $this->getNewInstance()->crm()->objects()->basicApi()
->getById('meeting', $engagementId, null, 'contact,company,deal');
}
public function deleteEngagement(string $engagementId): void
{
$this->getInstance()->engagements()->delete((int) $engagementId);
}
public function getAssociationsData(array $ids, string $fromObject, string $toObject): array
{
$associationData = [];
$idChunks = array_chunk($ids, self::ASSOCIATIONS_BATCH_SIZE_LIMIT);
foreach ($idChunks as $idChunk) {
try {
$batchInput = new \HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId();
$batchInput->setInputs(array_map(function ($id) {
$publicObjectId = new \HubSpot\Client\Crm\Associations\Model\PublicObjectId();
$publicObjectId->setId($id);
return $publicObjectId;
}, $idChunk));
$associatedObjectsData = $this
->getNewInstance()
->crm()
->associations()
->batchApi()
->read($fromObject, $toObject, $batchInput);
if ($associatedObjectsData instanceof \HubSpot\Client\Crm\Associations\Model\BatchResponsePublicAssociationMulti) {
foreach ($associatedObjectsData->getResults() as $association) {
$from = $association->getFrom()->getId();
$toAssociations = $association->getTo();
if (! empty($toAssociations)) {
$associationData[$from] = array_map(function ($item) {
return $item->getId();
}, $toAssociations);
}
}
}
} catch (\Exception $e) {
$this->log->error('[Hubspot] Failed to fetch associations', [
'from_object' => $fromObject,
'to_object' => $toObject,
'reason' => $e->getMessage(),
]);
}
}
return $associationData;
}
/**
* @throws \Exception
*/
private function getNoteAssociationType(NoteObject $noteObject): string
{
return match($noteObject) {
NoteObject::Opportunity => 'note_to_deal',
NoteObject::Lead, NoteObject::Contact => 'note_to_contact', // or 'note_to_lead' if your portal supports it
NoteObject::Account => 'note_to_company',
NoteObject::Call, NoteObject::Event => throw new \Exception('Not supported'),
};
}
/**
* @throws \Exception
*/
private function getNoteObject(NoteObject $noteObject): string
{
return match($noteObject) {
NoteObject::Opportunity => 'deal',
NoteObject::Lead, NoteObject::Contact => 'contact',
NoteObject::Account => 'company',
NoteObject::Call, NoteObject::Event => throw new \Exception('Not supported'),
};
}
public function addAssociations(string $objectType, string $associationType, array $payload): Response
{
$endpoint = "/crm/v4/associations/$objectType/$associationType/batch/create";
return $this->makeRequest($endpoint, 'POST', $payload);
}
public function removeAssociations(string $objectType, string $associationType, array $payload): Response
{
$endpoint = "/crm/v4/associations/$objectType/$associationType/batch/archive";
return $this->makeRequest($endpoint, 'POST', $payload);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8597075,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ClientTest","depth":6,"bounds":{"left":0.875,"top":0.019952115,"width":0.04055851,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ClientTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ClientTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"2","depth":4,"bounds":{"left":0.5069814,"top":0.17478053,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"66","depth":4,"bounds":{"left":0.5169548,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"2","depth":4,"bounds":{"left":0.52925533,"top":0.17478053,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.53889626,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.5462101,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse HubSpot\\Client\\Crm\\Deals\\ApiException as DealApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\ApiException as ContactApiException;\nuse HubSpot\\Client\\Crm\\Companies\\ApiException as CompanyApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations as ContactsWithAssociations;\nuse HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectWithAssociations as CompaniesWithAssociations;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectWithAssociations as DealWithAssociations;\nuse HubSpot\\Client\\Crm\\Objects\\Model\\SimplePublicObjectInput;\nuse HubSpot\\Client\\Crm\\Objects\\Model\\SimplePublicObjectWithAssociations as ObjectWithAssociations;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Error;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\PipelineStage;\nuse HubSpot\\Client\\Crm\\Properties\\Model\\Property;\nuse HubSpot\\Discovery\\Discovery;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Services\\Crm\\BaseClient;\nuse Jiminny\\Services\\Crm\\Hubspot\\DTO\\Response\\Owner;\nuse Jiminny\\Services\\SocialAccountService;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\nuse SevenShores\\Hubspot\\Factory;\nuse SevenShores\\Hubspot\\Http\\Response;\nuse Jiminny\\Services\\Crm\\Hubspot\\Pagination\\HubspotPaginationService;\nuse Illuminate\\Support\\Facades\\Redis;\nuse Throwable;\n\n/**\n * @phpstan-type CrmFieldOption array{id:string, label:string, value?:string}\n */\nclass Client extends BaseClient implements HubspotClientInterface\n{\n public const string MIN_API_VERSION = '2';\n\n public const string BASE_URL = 'https://api.hubapi.com';\n\n public const int ASSOCIATIONS_BATCH_SIZE_LIMIT = 1000;\n\n private HubspotPaginationService $paginationService;\n private HubspotTokenManager $tokenManager;\n\n public function __construct(\n SocialAccountService $socialAccountService,\n HubspotPaginationService $paginationService,\n HubspotTokenManager $tokenManager\n ) {\n parent::__construct($socialAccountService);\n $this->paginationService = $paginationService;\n $this->tokenManager = $tokenManager;\n\n $this->setBaseUrl(self::BASE_URL);\n $this->setVersion(self::MIN_API_VERSION);\n }\n\n /**\n * Execute a HubSpot API call with rate limit handling.\n *\n * On a 429, stores the absolute expiry timestamp with SET NX (first writer wins).\n * This means all subsequent jobs that also receive 429 in the same burst do not\n * reset the TTL — the window is anchored to the first 429, not the last.\n * Readers compute the remaining wait from the stored timestamp, so jobs that check\n * the cache near expiry are not delayed longer than necessary.\n *\n * @template T\n *\n * @param callable(): T $apiCall The API call to execute\n *\n * @throws RateLimitException When rate limit is hit or cached rate limit is active\n *\n * @return T The result of the API call\n */\n private function executeRequest(callable $apiCall)\n {\n $cacheKey = $this->getRateLimitCacheKey();\n\n $cachedExpiresAt = Redis::get($cacheKey);\n if (is_string($cachedExpiresAt) && is_numeric($cachedExpiresAt)) {\n $remaining = max(1, (int) $cachedExpiresAt - time());\n\n throw new RateLimitException(\n 'Hubspot rate limit (cached circuit-breaker)',\n $remaining,\n );\n }\n\n try {\n return $apiCall();\n } catch (Throwable $e) {\n if ($this->isHubspotRateLimit($e)) {\n $retryAfter = $this->parseRetryAfter($e);\n\n // NX: only the first job to receive a 429 in this burst sets the key.\n // Subsequent 429s in the same burst leave the TTL untouched so the\n // window is not reset by every concurrent job hitting the limit.\n Redis::set($cacheKey, (string) (time() + $retryAfter), ['nx', 'ex' => $retryAfter]);\n\n $this->log->warning('[Hubspot] Received 429 from API', [\n 'team_id' => $this->config->team_id,\n 'config_id' => $this->config->getId(),\n 'retry_after' => $retryAfter,\n 'reason' => $e->getMessage(),\n ]);\n\n throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);\n }\n\n throw $e;\n }\n }\n\n private function getRateLimitCacheKey(): string\n {\n return sprintf('hubspot:ratelimit:config:%d', $this->config->getId());\n }\n\n private function isHubspotRateLimit(Throwable $e): bool\n {\n if ($e instanceof BadRequest\n || $e instanceof DealApiException\n || $e instanceof ContactApiException\n || $e instanceof CompanyApiException\n || $e instanceof \\GuzzleHttp\\Exception\\RequestException\n ) {\n return (int) $e->getCode() === 429;\n }\n\n return false;\n }\n\n private function parseRetryAfter(Throwable $e): int\n {\n if (method_exists($e, 'getResponseHeaders')) {\n $headers = $e->getResponseHeaders() ?: [];\n $value = $headers['Retry-After'] ?? $headers['retry-after'] ?? null;\n if (is_array($value)) {\n $value = $value[0] ?? null;\n }\n if (is_numeric($value)) {\n return (int) $value;\n }\n }\n\n $message = strtolower($e->getMessage());\n\n if (str_contains($message, 'daily')) {\n return 600;\n }\n if (str_contains($message, 'ten secondly')) {\n return 10;\n }\n if (str_contains($message, 'secondly')) {\n return 1;\n }\n\n $this->log->warning('[Hubspot] No retry-after header or known message, using default', [\n 'exception_class' => get_class($e),\n 'message' => $message,\n ]);\n\n return 10;\n }\n\n public function getMinimumApiVersion(): string\n {\n return self::MIN_API_VERSION;\n }\n\n public function getInstance(): Factory\n {\n return new Factory([\n 'key' => $this->accessToken,\n 'oauth2' => true,\n 'base_url' => $this->baseUrl,\n ]);\n }\n\n public function getNewInstance(): Discovery\n {\n return \\HubSpot\\Factory::createWithAccessToken($this->accessToken);\n }\n\n /**\n * Secondly and daily limits for Hubspot API\n *\n * Product Tier: Free & Starter | Professional & Enterprise | API add-on (any tier)\n * Burst: 100/10 seconds | 150/10 seconds | 200/10 seconds\n * Daily: 250,000 | 500,000 | 1,000,000\n *\n * Official documentation states: The search endpoints are rate limited to five requests per second.\n * Since with 5 RPS were still hitting secondly rate limits we lowered it to 4\n */\n public function getPaginatedData(array $payload, string $type, int $offset = 0): array\n {\n $total = 0;\n $lastId = null;\n $rows = [];\n foreach ($this->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastId) as $row) {\n $rows[] = $row;\n }\n\n return ['results' => $rows, 'total' => $total, 'last_record' => $lastId];\n }\n\n /**\n * @throws HubspotException\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n */\n public function getPaginatedDataGenerator(\n array $payload,\n string $type,\n int $offset = 0,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n return $this->paginationService->getPaginatedDataGenerator(\n $this,\n $payload,\n $type,\n $offset,\n $total,\n $lastRecordId\n );\n }\n\n /**\n * Execute a search request against HubSpot CRM objects with rate limiting.\n *\n * @param string $objectType The object type ('deals', 'companies', 'contacts', 'calls')\n * @param array<string, mixed> $payload The search payload with filters, sorts, properties, etc.\n *\n * @throws RateLimitException When rate limit is hit\n * @throws HubspotException On API errors\n *\n * @return array The search response with 'results', 'total', 'paging' keys\n */\n public function search(string $objectType, array $payload): array\n {\n $endpoint = self::BASE_URL . \"/crm/v3/objects/{$objectType}/search\";\n\n return $this->executeRequest(function () use ($endpoint, $payload) {\n $response = $this->getInstance()->getClient()->request('POST', $endpoint, ['json' => $payload]);\n\n return $response->toArray();\n });\n }\n\n /**\n * @throws DealApiException\n * @throws CrmException\n */\n public function getOpportunityById(string $crmId, array $fields): array\n {\n try {\n $deal = $this->getNewInstance()->crm()->deals()->basicApi()->getById(\n $crmId,\n implode(',', $fields),\n 'companies,contacts'\n );\n } catch (DealApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $deal instanceof DealWithAssociations) {\n throw new CrmException('Deal not found');\n }\n\n return [\n 'id' => $deal->getId(),\n 'properties' => $deal->getProperties(),\n 'associations' => $deal->getAssociations(),\n ];\n }\n\n /**\n * Generic batch read method for HubSpot objects\n *\n * @param string $objectType The object type ('deals', 'companies', 'contacts')\n * @param array<string> $crmIds Array of HubSpot object IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with object data\n */\n private function batchReadObjects(string $objectType, array $crmIds, array $fields): array\n {\n if (empty($crmIds)) {\n return [];\n }\n\n $this->validateBatchSize($objectType, $crmIds);\n $this->ensureValidToken();\n\n try {\n $batchConfig = $this->createBatchConfiguration($objectType);\n $batchReadRequest = $this->prepareBatchRequest($batchConfig, $crmIds, $fields);\n\n $response = $this->executeRequest(fn () => $batchConfig['api']->read($batchReadRequest));\n\n $this->validateApiResponse($response, $objectType);\n\n $results = $this->processApiResults($response);\n $this->logBatchResults($objectType, $crmIds, $results);\n\n return $results;\n } catch (RateLimitException $e) {\n throw $e;\n } catch (\\Throwable $e) {\n $this->handleBatchError($e, $objectType, $crmIds);\n }\n }\n\n private function validateBatchSize(string $objectType, array $crmIds): void\n {\n if (count($crmIds) > 100) {\n throw new \\InvalidArgumentException(\"Batch size cannot exceed 100 {$objectType}\");\n }\n }\n\n private function createBatchConfiguration(string $objectType): array\n {\n $configurations = [\n 'deals' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Deals\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->deals()->batchApi(),\n ],\n 'companies' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Companies\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->companies()->batchApi(),\n ],\n 'contacts' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Contacts\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->contacts()->batchApi(),\n ],\n ];\n\n if (! isset($configurations[$objectType])) {\n throw new \\InvalidArgumentException(\"Unsupported object type: {$objectType}\");\n }\n\n return $configurations[$objectType];\n }\n\n private function prepareBatchRequest(array $batchConfig, array $crmIds, array $fields): object\n {\n $batchReadRequest = $batchConfig['batchReadRequest'];\n $inputClass = $batchConfig['inputClass'];\n\n $inputs = array_map(function ($crmId) use ($inputClass) {\n $input = new $inputClass();\n $input->setId($crmId);\n\n return $input;\n }, $crmIds);\n\n $batchReadRequest->setInputs($inputs);\n $batchReadRequest->setProperties($fields);\n\n return $batchReadRequest;\n }\n\n private function validateApiResponse($response, string $objectType): void\n {\n if (! $response) {\n throw new CrmException(\"HubSpot API returned null response for {$objectType} batch read\");\n }\n }\n\n private function processApiResults($response): array\n {\n $results = [];\n $responseResults = $response->getResults();\n\n if ($responseResults) {\n foreach ($responseResults as $object) {\n if ($object && $object->getId()) {\n $results[$object->getId()] = [\n 'id' => $object->getId(),\n 'properties' => $object->getProperties() ?: [],\n ];\n }\n }\n }\n\n return $results;\n }\n\n private function logBatchResults(string $objectType, array $crmIds, array $results): void\n {\n $this->log->info(\"[HubSpot] Batch fetched {$objectType}\", [\n 'requested_count' => count($crmIds),\n 'returned_count' => count($results),\n 'crm_ids' => $crmIds,\n ]);\n }\n\n private function handleBatchError(\\Throwable $e, string $objectType, array $crmIds): void\n {\n $errorMessage = $e->getMessage() ?: 'Unknown error';\n $errorTrace = $e->getTraceAsString() ?: 'No trace available';\n\n $this->log->error(\"[HubSpot] Failed to batch fetch {$objectType}\", [\n 'crm_ids' => $crmIds,\n 'error' => $errorMessage,\n 'trace' => $errorTrace,\n ]);\n\n throw new CrmException(\"Failed to batch fetch {$objectType}: \" . $errorMessage);\n }\n\n /**\n * Batch read multiple opportunities by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot deal IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with opportunity data\n */\n public function getOpportunitiesByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('deals', $crmIds, $fields);\n }\n\n /**\n * Batch read multiple companies by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot company IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with company data\n */\n public function getCompaniesByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('companies', $crmIds, $fields);\n }\n\n /**\n * Batch read multiple contacts by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot contact IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with contact data\n */\n public function getContactsByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('contacts', $crmIds, $fields);\n }\n\n /**\n * @throws CompanyApiException\n * @throws CrmException\n */\n public function getAccountById(string $crmId, array $fields): array\n {\n try {\n $company = $this->getNewInstance()->crm()->companies()->basicApi()->getById(\n $crmId,\n implode(',', $fields),\n );\n } catch (CompanyApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch account', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $company instanceof CompaniesWithAssociations) {\n throw new CrmException('Account not found');\n }\n\n return [\n 'id' => $company->getId(),\n 'properties' => $company->getProperties(),\n ];\n }\n\n /**\n * @throws ContactApiException\n * @throws CrmException\n */\n public function getContactById(string $crmId, array $fields): array\n {\n try {\n $contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(\n $crmId,\n implode(',', $fields)\n );\n } catch (ContactApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch contact', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $contact instanceof ContactsWithAssociations) {\n throw new CrmException('Contact not found');\n }\n\n return [\n 'id' => $contact->getId(),\n 'properties' => $contact->getProperties(),\n ];\n }\n\n /**\n * This is email search request that Hubspot offers as GET (more generous quota)\n */\n public function getContactByEmail(string $email, array $fields = []): array\n {\n try {\n $contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(\n $email,\n implode(',', $fields),\n null,\n false,\n 'email'\n );\n\n return [\n 'id' => $contact->getId(),\n 'properties' => $contact->getProperties(),\n ];\n } catch (ContactApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch contact', [\n 'email' => $email,\n 'reason' => $e->getMessage(),\n ]);\n\n return [];\n }\n }\n\n /**\n * @throws CrmException\n */\n public function fetchProperty(string $objectType, string $propertyId): Property\n {\n $result = $this->getNewInstance()->crm()->properties()->coreApi()->getByName($objectType, $propertyId);\n\n if (! $result instanceof Property) {\n $this->log->error('[Hubspot] Failed to fetch property', [\n 'object_type' => $objectType,\n 'property_id' => $propertyId,\n 'reason' => $result->getMessage(),\n ]);\n\n throw new CrmException('Failed to fetch property');\n }\n\n return $result;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchPropertyOptions(string $objectType, string $propertyId): array\n {\n /** @var array<CrmFieldOption> */\n return $this->fetchProperty($objectType, $propertyId)->getOptions();\n }\n\n /**\n * @return array<array{id:string, label:string, deleted:bool}>\n */\n public function fetchCallDispositions(): array\n {\n /** @var Response $response */\n $response = $this->getInstance()->engagements()->getCallDispositions();\n\n /**\n * @var array<array{\n * id:string,\n * label:string,\n * deleted: bool\n * }>\n */\n return $response->toArray();\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchOpportunityPipelineStages(): array\n {\n $stages = [];\n $apiResponse = $this->getNewInstance()->crm()->pipelines()->pipelinesApi()->getAll('deals');\n\n if ($apiResponse instanceof Error) {\n $this->log->error('[Hubspot] Failed to fetch opportunity pipelines', [\n 'reason' => $apiResponse->getMessage(),\n ]);\n\n return [];\n }\n\n foreach ($apiResponse->getResults() as $pipeline) {\n $pipelineStages = array_map(\n static function (PipelineStage $stage) {\n return [\n 'id' => $stage->getId(),\n 'label' => $stage->getLabel(),\n ];\n },\n $pipeline->getStages()\n );\n\n $stages = array_merge($stages, $pipelineStages);\n }\n\n return $stages;\n }\n\n public function fetchOpportunityPipelines(): array\n {\n $pipelines = [];\n\n try {\n $apiResponse = $this->makeRequest('/crm/v3/pipelines/deals');\n } catch (\\Exception $e) {\n $this->log->info('[Hubspot] Failed to fetch opportunity pipelines', [\n 'reason' => $e->getMessage(),\n ]);\n\n return [];\n }\n\n $response = $apiResponse->toArray();\n\n foreach ($response['results'] as $pipeline) {\n $pipelines[] = [\n 'id' => $pipeline['id'],\n 'label' => $pipeline['label'],\n ];\n }\n\n return $pipelines;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchMeetingOutcomeFieldOptions(Field $field): array\n {\n return $field->getCrmProviderId() === 'meetingOutcome'\n ? $this->fetchMeetingOutcomeTypes()\n : $this->fetchCallActivityTypes();\n }\n\n public function fetchMeetingOutcomeTypes(): array\n {\n return $this->extractMeetingTypeOptions(\n 'https://api.hubapi.com/crm/v3/properties/meeting/hs_meeting_outcome'\n );\n }\n\n public function fetchCallActivityTypes(): array\n {\n return $this->extractMeetingTypeOptions(\n 'https://api.hubapi.com/crm/v3/properties/call/hs_activity_type'\n );\n }\n\n private function extractMeetingTypeOptions(string $endpoint): array\n {\n /** @var Response $response */\n $response = $this->getInstance()\n ->getClient()\n ->request('GET', $endpoint);\n\n /**\n * @var array<array{\n * value: string,\n * label: string,\n * displayOrder: int\n * }> $optionData\n */\n $optionData = $response->toArray()['options'] ?? [];\n\n $options = [];\n foreach ($optionData as $item) {\n $options[] = [\n 'id' => $item['value'],\n 'value' => $item['value'],\n 'label' => $item['label'],\n 'display_order' => $item['displayOrder'],\n ];\n }\n\n return $options;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchDispositionFieldOptions(): array\n {\n $options = [];\n\n $dispositions = $this->fetchCallDispositions();\n\n foreach ($dispositions as $disposition) {\n if ($disposition['deleted'] !== false) {\n continue;\n }\n\n $option['value'] = $disposition['id'];\n $option['id'] = $disposition['id'];\n $option['label'] = $disposition['label'];\n\n $options[] = $option;\n }\n\n return $options;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchOpportunityFieldOptions(Field $field): array\n {\n if ($field->isStageField()) {\n return $this->fetchOpportunityPipelineStages();\n }\n\n if ($field->isPipelineField()) {\n return $this->fetchOpportunityPipelines();\n }\n\n return $this->fetchPropertyOptions('deals', $field->getCrmProviderId());\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function makeRequest(string $endpoint, $method = 'GET', $payload = [], ?string $queryString = null)\n {\n $endpoint = self::BASE_URL . $endpoint;\n\n if ($method === 'GET') {\n return $this->getInstance()->getClient()?->request(\n method: $method,\n endpoint: $endpoint,\n query_string: $queryString\n );\n } else {\n return $this->getInstance()->getClient()->request($method, $endpoint, [\n 'json' => ($payload),\n ]);\n }\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function createMeeting(array $payload): Response\n {\n $endpoint = '/crm/v3/objects/meetings';\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function updateMeeting(string $meetingId, array $payload): Response\n {\n $endpoint = '/crm/v3/objects/meetings/' . $meetingId;\n\n return $this->makeRequest($endpoint, 'PATCH', $payload);\n }\n\n /**\n * @throws \\Exception\n */\n public function createNote(\n string $body,\n string $ownerId,\n int $timestamp,\n string $objectId,\n NoteObject $noteObject\n ): ?string {\n try {\n $noteInput = new SimplePublicObjectInput([\n 'properties' => [\n 'hs_note_body' => $body,\n 'hubspot_owner_id' => $ownerId,\n 'hs_timestamp' => $timestamp,\n ],\n ]);\n\n // Create note\n $note = $this->getNewInstance()->crm()->objects()->basicApi()->create('note', $noteInput);\n\n $this->getNewInstance()->crm()->objects()->associationsApi()->create(\n 'note',\n $note->getId(),\n $this->getNoteObject($noteObject),\n $objectId,\n $this->getNoteAssociationType($noteObject),\n );\n\n return $note->getId();\n } catch (\\Exception $e) {\n $this->log->error('[Hubspot] Failed to create note', [\n 'objectId' => $objectId,\n 'noteObject' => $noteObject->getObjectType(),\n 'reason' => $e->getMessage(),\n ]);\n\n \\Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function updateEngagement(string $objectId, array $engagement, array $metadata): void\n {\n $this->getInstance()->engagements()->update($objectId, $engagement, $metadata);\n }\n\n public function getEngagementData(string $engagementId): array\n {\n $engagement = $this->getInstance()->engagements()->get($engagementId);\n\n return $engagement->toArray();\n }\n\n public function createEngagement(array $engagement, array $associations, array $metadata): Response\n {\n return $this->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n }\n\n public function isUnauthorizedException(\\Exception $e): bool\n {\n // Check for specific HubSpot API exception types first\n if ($e instanceof BadRequest) {\n // BadRequest can contain 401 status codes\n return $e->getCode() === 401;\n }\n\n // Check for HTTP client exceptions with status codes\n if ($e instanceof \\GuzzleHttp\\Exception\\RequestException && $e->hasResponse()) {\n $response = $e->getResponse();\n if ($response !== null) {\n return $response->getStatusCode() === 401;\n }\n }\n\n // Check for Guzzle HTTP exceptions\n if ($e instanceof \\GuzzleHttp\\Exception\\ClientException) {\n return $e->getCode() === 401;\n }\n\n // Fallback to string matching as last resort, but be more specific\n $message = strtolower($e->getMessage());\n\n return str_contains($message, '401 unauthorized') ||\n str_contains($message, 'http 401') ||\n str_contains($message, 'status code 401') ||\n (preg_match('/\\b401\\b/', $message) && str_contains($message, 'unauthorized'));\n }\n\n /**\n * Validates and refreshes the access token if needed before API requests.\n * This ensures long-running processes don't fail due to token expiration.\n *\n * @throws SocialAccountTokenInvalidException\n */\n public function ensureValidToken(): void\n {\n if ($this->oauthAccount === null) {\n return;\n }\n\n $newToken = $this->tokenManager->ensureValidToken($this->oauthAccount);\n if ($newToken !== null) {\n $this->accessToken = $newToken;\n }\n }\n\n public function getConfig()\n {\n return $this->config;\n }\n\n // returns only active (archived=false)\n public function getOwners(): array\n {\n return $this->getNewInstance()->crm()->owners()->getAll();\n }\n\n /**\n * @param bool $archived\n *\n * @return array<Owner>|[]\n */\n public function getOwnersArchived(bool $archived = true): array\n {\n $endpoint = '/crm/v3/owners';\n $queryParams = [\n 'archived' => $archived ? 'true' : 'false',\n ];\n $queryString = http_build_query($queryParams);\n\n $owners = [];\n\n try {\n $response = $this->makeRequest(endpoint: $endpoint, queryString: $queryString);\n $responseData = $response?->toArray();\n\n foreach ($responseData['results'] as $result) {\n try {\n $owners[] = Owner::create($result);\n } catch (Throwable $e) {\n $this->log->error('[HubSpot] Failed to process owner data', [\n 'result' => $result,\n 'error' => $e->getMessage(),\n ]);\n\n continue;\n }\n }\n } catch (Throwable $e) {\n $this->log->error('HubSpot] Failed to fetch owners', [\n 'archived' => $archived,\n 'error' => $e->getMessage(),\n ]);\n\n return [];\n }\n\n return $owners;\n }\n\n public function getMeeting(string $engagementId): ObjectWithAssociations\n {\n return $this->getNewInstance()->crm()->objects()->basicApi()\n ->getById('meeting', $engagementId, null, 'contact,company,deal');\n }\n\n public function deleteEngagement(string $engagementId): void\n {\n $this->getInstance()->engagements()->delete((int) $engagementId);\n }\n\n public function getAssociationsData(array $ids, string $fromObject, string $toObject): array\n {\n $associationData = [];\n $idChunks = array_chunk($ids, self::ASSOCIATIONS_BATCH_SIZE_LIMIT);\n\n foreach ($idChunks as $idChunk) {\n try {\n $batchInput = new \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId();\n $batchInput->setInputs(array_map(function ($id) {\n $publicObjectId = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicObjectId();\n $publicObjectId->setId($id);\n\n return $publicObjectId;\n }, $idChunk));\n\n $associatedObjectsData = $this\n ->getNewInstance()\n ->crm()\n ->associations()\n ->batchApi()\n ->read($fromObject, $toObject, $batchInput);\n\n if ($associatedObjectsData instanceof \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchResponsePublicAssociationMulti) {\n foreach ($associatedObjectsData->getResults() as $association) {\n $from = $association->getFrom()->getId();\n $toAssociations = $association->getTo();\n\n if (! empty($toAssociations)) {\n $associationData[$from] = array_map(function ($item) {\n return $item->getId();\n }, $toAssociations);\n }\n }\n }\n } catch (\\Exception $e) {\n $this->log->error('[Hubspot] Failed to fetch associations', [\n 'from_object' => $fromObject,\n 'to_object' => $toObject,\n 'reason' => $e->getMessage(),\n ]);\n }\n }\n\n return $associationData;\n }\n\n /**\n * @throws \\Exception\n */\n private function getNoteAssociationType(NoteObject $noteObject): string\n {\n return match($noteObject) {\n NoteObject::Opportunity => 'note_to_deal',\n NoteObject::Lead, NoteObject::Contact => 'note_to_contact', // or 'note_to_lead' if your portal supports it\n NoteObject::Account => 'note_to_company',\n NoteObject::Call, NoteObject::Event => throw new \\Exception('Not supported'),\n };\n }\n\n /**\n * @throws \\Exception\n */\n private function getNoteObject(NoteObject $noteObject): string\n {\n return match($noteObject) {\n NoteObject::Opportunity => 'deal',\n NoteObject::Lead, NoteObject::Contact => 'contact',\n NoteObject::Account => 'company',\n NoteObject::Call, NoteObject::Event => throw new \\Exception('Not supported'),\n };\n }\n\n public function addAssociations(string $objectType, string $associationType, array $payload): Response\n {\n $endpoint = \"/crm/v4/associations/$objectType/$associationType/batch/create\";\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n\n public function removeAssociations(string $objectType, string $associationType, array $payload): Response\n {\n $endpoint = \"/crm/v4/associations/$objectType/$associationType/batch/archive\";\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse HubSpot\\Client\\Crm\\Deals\\ApiException as DealApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\ApiException as ContactApiException;\nuse HubSpot\\Client\\Crm\\Companies\\ApiException as CompanyApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations as ContactsWithAssociations;\nuse HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectWithAssociations as CompaniesWithAssociations;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectWithAssociations as DealWithAssociations;\nuse HubSpot\\Client\\Crm\\Objects\\Model\\SimplePublicObjectInput;\nuse HubSpot\\Client\\Crm\\Objects\\Model\\SimplePublicObjectWithAssociations as ObjectWithAssociations;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Error;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\PipelineStage;\nuse HubSpot\\Client\\Crm\\Properties\\Model\\Property;\nuse HubSpot\\Discovery\\Discovery;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Services\\Crm\\BaseClient;\nuse Jiminny\\Services\\Crm\\Hubspot\\DTO\\Response\\Owner;\nuse Jiminny\\Services\\SocialAccountService;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\nuse SevenShores\\Hubspot\\Factory;\nuse SevenShores\\Hubspot\\Http\\Response;\nuse Jiminny\\Services\\Crm\\Hubspot\\Pagination\\HubspotPaginationService;\nuse Illuminate\\Support\\Facades\\Redis;\nuse Throwable;\n\n/**\n * @phpstan-type CrmFieldOption array{id:string, label:string, value?:string}\n */\nclass Client extends BaseClient implements HubspotClientInterface\n{\n public const string MIN_API_VERSION = '2';\n\n public const string BASE_URL = 'https://api.hubapi.com';\n\n public const int ASSOCIATIONS_BATCH_SIZE_LIMIT = 1000;\n\n private HubspotPaginationService $paginationService;\n private HubspotTokenManager $tokenManager;\n\n public function __construct(\n SocialAccountService $socialAccountService,\n HubspotPaginationService $paginationService,\n HubspotTokenManager $tokenManager\n ) {\n parent::__construct($socialAccountService);\n $this->paginationService = $paginationService;\n $this->tokenManager = $tokenManager;\n\n $this->setBaseUrl(self::BASE_URL);\n $this->setVersion(self::MIN_API_VERSION);\n }\n\n /**\n * Execute a HubSpot API call with rate limit handling.\n *\n * On a 429, stores the absolute expiry timestamp with SET NX (first writer wins).\n * This means all subsequent jobs that also receive 429 in the same burst do not\n * reset the TTL — the window is anchored to the first 429, not the last.\n * Readers compute the remaining wait from the stored timestamp, so jobs that check\n * the cache near expiry are not delayed longer than necessary.\n *\n * @template T\n *\n * @param callable(): T $apiCall The API call to execute\n *\n * @throws RateLimitException When rate limit is hit or cached rate limit is active\n *\n * @return T The result of the API call\n */\n private function executeRequest(callable $apiCall)\n {\n $cacheKey = $this->getRateLimitCacheKey();\n\n $cachedExpiresAt = Redis::get($cacheKey);\n if (is_string($cachedExpiresAt) && is_numeric($cachedExpiresAt)) {\n $remaining = max(1, (int) $cachedExpiresAt - time());\n\n throw new RateLimitException(\n 'Hubspot rate limit (cached circuit-breaker)',\n $remaining,\n );\n }\n\n try {\n return $apiCall();\n } catch (Throwable $e) {\n if ($this->isHubspotRateLimit($e)) {\n $retryAfter = $this->parseRetryAfter($e);\n\n // NX: only the first job to receive a 429 in this burst sets the key.\n // Subsequent 429s in the same burst leave the TTL untouched so the\n // window is not reset by every concurrent job hitting the limit.\n Redis::set($cacheKey, (string) (time() + $retryAfter), ['nx', 'ex' => $retryAfter]);\n\n $this->log->warning('[Hubspot] Received 429 from API', [\n 'team_id' => $this->config->team_id,\n 'config_id' => $this->config->getId(),\n 'retry_after' => $retryAfter,\n 'reason' => $e->getMessage(),\n ]);\n\n throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);\n }\n\n throw $e;\n }\n }\n\n private function getRateLimitCacheKey(): string\n {\n return sprintf('hubspot:ratelimit:config:%d', $this->config->getId());\n }\n\n private function isHubspotRateLimit(Throwable $e): bool\n {\n if ($e instanceof BadRequest\n || $e instanceof DealApiException\n || $e instanceof ContactApiException\n || $e instanceof CompanyApiException\n || $e instanceof \\GuzzleHttp\\Exception\\RequestException\n ) {\n return (int) $e->getCode() === 429;\n }\n\n return false;\n }\n\n private function parseRetryAfter(Throwable $e): int\n {\n if (method_exists($e, 'getResponseHeaders')) {\n $headers = $e->getResponseHeaders() ?: [];\n $value = $headers['Retry-After'] ?? $headers['retry-after'] ?? null;\n if (is_array($value)) {\n $value = $value[0] ?? null;\n }\n if (is_numeric($value)) {\n return (int) $value;\n }\n }\n\n $message = strtolower($e->getMessage());\n\n if (str_contains($message, 'daily')) {\n return 600;\n }\n if (str_contains($message, 'ten secondly')) {\n return 10;\n }\n if (str_contains($message, 'secondly')) {\n return 1;\n }\n\n $this->log->warning('[Hubspot] No retry-after header or known message, using default', [\n 'exception_class' => get_class($e),\n 'message' => $message,\n ]);\n\n return 10;\n }\n\n public function getMinimumApiVersion(): string\n {\n return self::MIN_API_VERSION;\n }\n\n public function getInstance(): Factory\n {\n return new Factory([\n 'key' => $this->accessToken,\n 'oauth2' => true,\n 'base_url' => $this->baseUrl,\n ]);\n }\n\n public function getNewInstance(): Discovery\n {\n return \\HubSpot\\Factory::createWithAccessToken($this->accessToken);\n }\n\n /**\n * Secondly and daily limits for Hubspot API\n *\n * Product Tier: Free & Starter | Professional & Enterprise | API add-on (any tier)\n * Burst: 100/10 seconds | 150/10 seconds | 200/10 seconds\n * Daily: 250,000 | 500,000 | 1,000,000\n *\n * Official documentation states: The search endpoints are rate limited to five requests per second.\n * Since with 5 RPS were still hitting secondly rate limits we lowered it to 4\n */\n public function getPaginatedData(array $payload, string $type, int $offset = 0): array\n {\n $total = 0;\n $lastId = null;\n $rows = [];\n foreach ($this->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastId) as $row) {\n $rows[] = $row;\n }\n\n return ['results' => $rows, 'total' => $total, 'last_record' => $lastId];\n }\n\n /**\n * @throws HubspotException\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n */\n public function getPaginatedDataGenerator(\n array $payload,\n string $type,\n int $offset = 0,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n return $this->paginationService->getPaginatedDataGenerator(\n $this,\n $payload,\n $type,\n $offset,\n $total,\n $lastRecordId\n );\n }\n\n /**\n * Execute a search request against HubSpot CRM objects with rate limiting.\n *\n * @param string $objectType The object type ('deals', 'companies', 'contacts', 'calls')\n * @param array<string, mixed> $payload The search payload with filters, sorts, properties, etc.\n *\n * @throws RateLimitException When rate limit is hit\n * @throws HubspotException On API errors\n *\n * @return array The search response with 'results', 'total', 'paging' keys\n */\n public function search(string $objectType, array $payload): array\n {\n $endpoint = self::BASE_URL . \"/crm/v3/objects/{$objectType}/search\";\n\n return $this->executeRequest(function () use ($endpoint, $payload) {\n $response = $this->getInstance()->getClient()->request('POST', $endpoint, ['json' => $payload]);\n\n return $response->toArray();\n });\n }\n\n /**\n * @throws DealApiException\n * @throws CrmException\n */\n public function getOpportunityById(string $crmId, array $fields): array\n {\n try {\n $deal = $this->getNewInstance()->crm()->deals()->basicApi()->getById(\n $crmId,\n implode(',', $fields),\n 'companies,contacts'\n );\n } catch (DealApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $deal instanceof DealWithAssociations) {\n throw new CrmException('Deal not found');\n }\n\n return [\n 'id' => $deal->getId(),\n 'properties' => $deal->getProperties(),\n 'associations' => $deal->getAssociations(),\n ];\n }\n\n /**\n * Generic batch read method for HubSpot objects\n *\n * @param string $objectType The object type ('deals', 'companies', 'contacts')\n * @param array<string> $crmIds Array of HubSpot object IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with object data\n */\n private function batchReadObjects(string $objectType, array $crmIds, array $fields): array\n {\n if (empty($crmIds)) {\n return [];\n }\n\n $this->validateBatchSize($objectType, $crmIds);\n $this->ensureValidToken();\n\n try {\n $batchConfig = $this->createBatchConfiguration($objectType);\n $batchReadRequest = $this->prepareBatchRequest($batchConfig, $crmIds, $fields);\n\n $response = $this->executeRequest(fn () => $batchConfig['api']->read($batchReadRequest));\n\n $this->validateApiResponse($response, $objectType);\n\n $results = $this->processApiResults($response);\n $this->logBatchResults($objectType, $crmIds, $results);\n\n return $results;\n } catch (RateLimitException $e) {\n throw $e;\n } catch (\\Throwable $e) {\n $this->handleBatchError($e, $objectType, $crmIds);\n }\n }\n\n private function validateBatchSize(string $objectType, array $crmIds): void\n {\n if (count($crmIds) > 100) {\n throw new \\InvalidArgumentException(\"Batch size cannot exceed 100 {$objectType}\");\n }\n }\n\n private function createBatchConfiguration(string $objectType): array\n {\n $configurations = [\n 'deals' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Deals\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->deals()->batchApi(),\n ],\n 'companies' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Companies\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->companies()->batchApi(),\n ],\n 'contacts' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Contacts\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->contacts()->batchApi(),\n ],\n ];\n\n if (! isset($configurations[$objectType])) {\n throw new \\InvalidArgumentException(\"Unsupported object type: {$objectType}\");\n }\n\n return $configurations[$objectType];\n }\n\n private function prepareBatchRequest(array $batchConfig, array $crmIds, array $fields): object\n {\n $batchReadRequest = $batchConfig['batchReadRequest'];\n $inputClass = $batchConfig['inputClass'];\n\n $inputs = array_map(function ($crmId) use ($inputClass) {\n $input = new $inputClass();\n $input->setId($crmId);\n\n return $input;\n }, $crmIds);\n\n $batchReadRequest->setInputs($inputs);\n $batchReadRequest->setProperties($fields);\n\n return $batchReadRequest;\n }\n\n private function validateApiResponse($response, string $objectType): void\n {\n if (! $response) {\n throw new CrmException(\"HubSpot API returned null response for {$objectType} batch read\");\n }\n }\n\n private function processApiResults($response): array\n {\n $results = [];\n $responseResults = $response->getResults();\n\n if ($responseResults) {\n foreach ($responseResults as $object) {\n if ($object && $object->getId()) {\n $results[$object->getId()] = [\n 'id' => $object->getId(),\n 'properties' => $object->getProperties() ?: [],\n ];\n }\n }\n }\n\n return $results;\n }\n\n private function logBatchResults(string $objectType, array $crmIds, array $results): void\n {\n $this->log->info(\"[HubSpot] Batch fetched {$objectType}\", [\n 'requested_count' => count($crmIds),\n 'returned_count' => count($results),\n 'crm_ids' => $crmIds,\n ]);\n }\n\n private function handleBatchError(\\Throwable $e, string $objectType, array $crmIds): void\n {\n $errorMessage = $e->getMessage() ?: 'Unknown error';\n $errorTrace = $e->getTraceAsString() ?: 'No trace available';\n\n $this->log->error(\"[HubSpot] Failed to batch fetch {$objectType}\", [\n 'crm_ids' => $crmIds,\n 'error' => $errorMessage,\n 'trace' => $errorTrace,\n ]);\n\n throw new CrmException(\"Failed to batch fetch {$objectType}: \" . $errorMessage);\n }\n\n /**\n * Batch read multiple opportunities by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot deal IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with opportunity data\n */\n public function getOpportunitiesByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('deals', $crmIds, $fields);\n }\n\n /**\n * Batch read multiple companies by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot company IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with company data\n */\n public function getCompaniesByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('companies', $crmIds, $fields);\n }\n\n /**\n * Batch read multiple contacts by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot contact IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with contact data\n */\n public function getContactsByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('contacts', $crmIds, $fields);\n }\n\n /**\n * @throws CompanyApiException\n * @throws CrmException\n */\n public function getAccountById(string $crmId, array $fields): array\n {\n try {\n $company = $this->getNewInstance()->crm()->companies()->basicApi()->getById(\n $crmId,\n implode(',', $fields),\n );\n } catch (CompanyApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch account', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $company instanceof CompaniesWithAssociations) {\n throw new CrmException('Account not found');\n }\n\n return [\n 'id' => $company->getId(),\n 'properties' => $company->getProperties(),\n ];\n }\n\n /**\n * @throws ContactApiException\n * @throws CrmException\n */\n public function getContactById(string $crmId, array $fields): array\n {\n try {\n $contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(\n $crmId,\n implode(',', $fields)\n );\n } catch (ContactApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch contact', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $contact instanceof ContactsWithAssociations) {\n throw new CrmException('Contact not found');\n }\n\n return [\n 'id' => $contact->getId(),\n 'properties' => $contact->getProperties(),\n ];\n }\n\n /**\n * This is email search request that Hubspot offers as GET (more generous quota)\n */\n public function getContactByEmail(string $email, array $fields = []): array\n {\n try {\n $contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(\n $email,\n implode(',', $fields),\n null,\n false,\n 'email'\n );\n\n return [\n 'id' => $contact->getId(),\n 'properties' => $contact->getProperties(),\n ];\n } catch (ContactApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch contact', [\n 'email' => $email,\n 'reason' => $e->getMessage(),\n ]);\n\n return [];\n }\n }\n\n /**\n * @throws CrmException\n */\n public function fetchProperty(string $objectType, string $propertyId): Property\n {\n $result = $this->getNewInstance()->crm()->properties()->coreApi()->getByName($objectType, $propertyId);\n\n if (! $result instanceof Property) {\n $this->log->error('[Hubspot] Failed to fetch property', [\n 'object_type' => $objectType,\n 'property_id' => $propertyId,\n 'reason' => $result->getMessage(),\n ]);\n\n throw new CrmException('Failed to fetch property');\n }\n\n return $result;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchPropertyOptions(string $objectType, string $propertyId): array\n {\n /** @var array<CrmFieldOption> */\n return $this->fetchProperty($objectType, $propertyId)->getOptions();\n }\n\n /**\n * @return array<array{id:string, label:string, deleted:bool}>\n */\n public function fetchCallDispositions(): array\n {\n /** @var Response $response */\n $response = $this->getInstance()->engagements()->getCallDispositions();\n\n /**\n * @var array<array{\n * id:string,\n * label:string,\n * deleted: bool\n * }>\n */\n return $response->toArray();\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchOpportunityPipelineStages(): array\n {\n $stages = [];\n $apiResponse = $this->getNewInstance()->crm()->pipelines()->pipelinesApi()->getAll('deals');\n\n if ($apiResponse instanceof Error) {\n $this->log->error('[Hubspot] Failed to fetch opportunity pipelines', [\n 'reason' => $apiResponse->getMessage(),\n ]);\n\n return [];\n }\n\n foreach ($apiResponse->getResults() as $pipeline) {\n $pipelineStages = array_map(\n static function (PipelineStage $stage) {\n return [\n 'id' => $stage->getId(),\n 'label' => $stage->getLabel(),\n ];\n },\n $pipeline->getStages()\n );\n\n $stages = array_merge($stages, $pipelineStages);\n }\n\n return $stages;\n }\n\n public function fetchOpportunityPipelines(): array\n {\n $pipelines = [];\n\n try {\n $apiResponse = $this->makeRequest('/crm/v3/pipelines/deals');\n } catch (\\Exception $e) {\n $this->log->info('[Hubspot] Failed to fetch opportunity pipelines', [\n 'reason' => $e->getMessage(),\n ]);\n\n return [];\n }\n\n $response = $apiResponse->toArray();\n\n foreach ($response['results'] as $pipeline) {\n $pipelines[] = [\n 'id' => $pipeline['id'],\n 'label' => $pipeline['label'],\n ];\n }\n\n return $pipelines;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchMeetingOutcomeFieldOptions(Field $field): array\n {\n return $field->getCrmProviderId() === 'meetingOutcome'\n ? $this->fetchMeetingOutcomeTypes()\n : $this->fetchCallActivityTypes();\n }\n\n public function fetchMeetingOutcomeTypes(): array\n {\n return $this->extractMeetingTypeOptions(\n 'https://api.hubapi.com/crm/v3/properties/meeting/hs_meeting_outcome'\n );\n }\n\n public function fetchCallActivityTypes(): array\n {\n return $this->extractMeetingTypeOptions(\n 'https://api.hubapi.com/crm/v3/properties/call/hs_activity_type'\n );\n }\n\n private function extractMeetingTypeOptions(string $endpoint): array\n {\n /** @var Response $response */\n $response = $this->getInstance()\n ->getClient()\n ->request('GET', $endpoint);\n\n /**\n * @var array<array{\n * value: string,\n * label: string,\n * displayOrder: int\n * }> $optionData\n */\n $optionData = $response->toArray()['options'] ?? [];\n\n $options = [];\n foreach ($optionData as $item) {\n $options[] = [\n 'id' => $item['value'],\n 'value' => $item['value'],\n 'label' => $item['label'],\n 'display_order' => $item['displayOrder'],\n ];\n }\n\n return $options;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchDispositionFieldOptions(): array\n {\n $options = [];\n\n $dispositions = $this->fetchCallDispositions();\n\n foreach ($dispositions as $disposition) {\n if ($disposition['deleted'] !== false) {\n continue;\n }\n\n $option['value'] = $disposition['id'];\n $option['id'] = $disposition['id'];\n $option['label'] = $disposition['label'];\n\n $options[] = $option;\n }\n\n return $options;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchOpportunityFieldOptions(Field $field): array\n {\n if ($field->isStageField()) {\n return $this->fetchOpportunityPipelineStages();\n }\n\n if ($field->isPipelineField()) {\n return $this->fetchOpportunityPipelines();\n }\n\n return $this->fetchPropertyOptions('deals', $field->getCrmProviderId());\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function makeRequest(string $endpoint, $method = 'GET', $payload = [], ?string $queryString = null)\n {\n $endpoint = self::BASE_URL . $endpoint;\n\n if ($method === 'GET') {\n return $this->getInstance()->getClient()?->request(\n method: $method,\n endpoint: $endpoint,\n query_string: $queryString\n );\n } else {\n return $this->getInstance()->getClient()->request($method, $endpoint, [\n 'json' => ($payload),\n ]);\n }\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function createMeeting(array $payload): Response\n {\n $endpoint = '/crm/v3/objects/meetings';\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function updateMeeting(string $meetingId, array $payload): Response\n {\n $endpoint = '/crm/v3/objects/meetings/' . $meetingId;\n\n return $this->makeRequest($endpoint, 'PATCH', $payload);\n }\n\n /**\n * @throws \\Exception\n */\n public function createNote(\n string $body,\n string $ownerId,\n int $timestamp,\n string $objectId,\n NoteObject $noteObject\n ): ?string {\n try {\n $noteInput = new SimplePublicObjectInput([\n 'properties' => [\n 'hs_note_body' => $body,\n 'hubspot_owner_id' => $ownerId,\n 'hs_timestamp' => $timestamp,\n ],\n ]);\n\n // Create note\n $note = $this->getNewInstance()->crm()->objects()->basicApi()->create('note', $noteInput);\n\n $this->getNewInstance()->crm()->objects()->associationsApi()->create(\n 'note',\n $note->getId(),\n $this->getNoteObject($noteObject),\n $objectId,\n $this->getNoteAssociationType($noteObject),\n );\n\n return $note->getId();\n } catch (\\Exception $e) {\n $this->log->error('[Hubspot] Failed to create note', [\n 'objectId' => $objectId,\n 'noteObject' => $noteObject->getObjectType(),\n 'reason' => $e->getMessage(),\n ]);\n\n \\Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function updateEngagement(string $objectId, array $engagement, array $metadata): void\n {\n $this->getInstance()->engagements()->update($objectId, $engagement, $metadata);\n }\n\n public function getEngagementData(string $engagementId): array\n {\n $engagement = $this->getInstance()->engagements()->get($engagementId);\n\n return $engagement->toArray();\n }\n\n public function createEngagement(array $engagement, array $associations, array $metadata): Response\n {\n return $this->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n }\n\n public function isUnauthorizedException(\\Exception $e): bool\n {\n // Check for specific HubSpot API exception types first\n if ($e instanceof BadRequest) {\n // BadRequest can contain 401 status codes\n return $e->getCode() === 401;\n }\n\n // Check for HTTP client exceptions with status codes\n if ($e instanceof \\GuzzleHttp\\Exception\\RequestException && $e->hasResponse()) {\n $response = $e->getResponse();\n if ($response !== null) {\n return $response->getStatusCode() === 401;\n }\n }\n\n // Check for Guzzle HTTP exceptions\n if ($e instanceof \\GuzzleHttp\\Exception\\ClientException) {\n return $e->getCode() === 401;\n }\n\n // Fallback to string matching as last resort, but be more specific\n $message = strtolower($e->getMessage());\n\n return str_contains($message, '401 unauthorized') ||\n str_contains($message, 'http 401') ||\n str_contains($message, 'status code 401') ||\n (preg_match('/\\b401\\b/', $message) && str_contains($message, 'unauthorized'));\n }\n\n /**\n * Validates and refreshes the access token if needed before API requests.\n * This ensures long-running processes don't fail due to token expiration.\n *\n * @throws SocialAccountTokenInvalidException\n */\n public function ensureValidToken(): void\n {\n if ($this->oauthAccount === null) {\n return;\n }\n\n $newToken = $this->tokenManager->ensureValidToken($this->oauthAccount);\n if ($newToken !== null) {\n $this->accessToken = $newToken;\n }\n }\n\n public function getConfig()\n {\n return $this->config;\n }\n\n // returns only active (archived=false)\n public function getOwners(): array\n {\n return $this->getNewInstance()->crm()->owners()->getAll();\n }\n\n /**\n * @param bool $archived\n *\n * @return array<Owner>|[]\n */\n public function getOwnersArchived(bool $archived = true): array\n {\n $endpoint = '/crm/v3/owners';\n $queryParams = [\n 'archived' => $archived ? 'true' : 'false',\n ];\n $queryString = http_build_query($queryParams);\n\n $owners = [];\n\n try {\n $response = $this->makeRequest(endpoint: $endpoint, queryString: $queryString);\n $responseData = $response?->toArray();\n\n foreach ($responseData['results'] as $result) {\n try {\n $owners[] = Owner::create($result);\n } catch (Throwable $e) {\n $this->log->error('[HubSpot] Failed to process owner data', [\n 'result' => $result,\n 'error' => $e->getMessage(),\n ]);\n\n continue;\n }\n }\n } catch (Throwable $e) {\n $this->log->error('HubSpot] Failed to fetch owners', [\n 'archived' => $archived,\n 'error' => $e->getMessage(),\n ]);\n\n return [];\n }\n\n return $owners;\n }\n\n public function getMeeting(string $engagementId): ObjectWithAssociations\n {\n return $this->getNewInstance()->crm()->objects()->basicApi()\n ->getById('meeting', $engagementId, null, 'contact,company,deal');\n }\n\n public function deleteEngagement(string $engagementId): void\n {\n $this->getInstance()->engagements()->delete((int) $engagementId);\n }\n\n public function getAssociationsData(array $ids, string $fromObject, string $toObject): array\n {\n $associationData = [];\n $idChunks = array_chunk($ids, self::ASSOCIATIONS_BATCH_SIZE_LIMIT);\n\n foreach ($idChunks as $idChunk) {\n try {\n $batchInput = new \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId();\n $batchInput->setInputs(array_map(function ($id) {\n $publicObjectId = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicObjectId();\n $publicObjectId->setId($id);\n\n return $publicObjectId;\n }, $idChunk));\n\n $associatedObjectsData = $this\n ->getNewInstance()\n ->crm()\n ->associations()\n ->batchApi()\n ->read($fromObject, $toObject, $batchInput);\n\n if ($associatedObjectsData instanceof \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchResponsePublicAssociationMulti) {\n foreach ($associatedObjectsData->getResults() as $association) {\n $from = $association->getFrom()->getId();\n $toAssociations = $association->getTo();\n\n if (! empty($toAssociations)) {\n $associationData[$from] = array_map(function ($item) {\n return $item->getId();\n }, $toAssociations);\n }\n }\n }\n } catch (\\Exception $e) {\n $this->log->error('[Hubspot] Failed to fetch associations', [\n 'from_object' => $fromObject,\n 'to_object' => $toObject,\n 'reason' => $e->getMessage(),\n ]);\n }\n }\n\n return $associationData;\n }\n\n /**\n * @throws \\Exception\n */\n private function getNoteAssociationType(NoteObject $noteObject): string\n {\n return match($noteObject) {\n NoteObject::Opportunity => 'note_to_deal',\n NoteObject::Lead, NoteObject::Contact => 'note_to_contact', // or 'note_to_lead' if your portal supports it\n NoteObject::Account => 'note_to_company',\n NoteObject::Call, NoteObject::Event => throw new \\Exception('Not supported'),\n };\n }\n\n /**\n * @throws \\Exception\n */\n private function getNoteObject(NoteObject $noteObject): string\n {\n return match($noteObject) {\n NoteObject::Opportunity => 'deal',\n NoteObject::Lead, NoteObject::Contact => 'contact',\n NoteObject::Account => 'company',\n NoteObject::Call, NoteObject::Event => throw new \\Exception('Not supported'),\n };\n }\n\n public function addAssociations(string $objectType, string $associationType, array $payload): Response\n {\n $endpoint = \"/crm/v4/associations/$objectType/$associationType/batch/create\";\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n\n public function removeAssociations(string $objectType, string $associationType, array $payload): Response\n {\n $endpoint = \"/crm/v4/associations/$objectType/$associationType/batch/archive\";\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.96276593,"top":0.07581804,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.9740692,"top":0.074221864,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
809868849040819166
|
614008889381095524
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
ClientTest
Run 'ClientTest'
Debug 'ClientTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
2
66
2
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use HubSpot\Client\Crm\Deals\ApiException as DealApiException;
use HubSpot\Client\Crm\Contacts\ApiException as ContactApiException;
use HubSpot\Client\Crm\Companies\ApiException as CompanyApiException;
use HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations as ContactsWithAssociations;
use HubSpot\Client\Crm\Companies\Model\SimplePublicObjectWithAssociations as CompaniesWithAssociations;
use HubSpot\Client\Crm\Deals\Model\SimplePublicObjectWithAssociations as DealWithAssociations;
use HubSpot\Client\Crm\Objects\Model\SimplePublicObjectInput;
use HubSpot\Client\Crm\Objects\Model\SimplePublicObjectWithAssociations as ObjectWithAssociations;
use HubSpot\Client\Crm\Pipelines\Model\Error;
use HubSpot\Client\Crm\Pipelines\Model\PipelineStage;
use HubSpot\Client\Crm\Properties\Model\Property;
use HubSpot\Discovery\Discovery;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Crm\Field;
use Jiminny\Services\Crm\BaseClient;
use Jiminny\Services\Crm\Hubspot\DTO\Response\Owner;
use Jiminny\Services\SocialAccountService;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
use SevenShores\Hubspot\Factory;
use SevenShores\Hubspot\Http\Response;
use Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService;
use Illuminate\Support\Facades\Redis;
use Throwable;
/**
* @phpstan-type CrmFieldOption array{id:string, label:string, value?:string}
*/
class Client extends BaseClient implements HubspotClientInterface
{
public const string MIN_API_VERSION = '2';
public const string BASE_URL = '[URL_WITH_CREDENTIALS] T
*
* @param callable(): T $apiCall The API call to execute
*
* @throws RateLimitException When rate limit is hit or cached rate limit is active
*
* @return T The result of the API call
*/
private function executeRequest(callable $apiCall)
{
$cacheKey = $this->getRateLimitCacheKey();
$cachedExpiresAt = Redis::get($cacheKey);
if (is_string($cachedExpiresAt) && is_numeric($cachedExpiresAt)) {
$remaining = max(1, (int) $cachedExpiresAt - time());
throw new RateLimitException(
'Hubspot rate limit (cached circuit-breaker)',
$remaining,
);
}
try {
return $apiCall();
} catch (Throwable $e) {
if ($this->isHubspotRateLimit($e)) {
$retryAfter = $this->parseRetryAfter($e);
// NX: only the first job to receive a 429 in this burst sets the key.
// Subsequent 429s in the same burst leave the TTL untouched so the
// window is not reset by every concurrent job hitting the limit.
Redis::set($cacheKey, (string) (time() + $retryAfter), ['nx', 'ex' => $retryAfter]);
$this->log->warning('[Hubspot] Received 429 from API', [
'team_id' => $this->config->team_id,
'config_id' => $this->config->getId(),
'retry_after' => $retryAfter,
'reason' => $e->getMessage(),
]);
throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);
}
throw $e;
}
}
private function getRateLimitCacheKey(): string
{
return sprintf('hubspot:ratelimit:config:%d', $this->config->getId());
}
private function isHubspotRateLimit(Throwable $e): bool
{
if ($e instanceof BadRequest
|| $e instanceof DealApiException
|| $e instanceof ContactApiException
|| $e instanceof CompanyApiException
|| $e instanceof \GuzzleHttp\Exception\RequestException
) {
return (int) $e->getCode() === 429;
}
return false;
}
private function parseRetryAfter(Throwable $e): int
{
if (method_exists($e, 'getResponseHeaders')) {
$headers = $e->getResponseHeaders() ?: [];
$value = $headers['Retry-After'] ?? $headers['retry-after'] ?? null;
if (is_array($value)) {
$value = $value[0] ?? null;
}
if (is_numeric($value)) {
return (int) $value;
}
}
$message = strtolower($e->getMessage());
if (str_contains($message, 'daily')) {
return 600;
}
if (str_contains($message, 'ten secondly')) {
return 10;
}
if (str_contains($message, 'secondly')) {
return 1;
}
$this->log->warning('[Hubspot] No retry-after header or known message, using default', [
'exception_class' => get_class($e),
'message' => $message,
]);
return 10;
}
public function getMinimumApiVersion(): string
{
return self::MIN_API_VERSION;
}
public function getInstance(): Factory
{
return new Factory([
'key' => $this->accessToken,
'oauth2' => true,
'base_url' => $this->baseUrl,
]);
}
public function getNewInstance(): Discovery
{
return \HubSpot\Factory::createWithAccessToken($this->accessToken);
}
/**
* Secondly and daily limits for Hubspot API
*
* Product Tier: Free & Starter | Professional & Enterprise | API add-on (any tier)
* Burst: 100/10 seconds | 150/10 seconds | 200/10 seconds
* Daily: 250,000 | 500,000 | 1,000,000
*
* Official documentation states: The search endpoints are rate limited to five requests per second.
* Since with 5 RPS were still hitting secondly rate limits we lowered it to 4
*/
public function getPaginatedData(array $payload, string $type, int $offset = 0): array
{
$total = 0;
$lastId = null;
$rows = [];
foreach ($this->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastId) as $row) {
$rows[] = $row;
}
return ['results' => $rows, 'total' => $total, 'last_record' => $lastId];
}
/**
* @throws HubspotException
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
*/
public function getPaginatedDataGenerator(
array $payload,
string $type,
int $offset = 0,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
return $this->paginationService->getPaginatedDataGenerator(
$this,
$payload,
$type,
$offset,
$total,
$lastRecordId
);
}
/**
* Execute a search request against HubSpot CRM objects with rate limiting.
*
* @param string $objectType The object type ('deals', 'companies', 'contacts', 'calls')
* @param array<string, mixed> $payload The search payload with filters, sorts, properties, etc.
*
* @throws RateLimitException When rate limit is hit
* @throws HubspotException On API errors
*
* @return array The search response with 'results', 'total', 'paging' keys
*/
public function search(string $objectType, array $payload): array
{
$endpoint = self::BASE_URL . "/crm/v3/objects/{$objectType}/search";
return $this->executeRequest(function () use ($endpoint, $payload) {
$response = $this->getInstance()->getClient()->request('POST', $endpoint, ['json' => $payload]);
return $response->toArray();
});
}
/**
* @throws DealApiException
* @throws CrmException
*/
public function getOpportunityById(string $crmId, array $fields): array
{
try {
$deal = $this->getNewInstance()->crm()->deals()->basicApi()->getById(
$crmId,
implode(',', $fields),
'companies,contacts'
);
} catch (DealApiException $e) {
$this->log->info('[Hubspot] Failed to fetch opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $deal instanceof DealWithAssociations) {
throw new CrmException('Deal not found');
}
return [
'id' => $deal->getId(),
'properties' => $deal->getProperties(),
'associations' => $deal->getAssociations(),
];
}
/**
* Generic batch read method for HubSpot objects
*
* @param string $objectType The object type ('deals', 'companies', 'contacts')
* @param array<string> $crmIds Array of HubSpot object IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with object data
*/
private function batchReadObjects(string $objectType, array $crmIds, array $fields): array
{
if (empty($crmIds)) {
return [];
}
$this->validateBatchSize($objectType, $crmIds);
$this->ensureValidToken();
try {
$batchConfig = $this->createBatchConfiguration($objectType);
$batchReadRequest = $this->prepareBatchRequest($batchConfig, $crmIds, $fields);
$response = $this->executeRequest(fn () => $batchConfig['api']->read($batchReadRequest));
$this->validateApiResponse($response, $objectType);
$results = $this->processApiResults($response);
$this->logBatchResults($objectType, $crmIds, $results);
return $results;
} catch (RateLimitException $e) {
throw $e;
} catch (\Throwable $e) {
$this->handleBatchError($e, $objectType, $crmIds);
}
}
private function validateBatchSize(string $objectType, array $crmIds): void
{
if (count($crmIds) > 100) {
throw new \InvalidArgumentException("Batch size cannot exceed 100 {$objectType}");
}
}
private function createBatchConfiguration(string $objectType): array
{
$configurations = [
'deals' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Deals\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Deals\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->deals()->batchApi(),
],
'companies' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Companies\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Companies\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->companies()->batchApi(),
],
'contacts' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Contacts\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->contacts()->batchApi(),
],
];
if (! isset($configurations[$objectType])) {
throw new \InvalidArgumentException("Unsupported object type: {$objectType}");
}
return $configurations[$objectType];
}
private function prepareBatchRequest(array $batchConfig, array $crmIds, array $fields): object
{
$batchReadRequest = $batchConfig['batchReadRequest'];
$inputClass = $batchConfig['inputClass'];
$inputs = array_map(function ($crmId) use ($inputClass) {
$input = new $inputClass();
$input->setId($crmId);
return $input;
}, $crmIds);
$batchReadRequest->setInputs($inputs);
$batchReadRequest->setProperties($fields);
return $batchReadRequest;
}
private function validateApiResponse($response, string $objectType): void
{
if (! $response) {
throw new CrmException("HubSpot API returned null response for {$objectType} batch read");
}
}
private function processApiResults($response): array
{
$results = [];
$responseResults = $response->getResults();
if ($responseResults) {
foreach ($responseResults as $object) {
if ($object && $object->getId()) {
$results[$object->getId()] = [
'id' => $object->getId(),
'properties' => $object->getProperties() ?: [],
];
}
}
}
return $results;
}
private function logBatchResults(string $objectType, array $crmIds, array $results): void
{
$this->log->info("[HubSpot] Batch fetched {$objectType}", [
'requested_count' => count($crmIds),
'returned_count' => count($results),
'crm_ids' => $crmIds,
]);
}
private function handleBatchError(\Throwable $e, string $objectType, array $crmIds): void
{
$errorMessage = $e->getMessage() ?: 'Unknown error';
$errorTrace = $e->getTraceAsString() ?: 'No trace available';
$this->log->error("[HubSpot] Failed to batch fetch {$objectType}", [
'crm_ids' => $crmIds,
'error' => $errorMessage,
'trace' => $errorTrace,
]);
throw new CrmException("Failed to batch fetch {$objectType}: " . $errorMessage);
}
/**
* Batch read multiple opportunities by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot deal IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with opportunity data
*/
public function getOpportunitiesByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('deals', $crmIds, $fields);
}
/**
* Batch read multiple companies by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot company IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with company data
*/
public function getCompaniesByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('companies', $crmIds, $fields);
}
/**
* Batch read multiple contacts by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot contact IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with contact data
*/
public function getContactsByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('contacts', $crmIds, $fields);
}
/**
* @throws CompanyApiException
* @throws CrmException
*/
public function getAccountById(string $crmId, array $fields): array
{
try {
$company = $this->getNewInstance()->crm()->companies()->basicApi()->getById(
$crmId,
implode(',', $fields),
);
} catch (CompanyApiException $e) {
$this->log->info('[Hubspot] Failed to fetch account', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $company instanceof CompaniesWithAssociations) {
throw new CrmException('Account not found');
}
return [
'id' => $company->getId(),
'properties' => $company->getProperties(),
];
}
/**
* @throws ContactApiException
* @throws CrmException
*/
public function getContactById(string $crmId, array $fields): array
{
try {
$contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(
$crmId,
implode(',', $fields)
);
} catch (ContactApiException $e) {
$this->log->info('[Hubspot] Failed to fetch contact', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $contact instanceof ContactsWithAssociations) {
throw new CrmException('Contact not found');
}
return [
'id' => $contact->getId(),
'properties' => $contact->getProperties(),
];
}
/**
* This is email search request that Hubspot offers as GET (more generous quota)
*/
public function getContactByEmail(string $email, array $fields = []): array
{
try {
$contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(
$email,
implode(',', $fields),
null,
false,
'email'
);
return [
'id' => $contact->getId(),
'properties' => $contact->getProperties(),
];
} catch (ContactApiException $e) {
$this->log->info('[Hubspot] Failed to fetch contact', [
'email' => $email,
'reason' => $e->getMessage(),
]);
return [];
}
}
/**
* @throws CrmException
*/
public function fetchProperty(string $objectType, string $propertyId): Property
{
$result = $this->getNewInstance()->crm()->properties()->coreApi()->getByName($objectType, $propertyId);
if (! $result instanceof Property) {
$this->log->error('[Hubspot] Failed to fetch property', [
'object_type' => $objectType,
'property_id' => $propertyId,
'reason' => $result->getMessage(),
]);
throw new CrmException('Failed to fetch property');
}
return $result;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchPropertyOptions(string $objectType, string $propertyId): array
{
/** @var array<CrmFieldOption> */
return $this->fetchProperty($objectType, $propertyId)->getOptions();
}
/**
* @return array<array{id:string, label:string, deleted:bool}>
*/
public function fetchCallDispositions(): array
{
/** @var Response $response */
$response = $this->getInstance()->engagements()->getCallDispositions();
/**
* @var array<array{
* id:string,
* label:string,
* deleted: bool
* }>
*/
return $response->toArray();
}
/**
* @return array<CrmFieldOption>
*/
public function fetchOpportunityPipelineStages(): array
{
$stages = [];
$apiResponse = $this->getNewInstance()->crm()->pipelines()->pipelinesApi()->getAll('deals');
if ($apiResponse instanceof Error) {
$this->log->error('[Hubspot] Failed to fetch opportunity pipelines', [
'reason' => $apiResponse->getMessage(),
]);
return [];
}
foreach ($apiResponse->getResults() as $pipeline) {
$pipelineStages = array_map(
static function (PipelineStage $stage) {
return [
'id' => $stage->getId(),
'label' => $stage->getLabel(),
];
},
$pipeline->getStages()
);
$stages = array_merge($stages, $pipelineStages);
}
return $stages;
}
public function fetchOpportunityPipelines(): array
{
$pipelines = [];
try {
$apiResponse = $this->makeRequest('/crm/v3/pipelines/deals');
} catch (\Exception $e) {
$this->log->info('[Hubspot] Failed to fetch opportunity pipelines', [
'reason' => $e->getMessage(),
]);
return [];
}
$response = $apiResponse->toArray();
foreach ($response['results'] as $pipeline) {
$pipelines[] = [
'id' => $pipeline['id'],
'label' => $pipeline['label'],
];
}
return $pipelines;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchMeetingOutcomeFieldOptions(Field $field): array
{
return $field->getCrmProviderId() === 'meetingOutcome'
? $this->fetchMeetingOutcomeTypes()
: $this->fetchCallActivityTypes();
}
public function fetchMeetingOutcomeTypes(): array
{
return $this->extractMeetingTypeOptions(
'[URL_WITH_CREDENTIALS] Response $response */
$response = $this->getInstance()
->getClient()
->request('GET', $endpoint);
/**
* @var array<array{
* value: string,
* label: string,
* displayOrder: int
* }> $optionData
*/
$optionData = $response->toArray()['options'] ?? [];
$options = [];
foreach ($optionData as $item) {
$options[] = [
'id' => $item['value'],
'value' => $item['value'],
'label' => $item['label'],
'display_order' => $item['displayOrder'],
];
}
return $options;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchDispositionFieldOptions(): array
{
$options = [];
$dispositions = $this->fetchCallDispositions();
foreach ($dispositions as $disposition) {
if ($disposition['deleted'] !== false) {
continue;
}
$option['value'] = $disposition['id'];
$option['id'] = $disposition['id'];
$option['label'] = $disposition['label'];
$options[] = $option;
}
return $options;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchOpportunityFieldOptions(Field $field): array
{
if ($field->isStageField()) {
return $this->fetchOpportunityPipelineStages();
}
if ($field->isPipelineField()) {
return $this->fetchOpportunityPipelines();
}
return $this->fetchPropertyOptions('deals', $field->getCrmProviderId());
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function makeRequest(string $endpoint, $method = 'GET', $payload = [], ?string $queryString = null)
{
$endpoint = self::BASE_URL . $endpoint;
if ($method === 'GET') {
return $this->getInstance()->getClient()?->request(
method: $method,
endpoint: $endpoint,
query_string: $queryString
);
} else {
return $this->getInstance()->getClient()->request($method, $endpoint, [
'json' => ($payload),
]);
}
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function createMeeting(array $payload): Response
{
$endpoint = '/crm/v3/objects/meetings';
return $this->makeRequest($endpoint, 'POST', $payload);
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function updateMeeting(string $meetingId, array $payload): Response
{
$endpoint = '/crm/v3/objects/meetings/' . $meetingId;
return $this->makeRequest($endpoint, 'PATCH', $payload);
}
/**
* @throws \Exception
*/
public function createNote(
string $body,
string $ownerId,
int $timestamp,
string $objectId,
NoteObject $noteObject
): ?string {
try {
$noteInput = new SimplePublicObjectInput([
'properties' => [
'hs_note_body' => $body,
'hubspot_owner_id' => $ownerId,
'hs_timestamp' => $timestamp,
],
]);
// Create note
$note = $this->getNewInstance()->crm()->objects()->basicApi()->create('note', $noteInput);
$this->getNewInstance()->crm()->objects()->associationsApi()->create(
'note',
$note->getId(),
$this->getNoteObject($noteObject),
$objectId,
$this->getNoteAssociationType($noteObject),
);
return $note->getId();
} catch (\Exception $e) {
$this->log->error('[Hubspot] Failed to create note', [
'objectId' => $objectId,
'noteObject' => $noteObject->getObjectType(),
'reason' => $e->getMessage(),
]);
\Sentry::captureException($e);
}
return null;
}
public function updateEngagement(string $objectId, array $engagement, array $metadata): void
{
$this->getInstance()->engagements()->update($objectId, $engagement, $metadata);
}
public function getEngagementData(string $engagementId): array
{
$engagement = $this->getInstance()->engagements()->get($engagementId);
return $engagement->toArray();
}
public function createEngagement(array $engagement, array $associations, array $metadata): Response
{
return $this->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
}
public function isUnauthorizedException(\Exception $e): bool
{
// Check for specific HubSpot API exception types first
if ($e instanceof BadRequest) {
// BadRequest can contain 401 status codes
return $e->getCode() === 401;
}
// Check for HTTP client exceptions with status codes
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
$response = $e->getResponse();
if ($response !== null) {
return $response->getStatusCode() === 401;
}
}
// Check for Guzzle HTTP exceptions
if ($e instanceof \GuzzleHttp\Exception\ClientException) {
return $e->getCode() === 401;
}
// Fallback to string matching as last resort, but be more specific
$message = strtolower($e->getMessage());
return str_contains($message, '401 unauthorized') ||
str_contains($message, 'http 401') ||
str_contains($message, 'status code 401') ||
(preg_match('/\b401\b/', $message) && str_contains($message, 'unauthorized'));
}
/**
* Validates and refreshes the access token if needed before API requests.
* This ensures long-running processes don't fail due to token expiration.
*
* @throws SocialAccountTokenInvalidException
*/
public function ensureValidToken(): void
{
if ($this->oauthAccount === null) {
return;
}
$newToken = $this->tokenManager->ensureValidToken($this->oauthAccount);
if ($newToken !== null) {
$this->accessToken = $newToken;
}
}
public function getConfig()
{
return $this->config;
}
// returns only active (archived=false)
public function getOwners(): array
{
return $this->getNewInstance()->crm()->owners()->getAll();
}
/**
* @param bool $archived
*
* @return array<Owner>|[]
*/
public function getOwnersArchived(bool $archived = true): array
{
$endpoint = '/crm/v3/owners';
$queryParams = [
'archived' => $archived ? 'true' : 'false',
];
$queryString = http_build_query($queryParams);
$owners = [];
try {
$response = $this->makeRequest(endpoint: $endpoint, queryString: $queryString);
$responseData = $response?->toArray();
foreach ($responseData['results'] as $result) {
try {
$owners[] = Owner::create($result);
} catch (Throwable $e) {
$this->log->error('[HubSpot] Failed to process owner data', [
'result' => $result,
'error' => $e->getMessage(),
]);
continue;
}
}
} catch (Throwable $e) {
$this->log->error('HubSpot] Failed to fetch owners', [
'archived' => $archived,
'error' => $e->getMessage(),
]);
return [];
}
return $owners;
}
public function getMeeting(string $engagementId): ObjectWithAssociations
{
return $this->getNewInstance()->crm()->objects()->basicApi()
->getById('meeting', $engagementId, null, 'contact,company,deal');
}
public function deleteEngagement(string $engagementId): void
{
$this->getInstance()->engagements()->delete((int) $engagementId);
}
public function getAssociationsData(array $ids, string $fromObject, string $toObject): array
{
$associationData = [];
$idChunks = array_chunk($ids, self::ASSOCIATIONS_BATCH_SIZE_LIMIT);
foreach ($idChunks as $idChunk) {
try {
$batchInput = new \HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId();
$batchInput->setInputs(array_map(function ($id) {
$publicObjectId = new \HubSpot\Client\Crm\Associations\Model\PublicObjectId();
$publicObjectId->setId($id);
return $publicObjectId;
}, $idChunk));
$associatedObjectsData = $this
->getNewInstance()
->crm()
->associations()
->batchApi()
->read($fromObject, $toObject, $batchInput);
if ($associatedObjectsData instanceof \HubSpot\Client\Crm\Associations\Model\BatchResponsePublicAssociationMulti) {
foreach ($associatedObjectsData->getResults() as $association) {
$from = $association->getFrom()->getId();
$toAssociations = $association->getTo();
if (! empty($toAssociations)) {
$associationData[$from] = array_map(function ($item) {
return $item->getId();
}, $toAssociations);
}
}
}
} catch (\Exception $e) {
$this->log->error('[Hubspot] Failed to fetch associations', [
'from_object' => $fromObject,
'to_object' => $toObject,
'reason' => $e->getMessage(),
]);
}
}
return $associationData;
}
/**
* @throws \Exception
*/
private function getNoteAssociationType(NoteObject $noteObject): string
{
return match($noteObject) {
NoteObject::Opportunity => 'note_to_deal',
NoteObject::Lead, NoteObject::Contact => 'note_to_contact', // or 'note_to_lead' if your portal supports it
NoteObject::Account => 'note_to_company',
NoteObject::Call, NoteObject::Event => throw new \Exception('Not supported'),
};
}
/**
* @throws \Exception
*/
private function getNoteObject(NoteObject $noteObject): string
{
return match($noteObject) {
NoteObject::Opportunity => 'deal',
NoteObject::Lead, NoteObject::Contact => 'contact',
NoteObject::Account => 'company',
NoteObject::Call, NoteObject::Event => throw new \Exception('Not supported'),
};
}
public function addAssociations(string $objectType, string $associationType, array $payload): Response
{
$endpoint = "/crm/v4/associations/$objectType/$associationType/batch/create";
return $this->makeRequest($endpoint, 'POST', $payload);
}
public function removeAssociations(string $objectType, string $associationType, array $payload): Response
{
$endpoint = "/crm/v4/associations/$objectType/$associationType/batch/archive";
return $this->makeRequest($endpoint, 'POST', $payload);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20539
|
891
|
11
|
2026-05-11T15:49:08.334857+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778514548334_m2.jpg...
|
PhpStorm
|
faVsco.js – Client.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
ClientTest
Run 'ClientTest'
Debug 'ClientTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
2
67
3
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use HubSpot\Client\Crm\Deals\ApiException as DealApiException;
use HubSpot\Client\Crm\Contacts\ApiException as ContactApiException;
use HubSpot\Client\Crm\Companies\ApiException as CompanyApiException;
use HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations as ContactsWithAssociations;
use HubSpot\Client\Crm\Companies\Model\SimplePublicObjectWithAssociations as CompaniesWithAssociations;
use HubSpot\Client\Crm\Deals\Model\SimplePublicObjectWithAssociations as DealWithAssociations;
use HubSpot\Client\Crm\Objects\Model\SimplePublicObjectInput;
use HubSpot\Client\Crm\Objects\Model\SimplePublicObjectWithAssociations as ObjectWithAssociations;
use HubSpot\Client\Crm\Pipelines\Model\Error;
use HubSpot\Client\Crm\Pipelines\Model\PipelineStage;
use HubSpot\Client\Crm\Properties\Model\Property;
use HubSpot\Discovery\Discovery;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Crm\Field;
use Jiminny\Services\Crm\BaseClient;
use Jiminny\Services\Crm\Hubspot\DTO\Response\Owner;
use Jiminny\Services\SocialAccountService;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
use SevenShores\Hubspot\Factory;
use SevenShores\Hubspot\Http\Response;
use Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService;
use Illuminate\Support\Facades\Redis;
use Throwable;
/**
* @phpstan-type CrmFieldOption array{id:string, label:string, value?:string}
*/
class Client extends BaseClient implements HubspotClientInterface
{
public const string MIN_API_VERSION = '2';
public const string BASE_URL = '[URL_WITH_CREDENTIALS] T
*
* @param callable(): T $apiCall The API call to execute
*
* @throws RateLimitException When rate limit is hit or cached rate limit is active
*
* @return T The result of the API call
*/
private function executeRequest(callable $apiCall)
{
$cacheKey = $this->getRateLimitCacheKey();
$cachedExpiresAt = Redis::get($cacheKey);
if (is_string($cachedExpiresAt) && is_numeric($cachedExpiresAt)) {
$remaining = max(1, (int) $cachedExpiresAt - time());
throw new RateLimitException(
'Hubspot rate limit (cached circuit-breaker)',
$remaining,
);
}
try {
return $apiCall();
} catch (Throwable $e) {
if ($this->isHubspotRateLimit($e)) {
$retryAfter = $this->parseRetryAfter($e);
// NX: only the first job to receive a 429 in this burst sets the key.
// Subsequent 429s in the same burst leave the TTL untouched so the
// window is not reset by every concurrent job hitting the limit.
Redis::set($cacheKey, (string) (time() + $retryAfter), ['nx', 'ex' => $retryAfter]);
$this->log->warning('[Hubspot] Received 429 from API', [
'team_id' => $this->config->team_id,
'config_id' => $this->config->getId(),
'retry_after' => $retryAfter,
'reason' => $e->getMessage(),
]);
throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);
}
throw $e;
}
}
private function getRateLimitCacheKey(): string
{
return sprintf('hubspot:ratelimit:config:%d', $this->config->getId());
}
private function isHubspotRateLimit(Throwable $e): bool
{
if ($e instanceof BadRequest
|| $e instanceof DealApiException
|| $e instanceof ContactApiException
|| $e instanceof CompanyApiException
|| $e instanceof \GuzzleHttp\Exception\RequestException
) {
return (int) $e->getCode() === 429;
}
return false;
}
private function parseRetryAfter(Throwable $e): int
{
if (method_exists($e, 'getResponseHeaders')) {
$headers = $e->getResponseHeaders() ?: [];
$value = $headers['Retry-After'] ?? $headers['retry-after'] ?? null;
if (is_array($value)) {
$value = $value[0] ?? null;
}
if (is_numeric($value)) {
return (int) $value;
}
}
$message = strtolower($e->getMessage());
if (str_contains($message, 'daily')) {
return 600;
}
if (str_contains($message, 'ten secondly')) {
return 10;
}
if (str_contains($message, 'secondly')) {
return 1;
}
$this->log->warning('[Hubspot] No retry-after header or known message, using default', [
'exception_class' => get_class($e),
'message' => $message,
]);
return 10;
}
public function getMinimumApiVersion(): string
{
return self::MIN_API_VERSION;
}
public function getInstance(): Factory
{
return new Factory([
'key' => $this->accessToken,
'oauth2' => true,
'base_url' => $this->baseUrl,
]);
}
public function getNewInstance(): Discovery
{
return \HubSpot\Factory::createWithAccessToken($this->accessToken);
}
/**
* Secondly and daily limits for Hubspot API
*
* Product Tier: Free & Starter | Professional & Enterprise | API add-on (any tier)
* Burst: 100/10 seconds | 150/10 seconds | 200/10 seconds
* Daily: 250,000 | 500,000 | 1,000,000
*
* Official documentation states: The search endpoints are rate limited to five requests per second.
* Since with 5 RPS were still hitting secondly rate limits we lowered it to 4
*/
public function getPaginatedData(array $payload, string $type, int $offset = 0): array
{
$total = 0;
$lastId = null;
$rows = [];
foreach ($this->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastId) as $row) {
$rows[] = $row;
}
return ['results' => $rows, 'total' => $total, 'last_record' => $lastId];
}
/**
* @throws HubspotException
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
*/
public function getPaginatedDataGenerator(
array $payload,
string $type,
int $offset = 0,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
return $this->paginationService->getPaginatedDataGenerator(
$this,
$payload,
$type,
$offset,
$total,
$lastRecordId
);
}
/**
* Execute a search request against HubSpot CRM objects with rate limiting.
*
* @param string $objectType The object type ('deals', 'companies', 'contacts', 'calls')
* @param array<string, mixed> $payload The search payload with filters, sorts, properties, etc.
*
* @throws RateLimitException When rate limit is hit
* @throws HubspotException On API errors
*
* @return array The search response with 'results', 'total', 'paging' keys
*/
public function search(string $objectType, array $payload): array
{
$endpoint = self::BASE_URL . "/crm/v3/objects/{$objectType}/search";
return $this->executeRequest(function () use ($endpoint, $payload) {
$response = $this->getInstance()->getClient()->request('POST', $endpoint, ['json' => $payload]);
return $response->toArray();
});
}
/**
* @throws DealApiException
* @throws CrmException
*/
public function getOpportunityById(string $crmId, array $fields): array
{
try {
$deal = $this->getNewInstance()->crm()->deals()->basicApi()->getById(
$crmId,
implode(',', $fields),
'companies,contacts'
);
} catch (DealApiException $e) {
$this->log->info('[Hubspot] Failed to fetch opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $deal instanceof DealWithAssociations) {
throw new CrmException('Deal not found');
}
return [
'id' => $deal->getId(),
'properties' => $deal->getProperties(),
'associations' => $deal->getAssociations(),
];
}
/**
* Generic batch read method for HubSpot objects
*
* @param string $objectType The object type ('deals', 'companies', 'contacts')
* @param array<string> $crmIds Array of HubSpot object IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with object data
*/
private function batchReadObjects(string $objectType, array $crmIds, array $fields): array
{
if (empty($crmIds)) {
return [];
}
$this->validateBatchSize($objectType, $crmIds);
$this->ensureValidToken();
try {
$batchConfig = $this->createBatchConfiguration($objectType);
$batchReadRequest = $this->prepareBatchRequest($batchConfig, $crmIds, $fields);
$response = $this->executeRequest(fn () => $batchConfig['api']->read($batchReadRequest));
$this->validateApiResponse($response, $objectType);
$results = $this->processApiResults($response);
$this->logBatchResults($objectType, $crmIds, $results);
return $results;
} catch (RateLimitException $e) {
throw $e;
} catch (\Throwable $e) {
$this->handleBatchError($e, $objectType, $crmIds);
}
}
private function validateBatchSize(string $objectType, array $crmIds): void
{
if (count($crmIds) > 100) {
throw new \InvalidArgumentException("Batch size cannot exceed 100 {$objectType}");
}
}
private function createBatchConfiguration(string $objectType): array
{
$configurations = [
'deals' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Deals\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Deals\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->deals()->batchApi(),
],
'companies' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Companies\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Companies\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->companies()->batchApi(),
],
'contacts' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Contacts\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->contacts()->batchApi(),
],
];
if (! isset($configurations[$objectType])) {
throw new \InvalidArgumentException("Unsupported object type: {$objectType}");
}
return $configurations[$objectType];
}
private function prepareBatchRequest(array $batchConfig, array $crmIds, array $fields): object
{
$batchReadRequest = $batchConfig['batchReadRequest'];
$inputClass = $batchConfig['inputClass'];
$inputs = array_map(function ($crmId) use ($inputClass) {
$input = new $inputClass();
$input->setId($crmId);
return $input;
}, $crmIds);
$batchReadRequest->setInputs($inputs);
$batchReadRequest->setProperties($fields);
return $batchReadRequest;
}
private function validateApiResponse($response, string $objectType): void
{
if (! $response) {
throw new CrmException("HubSpot API returned null response for {$objectType} batch read");
}
}
private function processApiResults($response): array
{
$results = [];
$responseResults = $response->getResults();
if ($responseResults) {
foreach ($responseResults as $object) {
if ($object && $object->getId()) {
$results[$object->getId()] = [
'id' => $object->getId(),
'properties' => $object->getProperties() ?: [],
];
}
}
}
return $results;
}
private function logBatchResults(string $objectType, array $crmIds, array $results): void
{
$this->log->info("[HubSpot] Batch fetched {$objectType}", [
'requested_count' => count($crmIds),
'returned_count' => count($results),
'crm_ids' => $crmIds,
]);
}
private function handleBatchError(\Throwable $e, string $objectType, array $crmIds): void
{
$errorMessage = $e->getMessage() ?: 'Unknown error';
$errorTrace = $e->getTraceAsString() ?: 'No trace available';
$this->log->error("[HubSpot] Failed to batch fetch {$objectType}", [
'crm_ids' => $crmIds,
'error' => $errorMessage,
'trace' => $errorTrace,
]);
throw new CrmException("Failed to batch fetch {$objectType}: " . $errorMessage);
}
/**
* Batch read multiple opportunities by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot deal IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with opportunity data
*/
public function getOpportunitiesByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('deals', $crmIds, $fields);
}
/**
* Batch read multiple companies by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot company IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with company data
*/
public function getCompaniesByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('companies', $crmIds, $fields);
}
/**
* Batch read multiple contacts by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot contact IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with contact data
*/
public function getContactsByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('contacts', $crmIds, $fields);
}
/**
* @throws CompanyApiException
* @throws CrmException
*/
public function getAccountById(string $crmId, array $fields): array
{
try {
$company = $this->getNewInstance()->crm()->companies()->basicApi()->getById(
$crmId,
implode(',', $fields),
);
} catch (CompanyApiException $e) {
$this->log->info('[Hubspot] Failed to fetch account', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $company instanceof CompaniesWithAssociations) {
throw new CrmException('Account not found');
}
return [
'id' => $company->getId(),
'properties' => $company->getProperties(),
];
}
/**
* @throws ContactApiException
* @throws CrmException
*/
public function getContactById(string $crmId, array $fields): array
{
try {
$contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(
$crmId,
implode(',', $fields)
);
} catch (ContactApiException $e) {
$this->log->info('[Hubspot] Failed to fetch contact', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $contact instanceof ContactsWithAssociations) {
throw new CrmException('Contact not found');
}
return [
'id' => $contact->getId(),
'properties' => $contact->getProperties(),
];
}
/**
* This is email search request that Hubspot offers as GET (more generous quota)
*/
public function getContactByEmail(string $email, array $fields = []): array
{
try {
$contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(
$email,
implode(',', $fields),
null,
false,
'email'
);
return [
'id' => $contact->getId(),
'properties' => $contact->getProperties(),
];
} catch (ContactApiException $e) {
$this->log->info('[Hubspot] Failed to fetch contact', [
'email' => $email,
'reason' => $e->getMessage(),
]);
return [];
}
}
/**
* @throws CrmException
*/
public function fetchProperty(string $objectType, string $propertyId): Property
{
$result = $this->getNewInstance()->crm()->properties()->coreApi()->getByName($objectType, $propertyId);
if (! $result instanceof Property) {
$this->log->error('[Hubspot] Failed to fetch property', [
'object_type' => $objectType,
'property_id' => $propertyId,
'reason' => $result->getMessage(),
]);
throw new CrmException('Failed to fetch property');
}
return $result;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchPropertyOptions(string $objectType, string $propertyId): array
{
/** @var array<CrmFieldOption> */
return $this->fetchProperty($objectType, $propertyId)->getOptions();
}
/**
* @return array<array{id:string, label:string, deleted:bool}>
*/
public function fetchCallDispositions(): array
{
/** @var Response $response */
$response = $this->getInstance()->engagements()->getCallDispositions();
/**
* @var array<array{
* id:string,
* label:string,
* deleted: bool
* }>
*/
return $response->toArray();
}
/**
* @return array<CrmFieldOption>
*/
public function fetchOpportunityPipelineStages(): array
{
$stages = [];
$apiResponse = $this->getNewInstance()->crm()->pipelines()->pipelinesApi()->getAll('deals');
if ($apiResponse instanceof Error) {
$this->log->error('[Hubspot] Failed to fetch opportunity pipelines', [
'reason' => $apiResponse->getMessage(),
]);
return [];
}
foreach ($apiResponse->getResults() as $pipeline) {
$pipelineStages = array_map(
static function (PipelineStage $stage) {
return [
'id' => $stage->getId(),
'label' => $stage->getLabel(),
];
},
$pipeline->getStages()
);
$stages = array_merge($stages, $pipelineStages);
}
return $stages;
}
public function fetchOpportunityPipelines(): array
{
$pipelines = [];
try {
$apiResponse = $this->makeRequest('/crm/v3/pipelines/deals');
} catch (\Exception $e) {
$this->log->info('[Hubspot] Failed to fetch opportunity pipelines', [
'reason' => $e->getMessage(),
]);
return [];
}
$response = $apiResponse->toArray();
foreach ($response['results'] as $pipeline) {
$pipelines[] = [
'id' => $pipeline['id'],
'label' => $pipeline['label'],
];
}
return $pipelines;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchMeetingOutcomeFieldOptions(Field $field): array
{
return $field->getCrmProviderId() === 'meetingOutcome'
? $this->fetchMeetingOutcomeTypes()
: $this->fetchCallActivityTypes();
}
public function fetchMeetingOutcomeTypes(): array
{
return $this->extractMeetingTypeOptions(
'[URL_WITH_CREDENTIALS] Response $response */
$response = $this->getInstance()
->getClient()
->request('GET', $endpoint);
/**
* @var array<array{
* value: string,
* label: string,
* displayOrder: int
* }> $optionData
*/
$optionData = $response->toArray()['options'] ?? [];
$options = [];
foreach ($optionData as $item) {
$options[] = [
'id' => $item['value'],
'value' => $item['value'],
'label' => $item['label'],
'display_order' => $item['displayOrder'],
];
}
return $options;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchDispositionFieldOptions(): array
{
$options = [];
$dispositions = $this->fetchCallDispositions();
foreach ($dispositions as $disposition) {
if ($disposition['deleted'] !== false) {
continue;
}
$option['value'] = $disposition['id'];
$option['id'] = $disposition['id'];
$option['label'] = $disposition['label'];
$options[] = $option;
}
return $options;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchOpportunityFieldOptions(Field $field): array
{
if ($field->isStageField()) {
return $this->fetchOpportunityPipelineStages();
}
if ($field->isPipelineField()) {
return $this->fetchOpportunityPipelines();
}
return $this->fetchPropertyOptions('deals', $field->getCrmProviderId());
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function makeRequest(string $endpoint, $method = 'GET', $payload = [], ?string $queryString = null)
{
$endpoint = self::BASE_URL . $endpoint;
if ($method === 'GET') {
return $this->getInstance()->getClient()?->request(
method: $method,
endpoint: $endpoint,
query_string: $queryString
);
} else {
return $this->getInstance()->getClient()->request($method, $endpoint, [
'json' => ($payload),
]);
}
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function createMeeting(array $payload): Response
{
$endpoint = '/crm/v3/objects/meetings';
return $this->makeRequest($endpoint, 'POST', $payload);
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function updateMeeting(string $meetingId, array $payload): Response
{
$endpoint = '/crm/v3/objects/meetings/' . $meetingId;
return $this->makeRequest($endpoint, 'PATCH', $payload);
}
/**
* @throws \Exception
*/
public function createNote(
string $body,
string $ownerId,
int $timestamp,
string $objectId,
NoteObject $noteObject
): ?string {
try {
$noteInput = new SimplePublicObjectInput([
'properties' => [
'hs_note_body' => $body,
'hubspot_owner_id' => $ownerId,
'hs_timestamp' => $timestamp,
],
]);
// Create note
$note = $this->getNewInstance()->crm()->objects()->basicApi()->create('note', $noteInput);
$this->getNewInstance()->crm()->objects()->associationsApi()->create(
'note',
$note->getId(),
$this->getNoteObject($noteObject),
$objectId,
$this->getNoteAssociationType($noteObject),
);
return $note->getId();
} catch (\Exception $e) {
$this->log->error('[Hubspot] Failed to create note', [
'objectId' => $objectId,
'noteObject' => $noteObject->getObjectType(),
'reason' => $e->getMessage(),
]);
\Sentry::captureException($e);
}
return null;
}
public function updateEngagement(string $objectId, array $engagement, array $metadata): void
{
$this->getInstance()->engagements()->update($objectId, $engagement, $metadata);
}
public function getEngagementData(string $engagementId): array
{
$engagement = $this->getInstance()->engagements()->get($engagementId);
return $engagement->toArray();
}
public function createEngagement(array $engagement, array $associations, array $metadata): Response
{
return $this->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
}
public function isUnauthorizedException(\Exception $e): bool
{
// Check for specific HubSpot API exception types first
if ($e instanceof BadRequest) {
// BadRequest can contain 401 status codes
return $e->getCode() === 401;
}
// Check for HTTP client exceptions with status codes
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
$response = $e->getResponse();
if ($response !== null) {
return $response->getStatusCode() === 401;
}
}
// Check for Guzzle HTTP exceptions
if ($e instanceof \GuzzleHttp\Exception\ClientException) {
return $e->getCode() === 401;
}
// Fallback to string matching as last resort, but be more specific
$message = strtolower($e->getMessage());
return str_contains($message, '401 unauthorized') ||
str_contains($message, 'http 401') ||
str_contains($message, 'status code 401') ||
(preg_match('/\b401\b/', $message) && str_contains($message, 'unauthorized'));
}
/**
* Validates and refreshes the access token if needed before API requests.
* This ensures long-running processes don't fail due to token expiration.
*
* @throws SocialAccountTokenInvalidException
*/
public function ensureValidToken(): void
{
if ($this->oauthAccount === null) {
return;
}
$newToken = $this->tokenManager->ensureValidToken($this->oauthAccount);
if ($newToken !== null) {
$this->accessToken = $newToken;
}
}
public function getConfig()
{
return $this->config;
}
// returns only active (archived=false)
public function getOwners(): array
{
return $this->getNewInstance()->crm()->owners()->getAll();
}
/**
* @param bool $archived
*
* @return array<Owner>|[]
*/
public function getOwnersArchived(bool $archived = true): array
{
$endpoint = '/crm/v3/owners';
$queryParams = [
'archived' => $archived ? 'true' : 'false',
];
$queryString = http_build_query($queryParams);
$owners = [];
try {
$response = $this->makeRequest(endpoint: $endpoint, queryString: $queryString);
$responseData = $response?->toArray();
foreach ($responseData['results'] as $result) {
try {
$owners[] = Owner::create($result);
} catch (Throwable $e) {
$this->log->error('[HubSpot] Failed to process owner data', [
'result' => $result,
'error' => $e->getMessage(),
]);
continue;
}
}
} catch (Throwable $e) {
$this->log->error('HubSpot] Failed to fetch owners', [
'archived' => $archived,
'error' => $e->getMessage(),
]);
return [];
}
return $owners;
}
public function getMeeting(string $engagementId): ObjectWithAssociations
{
return $this->getNewInstance()->crm()->objects()->basicApi()
->getById('meeting', $engagementId, null, 'contact,company,deal');
}
public function deleteEngagement(string $engagementId): void
{
$this->getInstance()->engagements()->delete((int) $engagementId);
}
public function getAssociationsData(array $ids, string $fromObject, string $toObject): array
{
$associationData = [];
$idChunks = array_chunk($ids, self::ASSOCIATIONS_BATCH_SIZE_LIMIT);
foreach ($idChunks as $idChunk) {
try {
$batchInput = new \HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId();
$batchInput->setInputs(array_map(function ($id) {
$publicObjectId = new \HubSpot\Client\Crm\Associations\Model\PublicObjectId();
$publicObjectId->setId($id);
return $publicObjectId;
}, $idChunk));
$associatedObjectsData = $this
->getNewInstance()
->crm()
->associations()
->batchApi()
->read($fromObject, $toObject, $batchInput);
if ($associatedObjectsData instanceof \HubSpot\Client\Crm\Associations\Model\BatchResponsePublicAssociationMulti) {
foreach ($associatedObjectsData->getResults() as $association) {
$from = $association->getFrom()->getId();
$toAssociations = $association->getTo();
if (! empty($toAssociations)) {
$associationData[$from] = array_map(function ($item) {
return $item->getId();
}, $toAssociations);
}
}
}
} catch (\Exception $e) {
$this->log->error('[Hubspot] Failed to fetch associations', [
'from_object' => $fromObject,
'to_object' => $toObject,
'reason' => $e->getMessage(),
]);
}
}
return $associationData;
}
/**
* @throws \Exception
*/
private function getNoteAssociationType(NoteObject $noteObject): string
{
return match($noteObject) {
NoteObject::Opportunity => 'note_to_deal',
NoteObject::Lead, NoteObject::Contact => 'note_to_contact', // or 'note_to_lead' if your portal supports it
NoteObject::Account => 'note_to_company',
NoteObject::Call, NoteObject::Event => throw new \Exception('Not supported'),
};
}
/**
* @throws \Exception
*/
private function getNoteObject(NoteObject $noteObject): string
{
return match($noteObject) {
NoteObject::Opportunity => 'deal',
NoteObject::Lead, NoteObject::Contact => 'contact',
NoteObject::Account => 'company',
NoteObject::Call, NoteObject::Event => throw new \Exception('Not supported'),
};
}
public function addAssociations(string $objectType, string $associationType, array $payload): Response
{
$endpoint = "/crm/v4/associations/$objectType/$associationType/batch/create";
return $this->makeRequest($endpoint, 'POST', $payload);
}
public function removeAssociations(string $objectType, string $associationType, array $payload): Response
{
$endpoint = "/crm/v4/associations/$objectType/$associationType/batch/archive";
return $this->makeRequest($endpoint, 'POST', $payload);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8597075,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ClientTest","depth":6,"bounds":{"left":0.875,"top":0.019952115,"width":0.04055851,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ClientTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ClientTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"2","depth":4,"bounds":{"left":0.50731385,"top":0.17478053,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"67","depth":4,"bounds":{"left":0.51728725,"top":0.17478053,"width":0.009973404,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.52925533,"top":0.17478053,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.53889626,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.5462101,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse HubSpot\\Client\\Crm\\Deals\\ApiException as DealApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\ApiException as ContactApiException;\nuse HubSpot\\Client\\Crm\\Companies\\ApiException as CompanyApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations as ContactsWithAssociations;\nuse HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectWithAssociations as CompaniesWithAssociations;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectWithAssociations as DealWithAssociations;\nuse HubSpot\\Client\\Crm\\Objects\\Model\\SimplePublicObjectInput;\nuse HubSpot\\Client\\Crm\\Objects\\Model\\SimplePublicObjectWithAssociations as ObjectWithAssociations;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Error;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\PipelineStage;\nuse HubSpot\\Client\\Crm\\Properties\\Model\\Property;\nuse HubSpot\\Discovery\\Discovery;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Services\\Crm\\BaseClient;\nuse Jiminny\\Services\\Crm\\Hubspot\\DTO\\Response\\Owner;\nuse Jiminny\\Services\\SocialAccountService;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\nuse SevenShores\\Hubspot\\Factory;\nuse SevenShores\\Hubspot\\Http\\Response;\nuse Jiminny\\Services\\Crm\\Hubspot\\Pagination\\HubspotPaginationService;\nuse Illuminate\\Support\\Facades\\Redis;\nuse Throwable;\n\n/**\n * @phpstan-type CrmFieldOption array{id:string, label:string, value?:string}\n */\nclass Client extends BaseClient implements HubspotClientInterface\n{\n public const string MIN_API_VERSION = '2';\n\n public const string BASE_URL = 'https://api.hubapi.com';\n\n public const int ASSOCIATIONS_BATCH_SIZE_LIMIT = 1000;\n\n private HubspotPaginationService $paginationService;\n private HubspotTokenManager $tokenManager;\n\n public function __construct(\n SocialAccountService $socialAccountService,\n HubspotPaginationService $paginationService,\n HubspotTokenManager $tokenManager\n ) {\n parent::__construct($socialAccountService);\n $this->paginationService = $paginationService;\n $this->tokenManager = $tokenManager;\n\n $this->setBaseUrl(self::BASE_URL);\n $this->setVersion(self::MIN_API_VERSION);\n }\n\n /**\n * Execute a HubSpot API call with rate limit handling.\n *\n * On a 429, stores the absolute expiry timestamp with SET NX (first writer wins).\n * This means all subsequent jobs that also receive 429 in the same burst do not\n * reset the TTL — the window is anchored to the first 429, not the last.\n * Readers compute the remaining wait from the stored timestamp, so jobs that check\n * the cache near expiry are not delayed longer than necessary.\n *\n * @template T\n *\n * @param callable(): T $apiCall The API call to execute\n *\n * @throws RateLimitException When rate limit is hit or cached rate limit is active\n *\n * @return T The result of the API call\n */\n private function executeRequest(callable $apiCall)\n {\n $cacheKey = $this->getRateLimitCacheKey();\n\n $cachedExpiresAt = Redis::get($cacheKey);\n if (is_string($cachedExpiresAt) && is_numeric($cachedExpiresAt)) {\n $remaining = max(1, (int) $cachedExpiresAt - time());\n\n throw new RateLimitException(\n 'Hubspot rate limit (cached circuit-breaker)',\n $remaining,\n );\n }\n\n try {\n return $apiCall();\n } catch (Throwable $e) {\n if ($this->isHubspotRateLimit($e)) {\n $retryAfter = $this->parseRetryAfter($e);\n\n // NX: only the first job to receive a 429 in this burst sets the key.\n // Subsequent 429s in the same burst leave the TTL untouched so the\n // window is not reset by every concurrent job hitting the limit.\n Redis::set($cacheKey, (string) (time() + $retryAfter), ['nx', 'ex' => $retryAfter]);\n\n $this->log->warning('[Hubspot] Received 429 from API', [\n 'team_id' => $this->config->team_id,\n 'config_id' => $this->config->getId(),\n 'retry_after' => $retryAfter,\n 'reason' => $e->getMessage(),\n ]);\n\n throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);\n }\n\n throw $e;\n }\n }\n\n private function getRateLimitCacheKey(): string\n {\n return sprintf('hubspot:ratelimit:config:%d', $this->config->getId());\n }\n\n private function isHubspotRateLimit(Throwable $e): bool\n {\n if ($e instanceof BadRequest\n || $e instanceof DealApiException\n || $e instanceof ContactApiException\n || $e instanceof CompanyApiException\n || $e instanceof \\GuzzleHttp\\Exception\\RequestException\n ) {\n return (int) $e->getCode() === 429;\n }\n\n return false;\n }\n\n private function parseRetryAfter(Throwable $e): int\n {\n if (method_exists($e, 'getResponseHeaders')) {\n $headers = $e->getResponseHeaders() ?: [];\n $value = $headers['Retry-After'] ?? $headers['retry-after'] ?? null;\n if (is_array($value)) {\n $value = $value[0] ?? null;\n }\n if (is_numeric($value)) {\n return (int) $value;\n }\n }\n\n $message = strtolower($e->getMessage());\n\n if (str_contains($message, 'daily')) {\n return 600;\n }\n if (str_contains($message, 'ten secondly')) {\n return 10;\n }\n if (str_contains($message, 'secondly')) {\n return 1;\n }\n\n $this->log->warning('[Hubspot] No retry-after header or known message, using default', [\n 'exception_class' => get_class($e),\n 'message' => $message,\n ]);\n\n return 10;\n }\n\n public function getMinimumApiVersion(): string\n {\n return self::MIN_API_VERSION;\n }\n\n public function getInstance(): Factory\n {\n return new Factory([\n 'key' => $this->accessToken,\n 'oauth2' => true,\n 'base_url' => $this->baseUrl,\n ]);\n }\n\n public function getNewInstance(): Discovery\n {\n return \\HubSpot\\Factory::createWithAccessToken($this->accessToken);\n }\n\n /**\n * Secondly and daily limits for Hubspot API\n *\n * Product Tier: Free & Starter | Professional & Enterprise | API add-on (any tier)\n * Burst: 100/10 seconds | 150/10 seconds | 200/10 seconds\n * Daily: 250,000 | 500,000 | 1,000,000\n *\n * Official documentation states: The search endpoints are rate limited to five requests per second.\n * Since with 5 RPS were still hitting secondly rate limits we lowered it to 4\n */\n public function getPaginatedData(array $payload, string $type, int $offset = 0): array\n {\n $total = 0;\n $lastId = null;\n $rows = [];\n foreach ($this->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastId) as $row) {\n $rows[] = $row;\n }\n\n return ['results' => $rows, 'total' => $total, 'last_record' => $lastId];\n }\n\n /**\n * @throws HubspotException\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n */\n public function getPaginatedDataGenerator(\n array $payload,\n string $type,\n int $offset = 0,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n return $this->paginationService->getPaginatedDataGenerator(\n $this,\n $payload,\n $type,\n $offset,\n $total,\n $lastRecordId\n );\n }\n\n /**\n * Execute a search request against HubSpot CRM objects with rate limiting.\n *\n * @param string $objectType The object type ('deals', 'companies', 'contacts', 'calls')\n * @param array<string, mixed> $payload The search payload with filters, sorts, properties, etc.\n *\n * @throws RateLimitException When rate limit is hit\n * @throws HubspotException On API errors\n *\n * @return array The search response with 'results', 'total', 'paging' keys\n */\n public function search(string $objectType, array $payload): array\n {\n $endpoint = self::BASE_URL . \"/crm/v3/objects/{$objectType}/search\";\n\n return $this->executeRequest(function () use ($endpoint, $payload) {\n $response = $this->getInstance()->getClient()->request('POST', $endpoint, ['json' => $payload]);\n\n return $response->toArray();\n });\n }\n\n /**\n * @throws DealApiException\n * @throws CrmException\n */\n public function getOpportunityById(string $crmId, array $fields): array\n {\n try {\n $deal = $this->getNewInstance()->crm()->deals()->basicApi()->getById(\n $crmId,\n implode(',', $fields),\n 'companies,contacts'\n );\n } catch (DealApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $deal instanceof DealWithAssociations) {\n throw new CrmException('Deal not found');\n }\n\n return [\n 'id' => $deal->getId(),\n 'properties' => $deal->getProperties(),\n 'associations' => $deal->getAssociations(),\n ];\n }\n\n /**\n * Generic batch read method for HubSpot objects\n *\n * @param string $objectType The object type ('deals', 'companies', 'contacts')\n * @param array<string> $crmIds Array of HubSpot object IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with object data\n */\n private function batchReadObjects(string $objectType, array $crmIds, array $fields): array\n {\n if (empty($crmIds)) {\n return [];\n }\n\n $this->validateBatchSize($objectType, $crmIds);\n $this->ensureValidToken();\n\n try {\n $batchConfig = $this->createBatchConfiguration($objectType);\n $batchReadRequest = $this->prepareBatchRequest($batchConfig, $crmIds, $fields);\n\n $response = $this->executeRequest(fn () => $batchConfig['api']->read($batchReadRequest));\n\n $this->validateApiResponse($response, $objectType);\n\n $results = $this->processApiResults($response);\n $this->logBatchResults($objectType, $crmIds, $results);\n\n return $results;\n } catch (RateLimitException $e) {\n throw $e;\n } catch (\\Throwable $e) {\n $this->handleBatchError($e, $objectType, $crmIds);\n }\n }\n\n private function validateBatchSize(string $objectType, array $crmIds): void\n {\n if (count($crmIds) > 100) {\n throw new \\InvalidArgumentException(\"Batch size cannot exceed 100 {$objectType}\");\n }\n }\n\n private function createBatchConfiguration(string $objectType): array\n {\n $configurations = [\n 'deals' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Deals\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->deals()->batchApi(),\n ],\n 'companies' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Companies\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->companies()->batchApi(),\n ],\n 'contacts' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Contacts\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->contacts()->batchApi(),\n ],\n ];\n\n if (! isset($configurations[$objectType])) {\n throw new \\InvalidArgumentException(\"Unsupported object type: {$objectType}\");\n }\n\n return $configurations[$objectType];\n }\n\n private function prepareBatchRequest(array $batchConfig, array $crmIds, array $fields): object\n {\n $batchReadRequest = $batchConfig['batchReadRequest'];\n $inputClass = $batchConfig['inputClass'];\n\n $inputs = array_map(function ($crmId) use ($inputClass) {\n $input = new $inputClass();\n $input->setId($crmId);\n\n return $input;\n }, $crmIds);\n\n $batchReadRequest->setInputs($inputs);\n $batchReadRequest->setProperties($fields);\n\n return $batchReadRequest;\n }\n\n private function validateApiResponse($response, string $objectType): void\n {\n if (! $response) {\n throw new CrmException(\"HubSpot API returned null response for {$objectType} batch read\");\n }\n }\n\n private function processApiResults($response): array\n {\n $results = [];\n $responseResults = $response->getResults();\n\n if ($responseResults) {\n foreach ($responseResults as $object) {\n if ($object && $object->getId()) {\n $results[$object->getId()] = [\n 'id' => $object->getId(),\n 'properties' => $object->getProperties() ?: [],\n ];\n }\n }\n }\n\n return $results;\n }\n\n private function logBatchResults(string $objectType, array $crmIds, array $results): void\n {\n $this->log->info(\"[HubSpot] Batch fetched {$objectType}\", [\n 'requested_count' => count($crmIds),\n 'returned_count' => count($results),\n 'crm_ids' => $crmIds,\n ]);\n }\n\n private function handleBatchError(\\Throwable $e, string $objectType, array $crmIds): void\n {\n $errorMessage = $e->getMessage() ?: 'Unknown error';\n $errorTrace = $e->getTraceAsString() ?: 'No trace available';\n\n $this->log->error(\"[HubSpot] Failed to batch fetch {$objectType}\", [\n 'crm_ids' => $crmIds,\n 'error' => $errorMessage,\n 'trace' => $errorTrace,\n ]);\n\n throw new CrmException(\"Failed to batch fetch {$objectType}: \" . $errorMessage);\n }\n\n /**\n * Batch read multiple opportunities by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot deal IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with opportunity data\n */\n public function getOpportunitiesByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('deals', $crmIds, $fields);\n }\n\n /**\n * Batch read multiple companies by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot company IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with company data\n */\n public function getCompaniesByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('companies', $crmIds, $fields);\n }\n\n /**\n * Batch read multiple contacts by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot contact IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with contact data\n */\n public function getContactsByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('contacts', $crmIds, $fields);\n }\n\n /**\n * @throws CompanyApiException\n * @throws CrmException\n */\n public function getAccountById(string $crmId, array $fields): array\n {\n try {\n $company = $this->getNewInstance()->crm()->companies()->basicApi()->getById(\n $crmId,\n implode(',', $fields),\n );\n } catch (CompanyApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch account', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $company instanceof CompaniesWithAssociations) {\n throw new CrmException('Account not found');\n }\n\n return [\n 'id' => $company->getId(),\n 'properties' => $company->getProperties(),\n ];\n }\n\n /**\n * @throws ContactApiException\n * @throws CrmException\n */\n public function getContactById(string $crmId, array $fields): array\n {\n try {\n $contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(\n $crmId,\n implode(',', $fields)\n );\n } catch (ContactApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch contact', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $contact instanceof ContactsWithAssociations) {\n throw new CrmException('Contact not found');\n }\n\n return [\n 'id' => $contact->getId(),\n 'properties' => $contact->getProperties(),\n ];\n }\n\n /**\n * This is email search request that Hubspot offers as GET (more generous quota)\n */\n public function getContactByEmail(string $email, array $fields = []): array\n {\n try {\n $contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(\n $email,\n implode(',', $fields),\n null,\n false,\n 'email'\n );\n\n return [\n 'id' => $contact->getId(),\n 'properties' => $contact->getProperties(),\n ];\n } catch (ContactApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch contact', [\n 'email' => $email,\n 'reason' => $e->getMessage(),\n ]);\n\n return [];\n }\n }\n\n /**\n * @throws CrmException\n */\n public function fetchProperty(string $objectType, string $propertyId): Property\n {\n $result = $this->getNewInstance()->crm()->properties()->coreApi()->getByName($objectType, $propertyId);\n\n if (! $result instanceof Property) {\n $this->log->error('[Hubspot] Failed to fetch property', [\n 'object_type' => $objectType,\n 'property_id' => $propertyId,\n 'reason' => $result->getMessage(),\n ]);\n\n throw new CrmException('Failed to fetch property');\n }\n\n return $result;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchPropertyOptions(string $objectType, string $propertyId): array\n {\n /** @var array<CrmFieldOption> */\n return $this->fetchProperty($objectType, $propertyId)->getOptions();\n }\n\n /**\n * @return array<array{id:string, label:string, deleted:bool}>\n */\n public function fetchCallDispositions(): array\n {\n /** @var Response $response */\n $response = $this->getInstance()->engagements()->getCallDispositions();\n\n /**\n * @var array<array{\n * id:string,\n * label:string,\n * deleted: bool\n * }>\n */\n return $response->toArray();\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchOpportunityPipelineStages(): array\n {\n $stages = [];\n $apiResponse = $this->getNewInstance()->crm()->pipelines()->pipelinesApi()->getAll('deals');\n\n if ($apiResponse instanceof Error) {\n $this->log->error('[Hubspot] Failed to fetch opportunity pipelines', [\n 'reason' => $apiResponse->getMessage(),\n ]);\n\n return [];\n }\n\n foreach ($apiResponse->getResults() as $pipeline) {\n $pipelineStages = array_map(\n static function (PipelineStage $stage) {\n return [\n 'id' => $stage->getId(),\n 'label' => $stage->getLabel(),\n ];\n },\n $pipeline->getStages()\n );\n\n $stages = array_merge($stages, $pipelineStages);\n }\n\n return $stages;\n }\n\n public function fetchOpportunityPipelines(): array\n {\n $pipelines = [];\n\n try {\n $apiResponse = $this->makeRequest('/crm/v3/pipelines/deals');\n } catch (\\Exception $e) {\n $this->log->info('[Hubspot] Failed to fetch opportunity pipelines', [\n 'reason' => $e->getMessage(),\n ]);\n\n return [];\n }\n\n $response = $apiResponse->toArray();\n\n foreach ($response['results'] as $pipeline) {\n $pipelines[] = [\n 'id' => $pipeline['id'],\n 'label' => $pipeline['label'],\n ];\n }\n\n return $pipelines;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchMeetingOutcomeFieldOptions(Field $field): array\n {\n return $field->getCrmProviderId() === 'meetingOutcome'\n ? $this->fetchMeetingOutcomeTypes()\n : $this->fetchCallActivityTypes();\n }\n\n public function fetchMeetingOutcomeTypes(): array\n {\n return $this->extractMeetingTypeOptions(\n 'https://api.hubapi.com/crm/v3/properties/meeting/hs_meeting_outcome'\n );\n }\n\n public function fetchCallActivityTypes(): array\n {\n return $this->extractMeetingTypeOptions(\n 'https://api.hubapi.com/crm/v3/properties/call/hs_activity_type'\n );\n }\n\n private function extractMeetingTypeOptions(string $endpoint): array\n {\n /** @var Response $response */\n $response = $this->getInstance()\n ->getClient()\n ->request('GET', $endpoint);\n\n /**\n * @var array<array{\n * value: string,\n * label: string,\n * displayOrder: int\n * }> $optionData\n */\n $optionData = $response->toArray()['options'] ?? [];\n\n $options = [];\n foreach ($optionData as $item) {\n $options[] = [\n 'id' => $item['value'],\n 'value' => $item['value'],\n 'label' => $item['label'],\n 'display_order' => $item['displayOrder'],\n ];\n }\n\n return $options;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchDispositionFieldOptions(): array\n {\n $options = [];\n\n $dispositions = $this->fetchCallDispositions();\n\n foreach ($dispositions as $disposition) {\n if ($disposition['deleted'] !== false) {\n continue;\n }\n\n $option['value'] = $disposition['id'];\n $option['id'] = $disposition['id'];\n $option['label'] = $disposition['label'];\n\n $options[] = $option;\n }\n\n return $options;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchOpportunityFieldOptions(Field $field): array\n {\n if ($field->isStageField()) {\n return $this->fetchOpportunityPipelineStages();\n }\n\n if ($field->isPipelineField()) {\n return $this->fetchOpportunityPipelines();\n }\n\n return $this->fetchPropertyOptions('deals', $field->getCrmProviderId());\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function makeRequest(string $endpoint, $method = 'GET', $payload = [], ?string $queryString = null)\n {\n $endpoint = self::BASE_URL . $endpoint;\n\n if ($method === 'GET') {\n return $this->getInstance()->getClient()?->request(\n method: $method,\n endpoint: $endpoint,\n query_string: $queryString\n );\n } else {\n return $this->getInstance()->getClient()->request($method, $endpoint, [\n 'json' => ($payload),\n ]);\n }\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function createMeeting(array $payload): Response\n {\n $endpoint = '/crm/v3/objects/meetings';\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function updateMeeting(string $meetingId, array $payload): Response\n {\n $endpoint = '/crm/v3/objects/meetings/' . $meetingId;\n\n return $this->makeRequest($endpoint, 'PATCH', $payload);\n }\n\n /**\n * @throws \\Exception\n */\n public function createNote(\n string $body,\n string $ownerId,\n int $timestamp,\n string $objectId,\n NoteObject $noteObject\n ): ?string {\n try {\n $noteInput = new SimplePublicObjectInput([\n 'properties' => [\n 'hs_note_body' => $body,\n 'hubspot_owner_id' => $ownerId,\n 'hs_timestamp' => $timestamp,\n ],\n ]);\n\n // Create note\n $note = $this->getNewInstance()->crm()->objects()->basicApi()->create('note', $noteInput);\n\n $this->getNewInstance()->crm()->objects()->associationsApi()->create(\n 'note',\n $note->getId(),\n $this->getNoteObject($noteObject),\n $objectId,\n $this->getNoteAssociationType($noteObject),\n );\n\n return $note->getId();\n } catch (\\Exception $e) {\n $this->log->error('[Hubspot] Failed to create note', [\n 'objectId' => $objectId,\n 'noteObject' => $noteObject->getObjectType(),\n 'reason' => $e->getMessage(),\n ]);\n\n \\Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function updateEngagement(string $objectId, array $engagement, array $metadata): void\n {\n $this->getInstance()->engagements()->update($objectId, $engagement, $metadata);\n }\n\n public function getEngagementData(string $engagementId): array\n {\n $engagement = $this->getInstance()->engagements()->get($engagementId);\n\n return $engagement->toArray();\n }\n\n public function createEngagement(array $engagement, array $associations, array $metadata): Response\n {\n return $this->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n }\n\n public function isUnauthorizedException(\\Exception $e): bool\n {\n // Check for specific HubSpot API exception types first\n if ($e instanceof BadRequest) {\n // BadRequest can contain 401 status codes\n return $e->getCode() === 401;\n }\n\n // Check for HTTP client exceptions with status codes\n if ($e instanceof \\GuzzleHttp\\Exception\\RequestException && $e->hasResponse()) {\n $response = $e->getResponse();\n if ($response !== null) {\n return $response->getStatusCode() === 401;\n }\n }\n\n // Check for Guzzle HTTP exceptions\n if ($e instanceof \\GuzzleHttp\\Exception\\ClientException) {\n return $e->getCode() === 401;\n }\n\n // Fallback to string matching as last resort, but be more specific\n $message = strtolower($e->getMessage());\n\n return str_contains($message, '401 unauthorized') ||\n str_contains($message, 'http 401') ||\n str_contains($message, 'status code 401') ||\n (preg_match('/\\b401\\b/', $message) && str_contains($message, 'unauthorized'));\n }\n\n /**\n * Validates and refreshes the access token if needed before API requests.\n * This ensures long-running processes don't fail due to token expiration.\n *\n * @throws SocialAccountTokenInvalidException\n */\n public function ensureValidToken(): void\n {\n if ($this->oauthAccount === null) {\n return;\n }\n\n $newToken = $this->tokenManager->ensureValidToken($this->oauthAccount);\n if ($newToken !== null) {\n $this->accessToken = $newToken;\n }\n }\n\n public function getConfig()\n {\n return $this->config;\n }\n\n // returns only active (archived=false)\n public function getOwners(): array\n {\n return $this->getNewInstance()->crm()->owners()->getAll();\n }\n\n /**\n * @param bool $archived\n *\n * @return array<Owner>|[]\n */\n public function getOwnersArchived(bool $archived = true): array\n {\n $endpoint = '/crm/v3/owners';\n $queryParams = [\n 'archived' => $archived ? 'true' : 'false',\n ];\n $queryString = http_build_query($queryParams);\n\n $owners = [];\n\n try {\n $response = $this->makeRequest(endpoint: $endpoint, queryString: $queryString);\n $responseData = $response?->toArray();\n\n foreach ($responseData['results'] as $result) {\n try {\n $owners[] = Owner::create($result);\n } catch (Throwable $e) {\n $this->log->error('[HubSpot] Failed to process owner data', [\n 'result' => $result,\n 'error' => $e->getMessage(),\n ]);\n\n continue;\n }\n }\n } catch (Throwable $e) {\n $this->log->error('HubSpot] Failed to fetch owners', [\n 'archived' => $archived,\n 'error' => $e->getMessage(),\n ]);\n\n return [];\n }\n\n return $owners;\n }\n\n public function getMeeting(string $engagementId): ObjectWithAssociations\n {\n return $this->getNewInstance()->crm()->objects()->basicApi()\n ->getById('meeting', $engagementId, null, 'contact,company,deal');\n }\n\n public function deleteEngagement(string $engagementId): void\n {\n $this->getInstance()->engagements()->delete((int) $engagementId);\n }\n\n public function getAssociationsData(array $ids, string $fromObject, string $toObject): array\n {\n $associationData = [];\n $idChunks = array_chunk($ids, self::ASSOCIATIONS_BATCH_SIZE_LIMIT);\n\n foreach ($idChunks as $idChunk) {\n try {\n $batchInput = new \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId();\n $batchInput->setInputs(array_map(function ($id) {\n $publicObjectId = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicObjectId();\n $publicObjectId->setId($id);\n\n return $publicObjectId;\n }, $idChunk));\n\n $associatedObjectsData = $this\n ->getNewInstance()\n ->crm()\n ->associations()\n ->batchApi()\n ->read($fromObject, $toObject, $batchInput);\n\n if ($associatedObjectsData instanceof \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchResponsePublicAssociationMulti) {\n foreach ($associatedObjectsData->getResults() as $association) {\n $from = $association->getFrom()->getId();\n $toAssociations = $association->getTo();\n\n if (! empty($toAssociations)) {\n $associationData[$from] = array_map(function ($item) {\n return $item->getId();\n }, $toAssociations);\n }\n }\n }\n } catch (\\Exception $e) {\n $this->log->error('[Hubspot] Failed to fetch associations', [\n 'from_object' => $fromObject,\n 'to_object' => $toObject,\n 'reason' => $e->getMessage(),\n ]);\n }\n }\n\n return $associationData;\n }\n\n /**\n * @throws \\Exception\n */\n private function getNoteAssociationType(NoteObject $noteObject): string\n {\n return match($noteObject) {\n NoteObject::Opportunity => 'note_to_deal',\n NoteObject::Lead, NoteObject::Contact => 'note_to_contact', // or 'note_to_lead' if your portal supports it\n NoteObject::Account => 'note_to_company',\n NoteObject::Call, NoteObject::Event => throw new \\Exception('Not supported'),\n };\n }\n\n /**\n * @throws \\Exception\n */\n private function getNoteObject(NoteObject $noteObject): string\n {\n return match($noteObject) {\n NoteObject::Opportunity => 'deal',\n NoteObject::Lead, NoteObject::Contact => 'contact',\n NoteObject::Account => 'company',\n NoteObject::Call, NoteObject::Event => throw new \\Exception('Not supported'),\n };\n }\n\n public function addAssociations(string $objectType, string $associationType, array $payload): Response\n {\n $endpoint = \"/crm/v4/associations/$objectType/$associationType/batch/create\";\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n\n public function removeAssociations(string $objectType, string $associationType, array $payload): Response\n {\n $endpoint = \"/crm/v4/associations/$objectType/$associationType/batch/archive\";\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse HubSpot\\Client\\Crm\\Deals\\ApiException as DealApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\ApiException as ContactApiException;\nuse HubSpot\\Client\\Crm\\Companies\\ApiException as CompanyApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations as ContactsWithAssociations;\nuse HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectWithAssociations as CompaniesWithAssociations;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectWithAssociations as DealWithAssociations;\nuse HubSpot\\Client\\Crm\\Objects\\Model\\SimplePublicObjectInput;\nuse HubSpot\\Client\\Crm\\Objects\\Model\\SimplePublicObjectWithAssociations as ObjectWithAssociations;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Error;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\PipelineStage;\nuse HubSpot\\Client\\Crm\\Properties\\Model\\Property;\nuse HubSpot\\Discovery\\Discovery;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Services\\Crm\\BaseClient;\nuse Jiminny\\Services\\Crm\\Hubspot\\DTO\\Response\\Owner;\nuse Jiminny\\Services\\SocialAccountService;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\nuse SevenShores\\Hubspot\\Factory;\nuse SevenShores\\Hubspot\\Http\\Response;\nuse Jiminny\\Services\\Crm\\Hubspot\\Pagination\\HubspotPaginationService;\nuse Illuminate\\Support\\Facades\\Redis;\nuse Throwable;\n\n/**\n * @phpstan-type CrmFieldOption array{id:string, label:string, value?:string}\n */\nclass Client extends BaseClient implements HubspotClientInterface\n{\n public const string MIN_API_VERSION = '2';\n\n public const string BASE_URL = 'https://api.hubapi.com';\n\n public const int ASSOCIATIONS_BATCH_SIZE_LIMIT = 1000;\n\n private HubspotPaginationService $paginationService;\n private HubspotTokenManager $tokenManager;\n\n public function __construct(\n SocialAccountService $socialAccountService,\n HubspotPaginationService $paginationService,\n HubspotTokenManager $tokenManager\n ) {\n parent::__construct($socialAccountService);\n $this->paginationService = $paginationService;\n $this->tokenManager = $tokenManager;\n\n $this->setBaseUrl(self::BASE_URL);\n $this->setVersion(self::MIN_API_VERSION);\n }\n\n /**\n * Execute a HubSpot API call with rate limit handling.\n *\n * On a 429, stores the absolute expiry timestamp with SET NX (first writer wins).\n * This means all subsequent jobs that also receive 429 in the same burst do not\n * reset the TTL — the window is anchored to the first 429, not the last.\n * Readers compute the remaining wait from the stored timestamp, so jobs that check\n * the cache near expiry are not delayed longer than necessary.\n *\n * @template T\n *\n * @param callable(): T $apiCall The API call to execute\n *\n * @throws RateLimitException When rate limit is hit or cached rate limit is active\n *\n * @return T The result of the API call\n */\n private function executeRequest(callable $apiCall)\n {\n $cacheKey = $this->getRateLimitCacheKey();\n\n $cachedExpiresAt = Redis::get($cacheKey);\n if (is_string($cachedExpiresAt) && is_numeric($cachedExpiresAt)) {\n $remaining = max(1, (int) $cachedExpiresAt - time());\n\n throw new RateLimitException(\n 'Hubspot rate limit (cached circuit-breaker)',\n $remaining,\n );\n }\n\n try {\n return $apiCall();\n } catch (Throwable $e) {\n if ($this->isHubspotRateLimit($e)) {\n $retryAfter = $this->parseRetryAfter($e);\n\n // NX: only the first job to receive a 429 in this burst sets the key.\n // Subsequent 429s in the same burst leave the TTL untouched so the\n // window is not reset by every concurrent job hitting the limit.\n Redis::set($cacheKey, (string) (time() + $retryAfter), ['nx', 'ex' => $retryAfter]);\n\n $this->log->warning('[Hubspot] Received 429 from API', [\n 'team_id' => $this->config->team_id,\n 'config_id' => $this->config->getId(),\n 'retry_after' => $retryAfter,\n 'reason' => $e->getMessage(),\n ]);\n\n throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);\n }\n\n throw $e;\n }\n }\n\n private function getRateLimitCacheKey(): string\n {\n return sprintf('hubspot:ratelimit:config:%d', $this->config->getId());\n }\n\n private function isHubspotRateLimit(Throwable $e): bool\n {\n if ($e instanceof BadRequest\n || $e instanceof DealApiException\n || $e instanceof ContactApiException\n || $e instanceof CompanyApiException\n || $e instanceof \\GuzzleHttp\\Exception\\RequestException\n ) {\n return (int) $e->getCode() === 429;\n }\n\n return false;\n }\n\n private function parseRetryAfter(Throwable $e): int\n {\n if (method_exists($e, 'getResponseHeaders')) {\n $headers = $e->getResponseHeaders() ?: [];\n $value = $headers['Retry-After'] ?? $headers['retry-after'] ?? null;\n if (is_array($value)) {\n $value = $value[0] ?? null;\n }\n if (is_numeric($value)) {\n return (int) $value;\n }\n }\n\n $message = strtolower($e->getMessage());\n\n if (str_contains($message, 'daily')) {\n return 600;\n }\n if (str_contains($message, 'ten secondly')) {\n return 10;\n }\n if (str_contains($message, 'secondly')) {\n return 1;\n }\n\n $this->log->warning('[Hubspot] No retry-after header or known message, using default', [\n 'exception_class' => get_class($e),\n 'message' => $message,\n ]);\n\n return 10;\n }\n\n public function getMinimumApiVersion(): string\n {\n return self::MIN_API_VERSION;\n }\n\n public function getInstance(): Factory\n {\n return new Factory([\n 'key' => $this->accessToken,\n 'oauth2' => true,\n 'base_url' => $this->baseUrl,\n ]);\n }\n\n public function getNewInstance(): Discovery\n {\n return \\HubSpot\\Factory::createWithAccessToken($this->accessToken);\n }\n\n /**\n * Secondly and daily limits for Hubspot API\n *\n * Product Tier: Free & Starter | Professional & Enterprise | API add-on (any tier)\n * Burst: 100/10 seconds | 150/10 seconds | 200/10 seconds\n * Daily: 250,000 | 500,000 | 1,000,000\n *\n * Official documentation states: The search endpoints are rate limited to five requests per second.\n * Since with 5 RPS were still hitting secondly rate limits we lowered it to 4\n */\n public function getPaginatedData(array $payload, string $type, int $offset = 0): array\n {\n $total = 0;\n $lastId = null;\n $rows = [];\n foreach ($this->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastId) as $row) {\n $rows[] = $row;\n }\n\n return ['results' => $rows, 'total' => $total, 'last_record' => $lastId];\n }\n\n /**\n * @throws HubspotException\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n */\n public function getPaginatedDataGenerator(\n array $payload,\n string $type,\n int $offset = 0,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n return $this->paginationService->getPaginatedDataGenerator(\n $this,\n $payload,\n $type,\n $offset,\n $total,\n $lastRecordId\n );\n }\n\n /**\n * Execute a search request against HubSpot CRM objects with rate limiting.\n *\n * @param string $objectType The object type ('deals', 'companies', 'contacts', 'calls')\n * @param array<string, mixed> $payload The search payload with filters, sorts, properties, etc.\n *\n * @throws RateLimitException When rate limit is hit\n * @throws HubspotException On API errors\n *\n * @return array The search response with 'results', 'total', 'paging' keys\n */\n public function search(string $objectType, array $payload): array\n {\n $endpoint = self::BASE_URL . \"/crm/v3/objects/{$objectType}/search\";\n\n return $this->executeRequest(function () use ($endpoint, $payload) {\n $response = $this->getInstance()->getClient()->request('POST', $endpoint, ['json' => $payload]);\n\n return $response->toArray();\n });\n }\n\n /**\n * @throws DealApiException\n * @throws CrmException\n */\n public function getOpportunityById(string $crmId, array $fields): array\n {\n try {\n $deal = $this->getNewInstance()->crm()->deals()->basicApi()->getById(\n $crmId,\n implode(',', $fields),\n 'companies,contacts'\n );\n } catch (DealApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $deal instanceof DealWithAssociations) {\n throw new CrmException('Deal not found');\n }\n\n return [\n 'id' => $deal->getId(),\n 'properties' => $deal->getProperties(),\n 'associations' => $deal->getAssociations(),\n ];\n }\n\n /**\n * Generic batch read method for HubSpot objects\n *\n * @param string $objectType The object type ('deals', 'companies', 'contacts')\n * @param array<string> $crmIds Array of HubSpot object IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with object data\n */\n private function batchReadObjects(string $objectType, array $crmIds, array $fields): array\n {\n if (empty($crmIds)) {\n return [];\n }\n\n $this->validateBatchSize($objectType, $crmIds);\n $this->ensureValidToken();\n\n try {\n $batchConfig = $this->createBatchConfiguration($objectType);\n $batchReadRequest = $this->prepareBatchRequest($batchConfig, $crmIds, $fields);\n\n $response = $this->executeRequest(fn () => $batchConfig['api']->read($batchReadRequest));\n\n $this->validateApiResponse($response, $objectType);\n\n $results = $this->processApiResults($response);\n $this->logBatchResults($objectType, $crmIds, $results);\n\n return $results;\n } catch (RateLimitException $e) {\n throw $e;\n } catch (\\Throwable $e) {\n $this->handleBatchError($e, $objectType, $crmIds);\n }\n }\n\n private function validateBatchSize(string $objectType, array $crmIds): void\n {\n if (count($crmIds) > 100) {\n throw new \\InvalidArgumentException(\"Batch size cannot exceed 100 {$objectType}\");\n }\n }\n\n private function createBatchConfiguration(string $objectType): array\n {\n $configurations = [\n 'deals' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Deals\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->deals()->batchApi(),\n ],\n 'companies' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Companies\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->companies()->batchApi(),\n ],\n 'contacts' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Contacts\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->contacts()->batchApi(),\n ],\n ];\n\n if (! isset($configurations[$objectType])) {\n throw new \\InvalidArgumentException(\"Unsupported object type: {$objectType}\");\n }\n\n return $configurations[$objectType];\n }\n\n private function prepareBatchRequest(array $batchConfig, array $crmIds, array $fields): object\n {\n $batchReadRequest = $batchConfig['batchReadRequest'];\n $inputClass = $batchConfig['inputClass'];\n\n $inputs = array_map(function ($crmId) use ($inputClass) {\n $input = new $inputClass();\n $input->setId($crmId);\n\n return $input;\n }, $crmIds);\n\n $batchReadRequest->setInputs($inputs);\n $batchReadRequest->setProperties($fields);\n\n return $batchReadRequest;\n }\n\n private function validateApiResponse($response, string $objectType): void\n {\n if (! $response) {\n throw new CrmException(\"HubSpot API returned null response for {$objectType} batch read\");\n }\n }\n\n private function processApiResults($response): array\n {\n $results = [];\n $responseResults = $response->getResults();\n\n if ($responseResults) {\n foreach ($responseResults as $object) {\n if ($object && $object->getId()) {\n $results[$object->getId()] = [\n 'id' => $object->getId(),\n 'properties' => $object->getProperties() ?: [],\n ];\n }\n }\n }\n\n return $results;\n }\n\n private function logBatchResults(string $objectType, array $crmIds, array $results): void\n {\n $this->log->info(\"[HubSpot] Batch fetched {$objectType}\", [\n 'requested_count' => count($crmIds),\n 'returned_count' => count($results),\n 'crm_ids' => $crmIds,\n ]);\n }\n\n private function handleBatchError(\\Throwable $e, string $objectType, array $crmIds): void\n {\n $errorMessage = $e->getMessage() ?: 'Unknown error';\n $errorTrace = $e->getTraceAsString() ?: 'No trace available';\n\n $this->log->error(\"[HubSpot] Failed to batch fetch {$objectType}\", [\n 'crm_ids' => $crmIds,\n 'error' => $errorMessage,\n 'trace' => $errorTrace,\n ]);\n\n throw new CrmException(\"Failed to batch fetch {$objectType}: \" . $errorMessage);\n }\n\n /**\n * Batch read multiple opportunities by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot deal IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with opportunity data\n */\n public function getOpportunitiesByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('deals', $crmIds, $fields);\n }\n\n /**\n * Batch read multiple companies by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot company IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with company data\n */\n public function getCompaniesByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('companies', $crmIds, $fields);\n }\n\n /**\n * Batch read multiple contacts by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot contact IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with contact data\n */\n public function getContactsByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('contacts', $crmIds, $fields);\n }\n\n /**\n * @throws CompanyApiException\n * @throws CrmException\n */\n public function getAccountById(string $crmId, array $fields): array\n {\n try {\n $company = $this->getNewInstance()->crm()->companies()->basicApi()->getById(\n $crmId,\n implode(',', $fields),\n );\n } catch (CompanyApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch account', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $company instanceof CompaniesWithAssociations) {\n throw new CrmException('Account not found');\n }\n\n return [\n 'id' => $company->getId(),\n 'properties' => $company->getProperties(),\n ];\n }\n\n /**\n * @throws ContactApiException\n * @throws CrmException\n */\n public function getContactById(string $crmId, array $fields): array\n {\n try {\n $contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(\n $crmId,\n implode(',', $fields)\n );\n } catch (ContactApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch contact', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $contact instanceof ContactsWithAssociations) {\n throw new CrmException('Contact not found');\n }\n\n return [\n 'id' => $contact->getId(),\n 'properties' => $contact->getProperties(),\n ];\n }\n\n /**\n * This is email search request that Hubspot offers as GET (more generous quota)\n */\n public function getContactByEmail(string $email, array $fields = []): array\n {\n try {\n $contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(\n $email,\n implode(',', $fields),\n null,\n false,\n 'email'\n );\n\n return [\n 'id' => $contact->getId(),\n 'properties' => $contact->getProperties(),\n ];\n } catch (ContactApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch contact', [\n 'email' => $email,\n 'reason' => $e->getMessage(),\n ]);\n\n return [];\n }\n }\n\n /**\n * @throws CrmException\n */\n public function fetchProperty(string $objectType, string $propertyId): Property\n {\n $result = $this->getNewInstance()->crm()->properties()->coreApi()->getByName($objectType, $propertyId);\n\n if (! $result instanceof Property) {\n $this->log->error('[Hubspot] Failed to fetch property', [\n 'object_type' => $objectType,\n 'property_id' => $propertyId,\n 'reason' => $result->getMessage(),\n ]);\n\n throw new CrmException('Failed to fetch property');\n }\n\n return $result;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchPropertyOptions(string $objectType, string $propertyId): array\n {\n /** @var array<CrmFieldOption> */\n return $this->fetchProperty($objectType, $propertyId)->getOptions();\n }\n\n /**\n * @return array<array{id:string, label:string, deleted:bool}>\n */\n public function fetchCallDispositions(): array\n {\n /** @var Response $response */\n $response = $this->getInstance()->engagements()->getCallDispositions();\n\n /**\n * @var array<array{\n * id:string,\n * label:string,\n * deleted: bool\n * }>\n */\n return $response->toArray();\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchOpportunityPipelineStages(): array\n {\n $stages = [];\n $apiResponse = $this->getNewInstance()->crm()->pipelines()->pipelinesApi()->getAll('deals');\n\n if ($apiResponse instanceof Error) {\n $this->log->error('[Hubspot] Failed to fetch opportunity pipelines', [\n 'reason' => $apiResponse->getMessage(),\n ]);\n\n return [];\n }\n\n foreach ($apiResponse->getResults() as $pipeline) {\n $pipelineStages = array_map(\n static function (PipelineStage $stage) {\n return [\n 'id' => $stage->getId(),\n 'label' => $stage->getLabel(),\n ];\n },\n $pipeline->getStages()\n );\n\n $stages = array_merge($stages, $pipelineStages);\n }\n\n return $stages;\n }\n\n public function fetchOpportunityPipelines(): array\n {\n $pipelines = [];\n\n try {\n $apiResponse = $this->makeRequest('/crm/v3/pipelines/deals');\n } catch (\\Exception $e) {\n $this->log->info('[Hubspot] Failed to fetch opportunity pipelines', [\n 'reason' => $e->getMessage(),\n ]);\n\n return [];\n }\n\n $response = $apiResponse->toArray();\n\n foreach ($response['results'] as $pipeline) {\n $pipelines[] = [\n 'id' => $pipeline['id'],\n 'label' => $pipeline['label'],\n ];\n }\n\n return $pipelines;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchMeetingOutcomeFieldOptions(Field $field): array\n {\n return $field->getCrmProviderId() === 'meetingOutcome'\n ? $this->fetchMeetingOutcomeTypes()\n : $this->fetchCallActivityTypes();\n }\n\n public function fetchMeetingOutcomeTypes(): array\n {\n return $this->extractMeetingTypeOptions(\n 'https://api.hubapi.com/crm/v3/properties/meeting/hs_meeting_outcome'\n );\n }\n\n public function fetchCallActivityTypes(): array\n {\n return $this->extractMeetingTypeOptions(\n 'https://api.hubapi.com/crm/v3/properties/call/hs_activity_type'\n );\n }\n\n private function extractMeetingTypeOptions(string $endpoint): array\n {\n /** @var Response $response */\n $response = $this->getInstance()\n ->getClient()\n ->request('GET', $endpoint);\n\n /**\n * @var array<array{\n * value: string,\n * label: string,\n * displayOrder: int\n * }> $optionData\n */\n $optionData = $response->toArray()['options'] ?? [];\n\n $options = [];\n foreach ($optionData as $item) {\n $options[] = [\n 'id' => $item['value'],\n 'value' => $item['value'],\n 'label' => $item['label'],\n 'display_order' => $item['displayOrder'],\n ];\n }\n\n return $options;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchDispositionFieldOptions(): array\n {\n $options = [];\n\n $dispositions = $this->fetchCallDispositions();\n\n foreach ($dispositions as $disposition) {\n if ($disposition['deleted'] !== false) {\n continue;\n }\n\n $option['value'] = $disposition['id'];\n $option['id'] = $disposition['id'];\n $option['label'] = $disposition['label'];\n\n $options[] = $option;\n }\n\n return $options;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchOpportunityFieldOptions(Field $field): array\n {\n if ($field->isStageField()) {\n return $this->fetchOpportunityPipelineStages();\n }\n\n if ($field->isPipelineField()) {\n return $this->fetchOpportunityPipelines();\n }\n\n return $this->fetchPropertyOptions('deals', $field->getCrmProviderId());\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function makeRequest(string $endpoint, $method = 'GET', $payload = [], ?string $queryString = null)\n {\n $endpoint = self::BASE_URL . $endpoint;\n\n if ($method === 'GET') {\n return $this->getInstance()->getClient()?->request(\n method: $method,\n endpoint: $endpoint,\n query_string: $queryString\n );\n } else {\n return $this->getInstance()->getClient()->request($method, $endpoint, [\n 'json' => ($payload),\n ]);\n }\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function createMeeting(array $payload): Response\n {\n $endpoint = '/crm/v3/objects/meetings';\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function updateMeeting(string $meetingId, array $payload): Response\n {\n $endpoint = '/crm/v3/objects/meetings/' . $meetingId;\n\n return $this->makeRequest($endpoint, 'PATCH', $payload);\n }\n\n /**\n * @throws \\Exception\n */\n public function createNote(\n string $body,\n string $ownerId,\n int $timestamp,\n string $objectId,\n NoteObject $noteObject\n ): ?string {\n try {\n $noteInput = new SimplePublicObjectInput([\n 'properties' => [\n 'hs_note_body' => $body,\n 'hubspot_owner_id' => $ownerId,\n 'hs_timestamp' => $timestamp,\n ],\n ]);\n\n // Create note\n $note = $this->getNewInstance()->crm()->objects()->basicApi()->create('note', $noteInput);\n\n $this->getNewInstance()->crm()->objects()->associationsApi()->create(\n 'note',\n $note->getId(),\n $this->getNoteObject($noteObject),\n $objectId,\n $this->getNoteAssociationType($noteObject),\n );\n\n return $note->getId();\n } catch (\\Exception $e) {\n $this->log->error('[Hubspot] Failed to create note', [\n 'objectId' => $objectId,\n 'noteObject' => $noteObject->getObjectType(),\n 'reason' => $e->getMessage(),\n ]);\n\n \\Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function updateEngagement(string $objectId, array $engagement, array $metadata): void\n {\n $this->getInstance()->engagements()->update($objectId, $engagement, $metadata);\n }\n\n public function getEngagementData(string $engagementId): array\n {\n $engagement = $this->getInstance()->engagements()->get($engagementId);\n\n return $engagement->toArray();\n }\n\n public function createEngagement(array $engagement, array $associations, array $metadata): Response\n {\n return $this->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n }\n\n public function isUnauthorizedException(\\Exception $e): bool\n {\n // Check for specific HubSpot API exception types first\n if ($e instanceof BadRequest) {\n // BadRequest can contain 401 status codes\n return $e->getCode() === 401;\n }\n\n // Check for HTTP client exceptions with status codes\n if ($e instanceof \\GuzzleHttp\\Exception\\RequestException && $e->hasResponse()) {\n $response = $e->getResponse();\n if ($response !== null) {\n return $response->getStatusCode() === 401;\n }\n }\n\n // Check for Guzzle HTTP exceptions\n if ($e instanceof \\GuzzleHttp\\Exception\\ClientException) {\n return $e->getCode() === 401;\n }\n\n // Fallback to string matching as last resort, but be more specific\n $message = strtolower($e->getMessage());\n\n return str_contains($message, '401 unauthorized') ||\n str_contains($message, 'http 401') ||\n str_contains($message, 'status code 401') ||\n (preg_match('/\\b401\\b/', $message) && str_contains($message, 'unauthorized'));\n }\n\n /**\n * Validates and refreshes the access token if needed before API requests.\n * This ensures long-running processes don't fail due to token expiration.\n *\n * @throws SocialAccountTokenInvalidException\n */\n public function ensureValidToken(): void\n {\n if ($this->oauthAccount === null) {\n return;\n }\n\n $newToken = $this->tokenManager->ensureValidToken($this->oauthAccount);\n if ($newToken !== null) {\n $this->accessToken = $newToken;\n }\n }\n\n public function getConfig()\n {\n return $this->config;\n }\n\n // returns only active (archived=false)\n public function getOwners(): array\n {\n return $this->getNewInstance()->crm()->owners()->getAll();\n }\n\n /**\n * @param bool $archived\n *\n * @return array<Owner>|[]\n */\n public function getOwnersArchived(bool $archived = true): array\n {\n $endpoint = '/crm/v3/owners';\n $queryParams = [\n 'archived' => $archived ? 'true' : 'false',\n ];\n $queryString = http_build_query($queryParams);\n\n $owners = [];\n\n try {\n $response = $this->makeRequest(endpoint: $endpoint, queryString: $queryString);\n $responseData = $response?->toArray();\n\n foreach ($responseData['results'] as $result) {\n try {\n $owners[] = Owner::create($result);\n } catch (Throwable $e) {\n $this->log->error('[HubSpot] Failed to process owner data', [\n 'result' => $result,\n 'error' => $e->getMessage(),\n ]);\n\n continue;\n }\n }\n } catch (Throwable $e) {\n $this->log->error('HubSpot] Failed to fetch owners', [\n 'archived' => $archived,\n 'error' => $e->getMessage(),\n ]);\n\n return [];\n }\n\n return $owners;\n }\n\n public function getMeeting(string $engagementId): ObjectWithAssociations\n {\n return $this->getNewInstance()->crm()->objects()->basicApi()\n ->getById('meeting', $engagementId, null, 'contact,company,deal');\n }\n\n public function deleteEngagement(string $engagementId): void\n {\n $this->getInstance()->engagements()->delete((int) $engagementId);\n }\n\n public function getAssociationsData(array $ids, string $fromObject, string $toObject): array\n {\n $associationData = [];\n $idChunks = array_chunk($ids, self::ASSOCIATIONS_BATCH_SIZE_LIMIT);\n\n foreach ($idChunks as $idChunk) {\n try {\n $batchInput = new \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId();\n $batchInput->setInputs(array_map(function ($id) {\n $publicObjectId = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicObjectId();\n $publicObjectId->setId($id);\n\n return $publicObjectId;\n }, $idChunk));\n\n $associatedObjectsData = $this\n ->getNewInstance()\n ->crm()\n ->associations()\n ->batchApi()\n ->read($fromObject, $toObject, $batchInput);\n\n if ($associatedObjectsData instanceof \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchResponsePublicAssociationMulti) {\n foreach ($associatedObjectsData->getResults() as $association) {\n $from = $association->getFrom()->getId();\n $toAssociations = $association->getTo();\n\n if (! empty($toAssociations)) {\n $associationData[$from] = array_map(function ($item) {\n return $item->getId();\n }, $toAssociations);\n }\n }\n }\n } catch (\\Exception $e) {\n $this->log->error('[Hubspot] Failed to fetch associations', [\n 'from_object' => $fromObject,\n 'to_object' => $toObject,\n 'reason' => $e->getMessage(),\n ]);\n }\n }\n\n return $associationData;\n }\n\n /**\n * @throws \\Exception\n */\n private function getNoteAssociationType(NoteObject $noteObject): string\n {\n return match($noteObject) {\n NoteObject::Opportunity => 'note_to_deal',\n NoteObject::Lead, NoteObject::Contact => 'note_to_contact', // or 'note_to_lead' if your portal supports it\n NoteObject::Account => 'note_to_company',\n NoteObject::Call, NoteObject::Event => throw new \\Exception('Not supported'),\n };\n }\n\n /**\n * @throws \\Exception\n */\n private function getNoteObject(NoteObject $noteObject): string\n {\n return match($noteObject) {\n NoteObject::Opportunity => 'deal',\n NoteObject::Lead, NoteObject::Contact => 'contact',\n NoteObject::Account => 'company',\n NoteObject::Call, NoteObject::Event => throw new \\Exception('Not supported'),\n };\n }\n\n public function addAssociations(string $objectType, string $associationType, array $payload): Response\n {\n $endpoint = \"/crm/v4/associations/$objectType/$associationType/batch/create\";\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n\n public function removeAssociations(string $objectType, string $associationType, array $payload): Response\n {\n $endpoint = \"/crm/v4/associations/$objectType/$associationType/batch/archive\";\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.96276593,"top":0.07581804,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.9740692,"top":0.074221864,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.98138297,"top":0.074221864,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-5110116099004204948
|
5522932483214936164
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
ClientTest
Run 'ClientTest'
Debug 'ClientTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
2
67
3
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use HubSpot\Client\Crm\Deals\ApiException as DealApiException;
use HubSpot\Client\Crm\Contacts\ApiException as ContactApiException;
use HubSpot\Client\Crm\Companies\ApiException as CompanyApiException;
use HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations as ContactsWithAssociations;
use HubSpot\Client\Crm\Companies\Model\SimplePublicObjectWithAssociations as CompaniesWithAssociations;
use HubSpot\Client\Crm\Deals\Model\SimplePublicObjectWithAssociations as DealWithAssociations;
use HubSpot\Client\Crm\Objects\Model\SimplePublicObjectInput;
use HubSpot\Client\Crm\Objects\Model\SimplePublicObjectWithAssociations as ObjectWithAssociations;
use HubSpot\Client\Crm\Pipelines\Model\Error;
use HubSpot\Client\Crm\Pipelines\Model\PipelineStage;
use HubSpot\Client\Crm\Properties\Model\Property;
use HubSpot\Discovery\Discovery;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Crm\Field;
use Jiminny\Services\Crm\BaseClient;
use Jiminny\Services\Crm\Hubspot\DTO\Response\Owner;
use Jiminny\Services\SocialAccountService;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
use SevenShores\Hubspot\Factory;
use SevenShores\Hubspot\Http\Response;
use Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService;
use Illuminate\Support\Facades\Redis;
use Throwable;
/**
* @phpstan-type CrmFieldOption array{id:string, label:string, value?:string}
*/
class Client extends BaseClient implements HubspotClientInterface
{
public const string MIN_API_VERSION = '2';
public const string BASE_URL = '[URL_WITH_CREDENTIALS] T
*
* @param callable(): T $apiCall The API call to execute
*
* @throws RateLimitException When rate limit is hit or cached rate limit is active
*
* @return T The result of the API call
*/
private function executeRequest(callable $apiCall)
{
$cacheKey = $this->getRateLimitCacheKey();
$cachedExpiresAt = Redis::get($cacheKey);
if (is_string($cachedExpiresAt) && is_numeric($cachedExpiresAt)) {
$remaining = max(1, (int) $cachedExpiresAt - time());
throw new RateLimitException(
'Hubspot rate limit (cached circuit-breaker)',
$remaining,
);
}
try {
return $apiCall();
} catch (Throwable $e) {
if ($this->isHubspotRateLimit($e)) {
$retryAfter = $this->parseRetryAfter($e);
// NX: only the first job to receive a 429 in this burst sets the key.
// Subsequent 429s in the same burst leave the TTL untouched so the
// window is not reset by every concurrent job hitting the limit.
Redis::set($cacheKey, (string) (time() + $retryAfter), ['nx', 'ex' => $retryAfter]);
$this->log->warning('[Hubspot] Received 429 from API', [
'team_id' => $this->config->team_id,
'config_id' => $this->config->getId(),
'retry_after' => $retryAfter,
'reason' => $e->getMessage(),
]);
throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);
}
throw $e;
}
}
private function getRateLimitCacheKey(): string
{
return sprintf('hubspot:ratelimit:config:%d', $this->config->getId());
}
private function isHubspotRateLimit(Throwable $e): bool
{
if ($e instanceof BadRequest
|| $e instanceof DealApiException
|| $e instanceof ContactApiException
|| $e instanceof CompanyApiException
|| $e instanceof \GuzzleHttp\Exception\RequestException
) {
return (int) $e->getCode() === 429;
}
return false;
}
private function parseRetryAfter(Throwable $e): int
{
if (method_exists($e, 'getResponseHeaders')) {
$headers = $e->getResponseHeaders() ?: [];
$value = $headers['Retry-After'] ?? $headers['retry-after'] ?? null;
if (is_array($value)) {
$value = $value[0] ?? null;
}
if (is_numeric($value)) {
return (int) $value;
}
}
$message = strtolower($e->getMessage());
if (str_contains($message, 'daily')) {
return 600;
}
if (str_contains($message, 'ten secondly')) {
return 10;
}
if (str_contains($message, 'secondly')) {
return 1;
}
$this->log->warning('[Hubspot] No retry-after header or known message, using default', [
'exception_class' => get_class($e),
'message' => $message,
]);
return 10;
}
public function getMinimumApiVersion(): string
{
return self::MIN_API_VERSION;
}
public function getInstance(): Factory
{
return new Factory([
'key' => $this->accessToken,
'oauth2' => true,
'base_url' => $this->baseUrl,
]);
}
public function getNewInstance(): Discovery
{
return \HubSpot\Factory::createWithAccessToken($this->accessToken);
}
/**
* Secondly and daily limits for Hubspot API
*
* Product Tier: Free & Starter | Professional & Enterprise | API add-on (any tier)
* Burst: 100/10 seconds | 150/10 seconds | 200/10 seconds
* Daily: 250,000 | 500,000 | 1,000,000
*
* Official documentation states: The search endpoints are rate limited to five requests per second.
* Since with 5 RPS were still hitting secondly rate limits we lowered it to 4
*/
public function getPaginatedData(array $payload, string $type, int $offset = 0): array
{
$total = 0;
$lastId = null;
$rows = [];
foreach ($this->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastId) as $row) {
$rows[] = $row;
}
return ['results' => $rows, 'total' => $total, 'last_record' => $lastId];
}
/**
* @throws HubspotException
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
*/
public function getPaginatedDataGenerator(
array $payload,
string $type,
int $offset = 0,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
return $this->paginationService->getPaginatedDataGenerator(
$this,
$payload,
$type,
$offset,
$total,
$lastRecordId
);
}
/**
* Execute a search request against HubSpot CRM objects with rate limiting.
*
* @param string $objectType The object type ('deals', 'companies', 'contacts', 'calls')
* @param array<string, mixed> $payload The search payload with filters, sorts, properties, etc.
*
* @throws RateLimitException When rate limit is hit
* @throws HubspotException On API errors
*
* @return array The search response with 'results', 'total', 'paging' keys
*/
public function search(string $objectType, array $payload): array
{
$endpoint = self::BASE_URL . "/crm/v3/objects/{$objectType}/search";
return $this->executeRequest(function () use ($endpoint, $payload) {
$response = $this->getInstance()->getClient()->request('POST', $endpoint, ['json' => $payload]);
return $response->toArray();
});
}
/**
* @throws DealApiException
* @throws CrmException
*/
public function getOpportunityById(string $crmId, array $fields): array
{
try {
$deal = $this->getNewInstance()->crm()->deals()->basicApi()->getById(
$crmId,
implode(',', $fields),
'companies,contacts'
);
} catch (DealApiException $e) {
$this->log->info('[Hubspot] Failed to fetch opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $deal instanceof DealWithAssociations) {
throw new CrmException('Deal not found');
}
return [
'id' => $deal->getId(),
'properties' => $deal->getProperties(),
'associations' => $deal->getAssociations(),
];
}
/**
* Generic batch read method for HubSpot objects
*
* @param string $objectType The object type ('deals', 'companies', 'contacts')
* @param array<string> $crmIds Array of HubSpot object IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with object data
*/
private function batchReadObjects(string $objectType, array $crmIds, array $fields): array
{
if (empty($crmIds)) {
return [];
}
$this->validateBatchSize($objectType, $crmIds);
$this->ensureValidToken();
try {
$batchConfig = $this->createBatchConfiguration($objectType);
$batchReadRequest = $this->prepareBatchRequest($batchConfig, $crmIds, $fields);
$response = $this->executeRequest(fn () => $batchConfig['api']->read($batchReadRequest));
$this->validateApiResponse($response, $objectType);
$results = $this->processApiResults($response);
$this->logBatchResults($objectType, $crmIds, $results);
return $results;
} catch (RateLimitException $e) {
throw $e;
} catch (\Throwable $e) {
$this->handleBatchError($e, $objectType, $crmIds);
}
}
private function validateBatchSize(string $objectType, array $crmIds): void
{
if (count($crmIds) > 100) {
throw new \InvalidArgumentException("Batch size cannot exceed 100 {$objectType}");
}
}
private function createBatchConfiguration(string $objectType): array
{
$configurations = [
'deals' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Deals\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Deals\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->deals()->batchApi(),
],
'companies' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Companies\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Companies\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->companies()->batchApi(),
],
'contacts' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Contacts\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->contacts()->batchApi(),
],
];
if (! isset($configurations[$objectType])) {
throw new \InvalidArgumentException("Unsupported object type: {$objectType}");
}
return $configurations[$objectType];
}
private function prepareBatchRequest(array $batchConfig, array $crmIds, array $fields): object
{
$batchReadRequest = $batchConfig['batchReadRequest'];
$inputClass = $batchConfig['inputClass'];
$inputs = array_map(function ($crmId) use ($inputClass) {
$input = new $inputClass();
$input->setId($crmId);
return $input;
}, $crmIds);
$batchReadRequest->setInputs($inputs);
$batchReadRequest->setProperties($fields);
return $batchReadRequest;
}
private function validateApiResponse($response, string $objectType): void
{
if (! $response) {
throw new CrmException("HubSpot API returned null response for {$objectType} batch read");
}
}
private function processApiResults($response): array
{
$results = [];
$responseResults = $response->getResults();
if ($responseResults) {
foreach ($responseResults as $object) {
if ($object && $object->getId()) {
$results[$object->getId()] = [
'id' => $object->getId(),
'properties' => $object->getProperties() ?: [],
];
}
}
}
return $results;
}
private function logBatchResults(string $objectType, array $crmIds, array $results): void
{
$this->log->info("[HubSpot] Batch fetched {$objectType}", [
'requested_count' => count($crmIds),
'returned_count' => count($results),
'crm_ids' => $crmIds,
]);
}
private function handleBatchError(\Throwable $e, string $objectType, array $crmIds): void
{
$errorMessage = $e->getMessage() ?: 'Unknown error';
$errorTrace = $e->getTraceAsString() ?: 'No trace available';
$this->log->error("[HubSpot] Failed to batch fetch {$objectType}", [
'crm_ids' => $crmIds,
'error' => $errorMessage,
'trace' => $errorTrace,
]);
throw new CrmException("Failed to batch fetch {$objectType}: " . $errorMessage);
}
/**
* Batch read multiple opportunities by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot deal IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with opportunity data
*/
public function getOpportunitiesByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('deals', $crmIds, $fields);
}
/**
* Batch read multiple companies by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot company IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with company data
*/
public function getCompaniesByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('companies', $crmIds, $fields);
}
/**
* Batch read multiple contacts by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot contact IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with contact data
*/
public function getContactsByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('contacts', $crmIds, $fields);
}
/**
* @throws CompanyApiException
* @throws CrmException
*/
public function getAccountById(string $crmId, array $fields): array
{
try {
$company = $this->getNewInstance()->crm()->companies()->basicApi()->getById(
$crmId,
implode(',', $fields),
);
} catch (CompanyApiException $e) {
$this->log->info('[Hubspot] Failed to fetch account', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $company instanceof CompaniesWithAssociations) {
throw new CrmException('Account not found');
}
return [
'id' => $company->getId(),
'properties' => $company->getProperties(),
];
}
/**
* @throws ContactApiException
* @throws CrmException
*/
public function getContactById(string $crmId, array $fields): array
{
try {
$contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(
$crmId,
implode(',', $fields)
);
} catch (ContactApiException $e) {
$this->log->info('[Hubspot] Failed to fetch contact', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $contact instanceof ContactsWithAssociations) {
throw new CrmException('Contact not found');
}
return [
'id' => $contact->getId(),
'properties' => $contact->getProperties(),
];
}
/**
* This is email search request that Hubspot offers as GET (more generous quota)
*/
public function getContactByEmail(string $email, array $fields = []): array
{
try {
$contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(
$email,
implode(',', $fields),
null,
false,
'email'
);
return [
'id' => $contact->getId(),
'properties' => $contact->getProperties(),
];
} catch (ContactApiException $e) {
$this->log->info('[Hubspot] Failed to fetch contact', [
'email' => $email,
'reason' => $e->getMessage(),
]);
return [];
}
}
/**
* @throws CrmException
*/
public function fetchProperty(string $objectType, string $propertyId): Property
{
$result = $this->getNewInstance()->crm()->properties()->coreApi()->getByName($objectType, $propertyId);
if (! $result instanceof Property) {
$this->log->error('[Hubspot] Failed to fetch property', [
'object_type' => $objectType,
'property_id' => $propertyId,
'reason' => $result->getMessage(),
]);
throw new CrmException('Failed to fetch property');
}
return $result;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchPropertyOptions(string $objectType, string $propertyId): array
{
/** @var array<CrmFieldOption> */
return $this->fetchProperty($objectType, $propertyId)->getOptions();
}
/**
* @return array<array{id:string, label:string, deleted:bool}>
*/
public function fetchCallDispositions(): array
{
/** @var Response $response */
$response = $this->getInstance()->engagements()->getCallDispositions();
/**
* @var array<array{
* id:string,
* label:string,
* deleted: bool
* }>
*/
return $response->toArray();
}
/**
* @return array<CrmFieldOption>
*/
public function fetchOpportunityPipelineStages(): array
{
$stages = [];
$apiResponse = $this->getNewInstance()->crm()->pipelines()->pipelinesApi()->getAll('deals');
if ($apiResponse instanceof Error) {
$this->log->error('[Hubspot] Failed to fetch opportunity pipelines', [
'reason' => $apiResponse->getMessage(),
]);
return [];
}
foreach ($apiResponse->getResults() as $pipeline) {
$pipelineStages = array_map(
static function (PipelineStage $stage) {
return [
'id' => $stage->getId(),
'label' => $stage->getLabel(),
];
},
$pipeline->getStages()
);
$stages = array_merge($stages, $pipelineStages);
}
return $stages;
}
public function fetchOpportunityPipelines(): array
{
$pipelines = [];
try {
$apiResponse = $this->makeRequest('/crm/v3/pipelines/deals');
} catch (\Exception $e) {
$this->log->info('[Hubspot] Failed to fetch opportunity pipelines', [
'reason' => $e->getMessage(),
]);
return [];
}
$response = $apiResponse->toArray();
foreach ($response['results'] as $pipeline) {
$pipelines[] = [
'id' => $pipeline['id'],
'label' => $pipeline['label'],
];
}
return $pipelines;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchMeetingOutcomeFieldOptions(Field $field): array
{
return $field->getCrmProviderId() === 'meetingOutcome'
? $this->fetchMeetingOutcomeTypes()
: $this->fetchCallActivityTypes();
}
public function fetchMeetingOutcomeTypes(): array
{
return $this->extractMeetingTypeOptions(
'[URL_WITH_CREDENTIALS] Response $response */
$response = $this->getInstance()
->getClient()
->request('GET', $endpoint);
/**
* @var array<array{
* value: string,
* label: string,
* displayOrder: int
* }> $optionData
*/
$optionData = $response->toArray()['options'] ?? [];
$options = [];
foreach ($optionData as $item) {
$options[] = [
'id' => $item['value'],
'value' => $item['value'],
'label' => $item['label'],
'display_order' => $item['displayOrder'],
];
}
return $options;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchDispositionFieldOptions(): array
{
$options = [];
$dispositions = $this->fetchCallDispositions();
foreach ($dispositions as $disposition) {
if ($disposition['deleted'] !== false) {
continue;
}
$option['value'] = $disposition['id'];
$option['id'] = $disposition['id'];
$option['label'] = $disposition['label'];
$options[] = $option;
}
return $options;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchOpportunityFieldOptions(Field $field): array
{
if ($field->isStageField()) {
return $this->fetchOpportunityPipelineStages();
}
if ($field->isPipelineField()) {
return $this->fetchOpportunityPipelines();
}
return $this->fetchPropertyOptions('deals', $field->getCrmProviderId());
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function makeRequest(string $endpoint, $method = 'GET', $payload = [], ?string $queryString = null)
{
$endpoint = self::BASE_URL . $endpoint;
if ($method === 'GET') {
return $this->getInstance()->getClient()?->request(
method: $method,
endpoint: $endpoint,
query_string: $queryString
);
} else {
return $this->getInstance()->getClient()->request($method, $endpoint, [
'json' => ($payload),
]);
}
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function createMeeting(array $payload): Response
{
$endpoint = '/crm/v3/objects/meetings';
return $this->makeRequest($endpoint, 'POST', $payload);
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function updateMeeting(string $meetingId, array $payload): Response
{
$endpoint = '/crm/v3/objects/meetings/' . $meetingId;
return $this->makeRequest($endpoint, 'PATCH', $payload);
}
/**
* @throws \Exception
*/
public function createNote(
string $body,
string $ownerId,
int $timestamp,
string $objectId,
NoteObject $noteObject
): ?string {
try {
$noteInput = new SimplePublicObjectInput([
'properties' => [
'hs_note_body' => $body,
'hubspot_owner_id' => $ownerId,
'hs_timestamp' => $timestamp,
],
]);
// Create note
$note = $this->getNewInstance()->crm()->objects()->basicApi()->create('note', $noteInput);
$this->getNewInstance()->crm()->objects()->associationsApi()->create(
'note',
$note->getId(),
$this->getNoteObject($noteObject),
$objectId,
$this->getNoteAssociationType($noteObject),
);
return $note->getId();
} catch (\Exception $e) {
$this->log->error('[Hubspot] Failed to create note', [
'objectId' => $objectId,
'noteObject' => $noteObject->getObjectType(),
'reason' => $e->getMessage(),
]);
\Sentry::captureException($e);
}
return null;
}
public function updateEngagement(string $objectId, array $engagement, array $metadata): void
{
$this->getInstance()->engagements()->update($objectId, $engagement, $metadata);
}
public function getEngagementData(string $engagementId): array
{
$engagement = $this->getInstance()->engagements()->get($engagementId);
return $engagement->toArray();
}
public function createEngagement(array $engagement, array $associations, array $metadata): Response
{
return $this->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
}
public function isUnauthorizedException(\Exception $e): bool
{
// Check for specific HubSpot API exception types first
if ($e instanceof BadRequest) {
// BadRequest can contain 401 status codes
return $e->getCode() === 401;
}
// Check for HTTP client exceptions with status codes
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
$response = $e->getResponse();
if ($response !== null) {
return $response->getStatusCode() === 401;
}
}
// Check for Guzzle HTTP exceptions
if ($e instanceof \GuzzleHttp\Exception\ClientException) {
return $e->getCode() === 401;
}
// Fallback to string matching as last resort, but be more specific
$message = strtolower($e->getMessage());
return str_contains($message, '401 unauthorized') ||
str_contains($message, 'http 401') ||
str_contains($message, 'status code 401') ||
(preg_match('/\b401\b/', $message) && str_contains($message, 'unauthorized'));
}
/**
* Validates and refreshes the access token if needed before API requests.
* This ensures long-running processes don't fail due to token expiration.
*
* @throws SocialAccountTokenInvalidException
*/
public function ensureValidToken(): void
{
if ($this->oauthAccount === null) {
return;
}
$newToken = $this->tokenManager->ensureValidToken($this->oauthAccount);
if ($newToken !== null) {
$this->accessToken = $newToken;
}
}
public function getConfig()
{
return $this->config;
}
// returns only active (archived=false)
public function getOwners(): array
{
return $this->getNewInstance()->crm()->owners()->getAll();
}
/**
* @param bool $archived
*
* @return array<Owner>|[]
*/
public function getOwnersArchived(bool $archived = true): array
{
$endpoint = '/crm/v3/owners';
$queryParams = [
'archived' => $archived ? 'true' : 'false',
];
$queryString = http_build_query($queryParams);
$owners = [];
try {
$response = $this->makeRequest(endpoint: $endpoint, queryString: $queryString);
$responseData = $response?->toArray();
foreach ($responseData['results'] as $result) {
try {
$owners[] = Owner::create($result);
} catch (Throwable $e) {
$this->log->error('[HubSpot] Failed to process owner data', [
'result' => $result,
'error' => $e->getMessage(),
]);
continue;
}
}
} catch (Throwable $e) {
$this->log->error('HubSpot] Failed to fetch owners', [
'archived' => $archived,
'error' => $e->getMessage(),
]);
return [];
}
return $owners;
}
public function getMeeting(string $engagementId): ObjectWithAssociations
{
return $this->getNewInstance()->crm()->objects()->basicApi()
->getById('meeting', $engagementId, null, 'contact,company,deal');
}
public function deleteEngagement(string $engagementId): void
{
$this->getInstance()->engagements()->delete((int) $engagementId);
}
public function getAssociationsData(array $ids, string $fromObject, string $toObject): array
{
$associationData = [];
$idChunks = array_chunk($ids, self::ASSOCIATIONS_BATCH_SIZE_LIMIT);
foreach ($idChunks as $idChunk) {
try {
$batchInput = new \HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId();
$batchInput->setInputs(array_map(function ($id) {
$publicObjectId = new \HubSpot\Client\Crm\Associations\Model\PublicObjectId();
$publicObjectId->setId($id);
return $publicObjectId;
}, $idChunk));
$associatedObjectsData = $this
->getNewInstance()
->crm()
->associations()
->batchApi()
->read($fromObject, $toObject, $batchInput);
if ($associatedObjectsData instanceof \HubSpot\Client\Crm\Associations\Model\BatchResponsePublicAssociationMulti) {
foreach ($associatedObjectsData->getResults() as $association) {
$from = $association->getFrom()->getId();
$toAssociations = $association->getTo();
if (! empty($toAssociations)) {
$associationData[$from] = array_map(function ($item) {
return $item->getId();
}, $toAssociations);
}
}
}
} catch (\Exception $e) {
$this->log->error('[Hubspot] Failed to fetch associations', [
'from_object' => $fromObject,
'to_object' => $toObject,
'reason' => $e->getMessage(),
]);
}
}
return $associationData;
}
/**
* @throws \Exception
*/
private function getNoteAssociationType(NoteObject $noteObject): string
{
return match($noteObject) {
NoteObject::Opportunity => 'note_to_deal',
NoteObject::Lead, NoteObject::Contact => 'note_to_contact', // or 'note_to_lead' if your portal supports it
NoteObject::Account => 'note_to_company',
NoteObject::Call, NoteObject::Event => throw new \Exception('Not supported'),
};
}
/**
* @throws \Exception
*/
private function getNoteObject(NoteObject $noteObject): string
{
return match($noteObject) {
NoteObject::Opportunity => 'deal',
NoteObject::Lead, NoteObject::Contact => 'contact',
NoteObject::Account => 'company',
NoteObject::Call, NoteObject::Event => throw new \Exception('Not supported'),
};
}
public function addAssociations(string $objectType, string $associationType, array $payload): Response
{
$endpoint = "/crm/v4/associations/$objectType/$associationType/batch/create";
return $this->makeRequest($endpoint, 'POST', $payload);
}
public function removeAssociations(string $objectType, string $associationType, array $payload): Response
{
$endpoint = "/crm/v4/associations/$objectType/$associationType/batch/archive";
return $this->makeRequest($endpoint, 'POST', $payload);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
20537
|
NULL
|
NULL
|
NULL
|
|
20543
|
891
|
13
|
2026-05-11T15:49:15.751255+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778514555751_m2.jpg...
|
PhpStorm
|
faVsco.js – Client.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
ClientTest
Run 'ClientTest'
Debug 'ClientTest'
More Actions...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8597075,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ClientTest","depth":6,"bounds":{"left":0.875,"top":0.019952115,"width":0.04055851,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ClientTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ClientTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-6439870152881658773
|
-8925008221627233534
|
visual_change
|
hybrid
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
ClientTest
Run 'ClientTest'
Debug 'ClientTest'
More Actions
PhostormVIewINavigarecodeKeractorFV faVsco.js°9 JY-20725-handle-HS-search-rate-limitProleteyRematchActivityOnCrmObjectDetach.php© HubspotPaginationService.php© BatchSyncCollectolUserautomatedkeporscontroller.onghuospot/serwice.pnpOhubspot/service.pnpTSvncCrmEntitiesTrait.onpe balchsynckealsseo closedDealstagess© MatchactivityermData.png© RateLimitException.phpDealrielasservice.gc)Decorateacuiviy.or© FieldDefinitions.phrclass Client extends Baseclient impLements HubspotclientintertaceC) FieldTvpeconvertel@ HubspotClientIntertc) HubspotTokenman• Generic batch read method for HubSoot obiects© PayloadBuilder.phpC) RemotecrmobiectnP ResponseNormalizec) Service,ono* @param string Sobjecttype The object type ('deals','comoanies', 'contacts')* @param array<string> $crmids Array of HubSpot object IDs (max 100)* @param array<string> $fields Array of property names to fetchC)SvncFieldAction.on© SyncRelatedActivit) 294C) WebhookSvncBatcl* @return array<string, array> Array keyed by CRM ID with object datav MintearationAor3 usagesM Acceccorsprivate function batchRead0bjects(string SobjectType, array $crmIds, array $fields): array297CancolayLog xChanaes & filles= env.local ano+ → E Side-by-side viewer -8 02d5214b app/Services/Crm/Hubspot/Client.phpDo not ignoreHighlight words →x 15 B?(C) Client.oho aoo/Services/Crm/Hubsoot© ClientTest.php tests/Unit/Services/Crm/Hubspot© HandleHubspotRateLimitTest.php tests/Unit/Jobs/Middleware© JiminnyDebugCommand.php app/Console/Commandsphp logging.php config© MatchActivityCrmData.php app/Jobs/CrmRateLimitException.php app/ExceptionsUnversioned Files 9 files.env.nikilocal app= env.other app© CanAccessAiReportsTest.php tests/Unit/Policies© CreateMockAskJiminnyReportResultCommand.php app/Console/Commands/Re( favicon.ico publicE ids.txt appRraw sal_query.sal app© SimulateWebhooksCommand.php app/Console/Commands/Crm/Hubspotprivate function getRateLimitCacheKey: stringrecurn spranur nuospor.raceulilt.ortal:%d', $this->config->getId)public function isHubspotRateLimit(Throwable Se): boolif (Se instanceof BadReguestSe 1nstanceot DealApzExcept1onIl Se instanceof ContactApiExcentionreturn false:M.WE8TOOK FILTERING IMPLEMENTATION.mo a00public function parseRetryAfter(Throwable $e): intif (method_exists($e, 'getResponseHeaders')) €Sheaders = $e->getResponseHeaders ?: 0Svalue = Sheaders['Retry-After'] ?? Sheaders['retry-after'] ?? null;C) TrackAutomatedRenortGeneratedFvent.nhnlC) CachedCrmServiceDecorator.ongCheскAnакetrукemotematch.pngm A2 A67 M3 л VI 1I1111ПICIРIIlTacts naccod: 80 (17 minutes adol100% C47 • Mon 11 May 18:49:15ClientTestA SF [jiminny@localhost]4 HS_local [jiminny@localhost]console [pRODl« console [EU]console [STAGINGI"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWM0Q.UfZEXDZyHz2mBUFdzdo2gTHEsOkX1 ~07-May-26 14:51:15 GMT: domain=.hubapi.com:Http0nly: Secure: SameSite=None"].V.19A"keporc-10"."N"urL\":1"https:(VNVa.net.cloudf Lare.com\V/report\V/v4?s=NYALsVTPotYm52qrSDJxYE4sd2RwRq15p5wHsmd=g<Lz@YdxLx2B1XVpHmsKnS0%2BKVA5mF1J2m/YRECD65nx2BW2LYT206FM4%2l v("group"; \"cf-nell","max age":604800,"J,"success traction".0.olg"max age":6048002""Serven":"cloudflare"?>4"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",3 differencesTeirront vorcianprivate function getRateLimitCacheKey: stringreturn sprintf('hubspot:ratelimit:cofig:%d', Sthis->config->getId():private function isHubspotRateLimit(Throwable Se): boolif (Se instanceof BadRequesiSe 1nstanceot DealApzExcept1onlse instanceot contactanz Excentionreturn false:private function parseRetryAfter(Throwable $e): intif (method_exists($e, 'getResponseHeaders')) €Sheadens = Se->ae+ResnonseHeadenco2.m-$value = Sheaders['Retry-After'] ?? Sheaders['retry-after'] ?? null;W Windsurf Teams 294:75 UTF-8 i 4 spaces ®...
|
20541
|
NULL
|
NULL
|
NULL
|
|
20548
|
891
|
15
|
2026-05-11T15:49:24.957589+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778514564957_m2.jpg...
|
PhpStorm
|
faVsco.js – ClientTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
ClientTest
Run 'ClientTest'
Debug 'ClientTest'...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8597075,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ClientTest","depth":6,"bounds":{"left":0.875,"top":0.019952115,"width":0.04055851,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ClientTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ClientTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
5458160177223257008
|
-8934015352164857600
|
visual_change
|
hybrid
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
ClientTest
Run 'ClientTest'
Debug 'ClientTest'
PhostormVIewINavigareCodeFV faVsco.js°9 JY-20725-handle-HS-search-rate-limProiectRematchActivityOnCrmObjectDetach.php• m HelnersC) UserAutomatedReportscontroller.onghuospot/service.onpv M HubsnotMActions© MatchacuivitycrmData.ong>ODTO> D Fields> @ Journal> @ OpportunitySyncv D Pagination© HubspotPagiC PaginationCcC PaginationSt:> 0 ProspectSearch- service lraits> C Webhook(c) BatchSvncColle‹C) BatchSvncRedisc) ClientTest.ono©) ClosedDealStaa(c) DealFieldsServicc) DecorateActivit(c) FieldDetinitionsi@ FieldTvpeConvec HubsnotTokenv(c PavloadBuildert(c) RemoteCrmObic(c) ResponseNormac ServiceRecnonsE ServiceTest.php©) SyncFieldAction©) SyncRelatedAct© WebhookSyncB:› D IntegrationApp_ Listeners› D Pipedrivev D Salesforce> O Fields• @ OpportunityMati• C OpportunitySvnc• C ProspectSearch.→ ServiceTraits@ ClientTest.phpC DecorateActiviti© DeleteObjectsTrC FieldDefinitions1@* GetActivitvField@ PavloadBuilderT@ QuervBuilderTesc) QuervHandlerte(c QuerviteratorTech @uervRecultsTeCh ServiceTest nhnTacts naccod. 80 (17 minutec aaol© RateLimitException.php<?phpdeclarelstrict tyoessio:namespace Tests Unit Services Crm Hubspot:› use ...* QrunTestsTnSenarateProcesses* @preserveGlobalState disabledclass Clienttest eytends Testfaseprivate const string RESPONSE TYPE STAGE FIELD = 'stage':private const string RESPONSE TYPE PIPELINE FIELD = 'pipeline':private const string RESPONSE TYPE REGULAR_FIELD = 'regular':* @var Client&Mock0bject86 usadesprivate Client Sclient:private Confiquration Sconfia:/x*d*ovan Soci@lAccountService.Mockohiect2 usadesnrivate SocialAccountService SsocialAccountServiceMock•* @vac HubspotPaginationService&Mock0bject*/4 usacesprivate HubsootPaginationService SnaginationSonvicoMonke*@var HubspotTokenManaaenSMocl0hsontorivate huospotokenranader stokenranagerhock.C) TrackAutomated Revori GeneraledeventonpC) CachedCrmServiceDecorator.ongCheскAnакetrукemotematch.pngm A19 A144 ×11 ^S0 hh100% Lz• Mon 11 May 18:49:24ClientTest v# console [PKoDJA console [eu)console [STAGINGIA SF [jiminny@localhost]A ho_local Uiminny@localnost[2026-05-07 14:21:15] local.INF0: [Hubspot] DEBUG Getting headers {w.19A"neaders".i"Date":["Thu,07 May 2026 14:21:15 GMT"]"Lontent-lype". applicacion/son charset=utt-on"Transfer-Encoding":["chunked"],"CF-Ray": ["9f80deb8db60dc3a-SOF"],"Strict-Transport-Security":["max-aqe=31536000: includeSubDomains: preload"].'access-control-allow-credentials": "false"server-timing": ["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",cfn:desc="9r80deb8ercodcsa-JAD""."x-content-type-options": ["nosniff"],"x-hubsoot-correlation-id":"019e02d0-6fd8-7812-bdba-885b7cch3ee3")"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZẸXDZyHz2mBUFdzdo2gTHEs0kXMSẸShjK®hGYxNhUdomain=.hubapi.com; Http0nly; Secure; SameSite=None"],"Renont-Toll•|"\"url\":\"https:|VAV/a.nel.cloudflare.com\V/report\V/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn30%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTz06FM4%2I\"max_age\":604800}"],INFI"•T"S"success_fraction)":0.01,"nenont to ": "cf-nel","Server": ["cloudflare"]}} {"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab" ."trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}W Windsurf Teams 61:8 UTF-8 P 4 spaces ®...
|
20546
|
NULL
|
NULL
|
NULL
|
|
20550
|
NULL
|
0
|
2026-05-11T15:49:30.937355+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778514570937_m2.jpg...
|
PhpStorm
|
faVsco.js – ClientTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
ClientTest
Rerun 'PHPUnit: ClientTest'
Debug 'ClientTest'
Stop 'ClientTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
144
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Hubspot;
use GuzzleHttp\Psr7\Response;
use HubSpot\Client\Crm\Associations\Api\BatchApi;
use HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId;
use HubSpot\Client\Crm\Associations\Model\BatchResponsePublicAssociationMulti;
use HubSpot\Client\Crm\Associations\Model\PublicObjectId;
use HubSpot\Client\Crm\Companies\ApiException as CompanyApiException;
use HubSpot\Client\Crm\Contacts\ApiException as ContactApiException;
use HubSpot\Client\Crm\Deals\Api\BasicApi as DealsBasicApi;
use HubSpot\Client\Crm\Deals\ApiException as DealApiException;
use HubSpot\Client\Crm\Deals\Model\SimplePublicObjectWithAssociations as DealWithAssociations;
use HubSpot\Client\Crm\Pipelines\Api\PipelinesApi;
use HubSpot\Client\Crm\Pipelines\Model\CollectionResponsePipeline;
use HubSpot\Client\Crm\Pipelines\Model\Error;
use HubSpot\Client\Crm\Pipelines\Model\Pipeline;
use HubSpot\Client\Crm\Pipelines\Model\PipelineStage;
use HubSpot\Client\Crm\Properties\Api\CoreApi;
use HubSpot\Client\Crm\Properties\Model\Property;
use HubSpot\Discovery as HubSpotDiscovery;
use HubSpot\Discovery\Crm\Deals\Discovery as DealsDiscovery;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\SocialAccount;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\HubspotTokenManager;
use Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService;
use Jiminny\Services\SocialAccountService;
use League\OAuth2\Client\Token\AccessToken;
use Mockery;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use SevenShores\Hubspot\Endpoints\Engagements;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Factory;
use SevenShores\Hubspot\Http\Client as HubspotClient;
use SevenShores\Hubspot\Http\Response as HubspotResponse;
/**
* @runTestsInSeparateProcesses
*
* @preserveGlobalState disabled
*/
class ClientTest extends TestCase
{
private const string RESPONSE_TYPE_STAGE_FIELD = 'stage';
private const string RESPONSE_TYPE_PIPELINE_FIELD = 'pipeline';
private const string RESPONSE_TYPE_REGULAR_FIELD = 'regular';
/**
* @var Client&MockObject
*/
private Client $client;
private Configuration $config;
/**
* @var SocialAccountService&MockObject
*/
private SocialAccountService $socialAccountServiceMock;
/**
* @var HubspotPaginationService&MockObject
*/
private HubspotPaginationService $paginationServiceMock;
/**
* @var HubspotTokenManager&MockObject
*/
private HubspotTokenManager $tokenManagerMock;
/**
* @var CoreApi&MockObject
*/
private CoreApi $coreApiMock;
/**
* @var PipelinesApi&MockObject
*/
private PipelinesApi $pipelinesApiMock;
/**
* @var BatchApi&MockObject
*/
private BatchApi $associationsBatchApiMock;
/**
* @var DealsBasicApi&MockObject
*/
private DealsBasicApi $dealsBasicApiMock;
/**
* @var Engagements&MockObject
*/
private $engagementsMock;
/**
* @var HubspotClient&MockObject
*/
private HubspotClient $hubspotClientMock;
protected function setUp(): void
{
// Create mocks for dependencies
$this->socialAccountServiceMock = $this->createMock(SocialAccountService::class);
$this->paginationServiceMock = $this->createMock(HubspotPaginationService::class);
$this->tokenManagerMock = $this->createMock(HubspotTokenManager::class);
// Create a real Client instance with mocked dependencies
// Create a partial mock only for the methods we need to mock
$client = $this->createPartialMock(Client::class, ['getInstance', 'getNewInstance', 'makeRequest']);
$client->setLogger(new NullLogger());
// Inject the real dependencies using reflection
$reflection = new \ReflectionClass(Client::class);
$accountServiceProperty = $reflection->getProperty('accountService');
$accountServiceProperty->setAccessible(true);
$accountServiceProperty->setValue($client, $this->socialAccountServiceMock);
$paginationServiceProperty = $reflection->getProperty('paginationService');
$paginationServiceProperty->setAccessible(true);
$paginationServiceProperty->setValue($client, $this->paginationServiceMock);
$tokenManagerProperty = $reflection->getProperty('tokenManager');
$tokenManagerProperty->setAccessible(true);
$tokenManagerProperty->setValue($client, $this->tokenManagerMock);
$factoryMock = $this->createMock(Factory::class);
$hubspotClientMock = $this->createMock(HubspotClient::class);
$engagementsMock = $this->createMock(\SevenShores\Hubspot\Endpoints\Engagements::class);
$factoryMock->method('getClient')->willReturn($hubspotClientMock);
$factoryMock->method('__call')->with('engagements')->willReturn($engagementsMock);
$client->method('getInstance')->willReturn($factoryMock);
$discoveryMock = $this->createMock(HubSpotDiscovery\Discovery::class);
$crmMock = $this->createMock(HubSpotDiscovery\Crm\Discovery::class);
$associationsMock = $this->createMock(HubSpotDiscovery\Crm\Associations\Discovery::class);
$propertiesMock = $this->createMock(HubSpotDiscovery\Crm\Properties\Discovery::class);
$pipelinesMock = $this->createMock(HubSpotDiscovery\Crm\Pipelines\Discovery::class);
$coreApiMock = $this->createMock(CoreApi::class);
$pipelinesApiMock = $this->createMock(PipelinesApi::class);
$associationsBatchApiMock = $this->createMock(BatchApi::class);
$dealsBasicApiMock = $this->createMock(DealsBasicApi::class);
$propertiesMock->method('__call')->with('coreApi')->willReturn($coreApiMock);
$pipelinesMock->method('__call')->with('pipelinesApi')->willReturn($pipelinesApiMock);
$associationsMock->method('__call')->with('batchApi')->willReturn($associationsBatchApiMock);
$dealsDiscoveryMock = $this->createMock(DealsDiscovery::class);
$dealsDiscoveryMock->method('__call')->with('basicApi')->willReturn($dealsBasicApiMock);
$returnMap = ['properties' => $propertiesMock, 'pipelines' => $pipelinesMock, 'associations' => $associationsMock, 'deals' => $dealsDiscoveryMock];
$crmMock->method('__call')
->willReturnCallback(static fn (string $name) => $returnMap[$name])
;
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client->method('getNewInstance')->willReturn($discoveryMock);
$this->client = $client;
$this->config = $this->createMock(Configuration::class);
$this->client->setConfiguration($this->config);
$this->coreApiMock = $coreApiMock;
$this->pipelinesApiMock = $pipelinesApiMock;
$this->associationsBatchApiMock = $associationsBatchApiMock;
$this->dealsBasicApiMock = $dealsBasicApiMock;
$this->engagementsMock = $engagementsMock;
$this->hubspotClientMock = $hubspotClientMock;
}
public function testGetMinimumApiVersion(): void
{
$this->assertIsString($this->client->getMinimumApiVersion());
}
public function testGetInstance(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManager = $this->createMock(HubspotTokenManager::class);
$client = new Client($socialAccountService, $paginationService, $tokenManager);
$client->setBaseUrl('[URL_WITH_CREDENTIALS] The class CollectionResponsePipeline will be deprecated in the next Hubspot version
*/
$this->pipelinesApiMock
->method('getAll')
->with('deals')
->willReturn(new CollectionResponsePipeline([
'results' => [$this->generatePipeline()],
]))
;
$this->assertEquals(
[
['id' => 'foo', 'label' => 'bar'],
['id' => 'baz', 'label' => 'qux'],
],
$this->client->fetchOpportunityPipelineStages()
);
}
public function testFetchOpportunityPipelineStagesErrorResponse(): void
{
$this->pipelinesApiMock
->method('getAll')
->with('deals')
->willReturn(new Error(['message' => 'test error']))
;
$this->assertEmpty($this->client->fetchOpportunityPipelineStages());
}
#[\PHPUnit\Framework\Attributes\DataProvider('meetingOutcomeFieldProvider')]
public function testFetchMeetingOutcomeFieldOptions(string $fieldId, string $expectedEndpoint): void
{
$field = new Field(['crm_provider_id' => $fieldId]);
$this->hubspotClientMock
->expects($this->once())
->method('request')
->with('GET', $expectedEndpoint)
->willReturn($this->generateHubSpotResponse(
[
'options' => [
['value' => 'option_1', 'label' => 'Option 1', 'displayOrder' => 0],
['value' => 'option_2', 'label' => 'Option 2', 'displayOrder' => 1],
['value' => 'option_3', 'label' => 'Option 3', 'displayOrder' => 2],
],
]
))
;
$this->assertEquals(
[
['id' => 'option_1', 'value' => 'option_1', 'label' => 'Option 1', 'display_order' => 0],
['id' => 'option_2', 'value' => 'option_2', 'label' => 'Option 2', 'display_order' => 1],
['id' => 'option_3', 'value' => 'option_3', 'label' => 'Option 3', 'display_order' => 2],
],
$this->client->fetchMeetingOutcomeFieldOptions($field)
);
}
public static function meetingOutcomeFieldProvider(): array
{
return [
'meeting outcome field' => [
'meetingOutcome',
'[URL_WITH_CREDENTIALS] The class CollectionResponsePipeline will be deprecated in the next Hubspot version
*/
$pipelineStagesResponse = new CollectionResponsePipeline([
'results' => [$this->generatePipeline()],
]);
$this->pipelinesApiMock
->method('getAll')
->willReturn($pipelineStagesResponse);
}
if ($type === self::RESPONSE_TYPE_PIPELINE_FIELD) {
$field->method('isStageField')->willReturn(false);
$field->method('isPipelineField')->willReturn(true);
$pipelineResponse = $this->generateHubSpotResponse([
'results' => [
['id' => '123', 'label' => 'Sales'],
['id' => 'default', 'label' => 'CS'],
],
]);
$this->client
->method('makeRequest')
->with('/crm/v3/pipelines/deals')
->willReturn($pipelineResponse);
}
$this->assertEquals(
$responses[$type],
$this->client->fetchOpportunityFieldOptions($field)
);
}
public static function opportunityFieldOptionsProvider(): array
{
return [
'stage field' => [
'field' => new Field(['crm_provider_id' => 'dealstage']),
'type' => self::RESPONSE_TYPE_STAGE_FIELD,
],
'pipeline field' => [
'field' => new Field(['crm_provider_id' => 'pipeline']),
'type' => self::RESPONSE_TYPE_PIPELINE_FIELD,
],
'regular field' => [
'field' => new Field(['crm_provider_id' => 'some_property']),
'type' => self::RESPONSE_TYPE_REGULAR_FIELD,
],
];
}
private function generateHubSpotResponse(array $data): HubspotResponse
{
return new HubspotResponse(new Response(200, [], json_encode($data)));
}
private function generateProperty(): Property
{
return new Property([
'name' => 'some_property',
'options' => [
[
'label' => 'label_1',
'value' => 'value_1',
],
[
'label' => 'label_2',
'value' => 'value_2',
],
],
]);
}
private function generatePipeline(): Pipeline
{
return new Pipeline(['stages' => [
new PipelineStage(['id' => 'foo', 'label' => 'bar']),
new PipelineStage(['id' => 'baz', 'label' => 'qux']),
]]);
}
public function testFetchOpportunityPipelines(): void
{
$this->client
->method('makeRequest')
->with('/crm/v3/pipelines/deals')
->willReturn($this->generateHubSpotResponse([
'results' => [
['id' => 'id_1', 'label' => 'Option 1', 'displayOrder' => 0],
['id' => 'id_2', 'label' => 'Option 2', 'displayOrder' => 1],
['id' => 'id_3', 'label' => 'Option 3', 'displayOrder' => 2],
],
]));
$this->assertEquals(
[
['id' => 'id_1', 'label' => 'Option 1'],
['id' => 'id_2', 'label' => 'Option 2'],
['id' => 'id_3', 'label' => 'Option 3'],
],
$this->client->fetchOpportunityPipelines()
);
}
public function testGetPaginatedData(): void
{
$expectedResults = [
['id' => 'id_1', 'properties' => []],
['id' => 'id_2', 'properties' => []],
['id' => 'id_3', 'properties' => []],
];
// Mock the pagination service to return a generator and modify reference parameters
$this->paginationServiceMock
->expects($this->once())
->method('getPaginatedDataGenerator')
->with(
$this->client,
['payload_key' => 'payload_value'],
'foobar',
0,
$this->anything(),
$this->anything()
)
->willReturnCallback(function ($client, $payload, $type, $offset, &$total, &$lastRecordId) use ($expectedResults) {
$total = 3;
$lastRecordId = 'id_3';
foreach ($expectedResults as $result) {
yield $result;
}
});
$this->assertEquals(
[
'results' => $expectedResults,
'total' => 3,
'last_record' => 'id_3',
],
$this->client->getPaginatedData(['payload_key' => 'payload_value'], 'foobar')
);
}
public function testGetAssociationsData(): void
{
$ids = ['1', '2', '3'];
$fromObject = 'deals';
$toObject = 'contacts';
$responseResults = [];
foreach ($ids as $id) {
$from = new PublicObjectId();
$from->setId($id);
$to1 = new PublicObjectId();
$to1->setId('contact_' . $id . '_1');
$to2 = new PublicObjectId();
$to2->setId('contact_' . $id . '_2');
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to1, $to2]);
$responseResults[] = $result;
}
$batchResponse = new BatchResponsePublicAssociationMulti();
$batchResponse->setResults($responseResults);
$this->associationsBatchApiMock->expects($this->once())
->method('read')
->with(
$this->equalTo($fromObject),
$this->equalTo($toObject),
$this->callback(function (BatchInputPublicObjectId $batchInput) use ($ids) {
$inputIds = array_map(
fn ($input) => $input->getId(),
$batchInput->getInputs()
);
return $inputIds === $ids;
})
)
->willReturn($batchResponse);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$expectedResult = [
'1' => ['contact_1_1', 'contact_1_2'],
'2' => ['contact_2_1', 'contact_2_2'],
'3' => ['contact_3_1', 'contact_3_2'],
];
$this->assertEquals($expectedResult, $result);
}
public function testGetAssociationsDataHandlesException(): void
{
$ids = ['1', '2', '3'];
$fromObject = 'deals';
$toObject = 'contacts';
$exception = new \Exception('API Error');
$this->associationsBatchApiMock->expects($this->once())
->method('read')
->with(
$this->equalTo($fromObject),
$this->equalTo($toObject),
$this->callback(function ($batchInput) use ($ids) {
return $batchInput instanceof \HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId
&& count($batchInput->getInputs()) === count($ids);
})
)
->willThrowException($exception);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with(
'[Hubspot] Failed to fetch associations',
[
'from_object' => $fromObject,
'to_object' => $toObject,
'reason' => 'API Error',
]
);
$this->client->setLogger($loggerMock);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$this->assertEmpty($result);
$this->assertIsArray($result);
}
public function testGetAssociationsDataWithLargeDataSet(): void
{
$ids = array_map(fn ($i) => (string) $i, range(1, 2500)); // More than 1000 items
$fromObject = 'deals';
$toObject = 'contacts';
$firstBatchResponse = new BatchResponsePublicAssociationMulti();
$firstBatchResults = array_map(function ($id) {
$from = new PublicObjectId();
$from->setId($id);
$to = new PublicObjectId();
$to->setId('contact_' . $id);
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to]);
return $result;
}, array_slice($ids, 0, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));
$firstBatchResponse->setResults($firstBatchResults);
$secondBatchResponse = new BatchResponsePublicAssociationMulti();
$secondBatchResults = array_map(function ($id) {
$from = new PublicObjectId();
$from->setId($id);
$to = new PublicObjectId();
$to->setId('contact_' . $id);
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to]);
return $result;
}, array_slice($ids, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));
$secondBatchResponse->setResults($secondBatchResults);
$this->associationsBatchApiMock->expects($this->exactly(3))
->method('read')
->willReturnOnConsecutiveCalls($firstBatchResponse, $secondBatchResponse);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$this->assertCount(2500, $result);
$this->assertArrayHasKey('1', $result);
$this->assertArrayHasKey('2500', $result);
$this->assertEquals(['contact_1'], $result['1']);
$this->assertEquals(['contact_2500'], $result['2500']);
}
public function testGetContactByEmailSuccess(): void
{
$email = '[EMAIL]';
$fields = ['firstname', 'lastname', 'email'];
$contactMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$contactMock->method('getId')->willReturn('12345');
$contactMock->method('getProperties')->willReturn([
'firstname' => 'John',
'lastname' => 'Doe',
'email' => '[EMAIL]',
]);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, 'firstname,lastname,email', null, false, 'email')
->willReturn($contactMock);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new NullLogger());
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([
'id' => '12345',
'properties' => [
'firstname' => 'John',
'lastname' => 'Doe',
'email' => '[EMAIL]',
],
], $result);
}
public function testGetContactByEmailWithEmptyFields(): void
{
$email = '[EMAIL]';
$fields = [];
$contactMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$contactMock->method('getId')->willReturn('12345');
$contactMock->method('getProperties')->willReturn(['email' => '[EMAIL]']);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, '', null, false, 'email')
->willReturn($contactMock);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new NullLogger());
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([
'id' => '12345',
'properties' => ['email' => '[EMAIL]'],
], $result);
}
public function testGetContactByEmailApiException(): void
{
$email = '[EMAIL]';
$fields = ['firstname', 'lastname'];
$exception = new \HubSpot\Client\Crm\Contacts\ApiException('Contact not found', 404);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, 'firstname,lastname', null, false, 'email')
->willThrowException($exception);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('info')
->with(
'[Hubspot] Failed to fetch contact',
[
'email' => $email,
'reason' => 'Contact not found',
]
);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger($loggerMock);
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([], $result);
}
public function testGetOpportunityById(): void
{
$opportunityId = '12345';
$expectedProperties = [
'dealname' => 'Test Opportunity',
'amount' => '1000.00',
'closedate' => '2024-12-31T23:59:59.999Z',
'dealstage' => 'presentationscheduled',
'pipeline' => 'default',
];
$mockHubspotOpportunity = $this->createMock(DealWithAssociations::class);
$mockHubspotOpportunity->method('getProperties')->willReturn((object) $expectedProperties);
$mockHubspotOpportunity->method('getId')->willReturn($opportunityId);
$now = new \DateTimeImmutable();
$mockHubspotOpportunity->method('getCreatedAt')->willReturn($now);
$mockHubspotOpportunity->method('getUpdatedAt')->willReturn($now);
$mockHubspotOpportunity->method('getArchived')->willReturn(false);
$this->dealsBasicApiMock
->expects($this->once())
->method('getById')
->willReturn($mockHubspotOpportunity);
// Assuming Client::getOpportunityById processes the SimplePublicObject and returns an associative array.
// The structure might be like: ['id' => ..., 'properties' => [...], 'createdAt' => ..., ...]
// Adjust assertions below based on the actual return structure of your method.
$result = $this->client->getOpportunityById($opportunityId, ['test', 'test']);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertEquals($opportunityId, $result['id']);
}
public function testGetContactById(): void
{
$crmId = 'contact-123';
$fields = ['firstname', 'lastname'];
$expectedProperties = ['firstname' => 'John', 'lastname' => 'Doe'];
$mockContact = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$mockContact->method('getId')->willReturn($crmId);
$mockContact->method('getProperties')->willReturn((object) $expectedProperties);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($crmId, 'firstname,lastname')
->willReturn($mockContact);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new \Psr\Log\NullLogger());
$result = $client->getContactById($crmId, $fields);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('properties', $result);
$this->assertEquals($crmId, $result['id']);
$this->assertEquals((object) $expectedProperties, $result['properties']);
}
public function testGetAccountById(): void
{
$crmId = 'account-123';
$fields = ['name', 'industry'];
$expectedProperties = ['name' => 'Acme Corp', 'industry' => 'Technology'];
$mockCompany = $this->createMock(\HubSpot\Client\Crm\Companies\Model\SimplePublicObjectWithAssociations::class);
$mockCompany->method('getId')->willReturn($crmId);
$mockCompany->method('getProperties')->willReturn((object) $expectedProperties);
$companiesApiMock = $this->createMock(\HubSpot\Client\Crm\Companies\Api\BasicApi::class);
$companiesApiMock->expects($this->once())
->method('getById')
->with($crmId, 'name,industry')
->willReturn($mockCompany);
$companiesDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Companies\Discovery::class);
$companiesDiscoveryMock->method('__call')->with('basicApi')->willReturn($companiesApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($companiesDiscoveryMock) {
if ($name === 'companies') {
return $companiesDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new \Psr\Log\NullLogger());
$result = $client->getAccountById($crmId, $fields);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('properties', $result);
$this->assertEquals($crmId, $result['id']);
$this->assertEquals((object) $expectedProperties, $result['properties']);
}
public function testEnsureValidTokenWithNoTokenUpdate(): void
{
$socialAccountMock = $this->createMock(SocialAccount::class);
// Set up OAuth account
$reflection = new \ReflectionClass($this->client);
$oauthAccountProperty = $reflection->getProperty('oauthAccount');
$oauthAccountProperty->setAccessible(true);
$oauthAccountProperty->setValue($this->client, $socialAccountMock);
$originalToken = 'original_token';
$accessTokenProperty = $reflection->getProperty('accessToken');
$accessTokenProperty->setAccessible(true);
$accessTokenProperty->setValue($this->client, $originalToken);
// Mock token manager to return null (no refresh needed)
$this->tokenManagerMock
->expects($this->once())
->method('ensureValidToken')
->with($socialAccountMock)
->willReturn(null);
// Call ensureValidToken
$this->client->ensureValidToken();
// Verify access token was not changed
$this->assertEquals($originalToken, $accessTokenProperty->getValue($this->client));
}
public function testGetPaginatedDataGeneratorDelegatesToPaginationService(): void
{
$payload = ['filters' => []];
$type = 'contacts';
$offset = 0;
$total = 0;
$lastRecordId = null;
$expectedResults = [
['id' => 'id_1', 'properties' => []],
['id' => 'id_2', 'properties' => []],
];
// Mock the pagination service to return a generator
$this->paginationServiceMock
->expects($this->once())
->method('getPaginatedDataGenerator')
->with(
$this->client,
$payload,
$type,
$offset,
$this->anything(),
$this->anything()
)
->willReturnCallback(function () use ($expectedResults) {
foreach ($expectedResults as $result) {
yield $result;
}
});
// Execute the pagination
$results = [];
foreach ($this->client->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastRecordId) as $result) {
$results[] = $result;
}
$this->assertCount(2, $results);
$this->assertEquals('id_1', $results[0]['id']);
$this->assertEquals('id_2', $results[1]['id']);
}
public function testEnsureValidTokenDelegatesToTokenManager(): void
{
$socialAccountMock = $this->createMock(SocialAccount::class);
// Set up OAuth account
$reflection = new \ReflectionClass($this->client);
$oauthAccountProperty = $reflection->getProperty('oauthAccount');
$oauthAccountProperty->setAccessible(true);
$oauthAccountProperty->setValue($this->client, $socialAccountMock);
// Mock token manager to return new token
$this->tokenManagerMock
->expects($this->once())
->method('ensureValidToken')
->with($socialAccountMock)
->willReturn('new_access_token');
// Call ensureValidToken
$this->client->ensureValidToken();
// Verify access token was updated
$accessTokenProperty = $reflection->getProperty('accessToken');
$accessTokenProperty->setAccessible(true);
$this->assertEquals('new_access_token', $accessTokenProperty->getValue($this->client));
}
public function testGetOwnersArchivedWithValidResponse(): void
{
$responseData = [
'results' => [
[
'id' => '123',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'John',
'lastName' => 'Doe',
'userId' => 456,
'userIdIncludingInactive' => 789,
'createdAt' => '2023-01-01T12:00:00Z',
'updatedAt' => '2023-01-02T12:00:00Z',
'archived' => true,
],
],
];
// Create a mock response object
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn($responseData);
// Set up the client to return our test data
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=true'
)
->willReturn($response);
// Call the method
$result = $this->client->getOwnersArchived(true);
// Assert the results
$this->assertCount(1, $result);
$this->assertEquals('123', $result[0]->getId());
$this->assertEquals('[EMAIL]', $result[0]->getEmail());
$this->assertEquals('John Doe', $result[0]->getFullName());
$this->assertTrue($result[0]->isArchived());
}
public function testGetOwnersArchivedWithEmptyResponse(): void
{
// Create a mock response object with empty results
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn(['results' => []]);
// Set up the client to return empty results
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=false'
)
->willReturn($response);
// Call the method
$result = $this->client->getOwnersArchived(false);
// Assert the results
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithInvalidResponse(): void
{
// Create a mock response that will throw an exception when toArray is called
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willThrowException(new \InvalidArgumentException('Invalid JSON'));
// Set up the client to return the problematic response
$this->client->method('makeRequest')
->willReturn($response);
// Mock the logger to expect an error message
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with($this->stringContains('Failed to fetch owners'));
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
// Call the method and expect an empty array on error
$result = $this->client->getOwnersArchived(true);
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithHttpError(): void
{
// Set up the client to throw an exception
$this->client->method('makeRequest')
->willThrowException(new \Exception('HTTP Error'));
// Mock the logger to expect an error message
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with($this->stringContains('Failed to fetch owners'));
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
// Call the method and expect an empty array on error
$result = $this->client->getOwnersArchived(false);
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithOwnerCreationException(): void
{
$responseData = [
'results' => [
[
'id' => '123',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'John',
'lastName' => 'Doe',
'userId' => 456,
'userIdIncludingInactive' => 789,
'createdAt' => '2023-01-01T12:00:00Z',
'updatedAt' => '2023-01-02T12:00:00Z',
'archived' => false,
],
[
'id' => '456',
'email' => '[EMAIL]',
'type' => 'PERSON',
'createdAt' => 'invalid-date-format',
],
[
'id' => '789',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'Jane',
'lastName' => 'Smith',
'userId' => 999,
'userIdIncludingInactive' => 888,
'createdAt' => '2023-01-03T12:00:00Z',
'updatedAt' => '2023-01-04T12:00:00Z',
'archived' => false,
],
],
];
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn($responseData);
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=false'
)
->willReturn($response);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with(
'[HubSpot] Failed to process owner data',
$this->callback(function ($context) {
return isset($context['result']) &&
isset($context['error']) &&
$context['result']['id'] === '456' &&
$context['result']['email'] === '[EMAIL]' &&
str_contains($context['error'], 'invalid-date-format');
})
);
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
$result = $this->client->getOwnersArchived(false);
$this->assertIsArray($result);
$this->assertCount(2, $result);
$this->assertEquals('123', $result[0]->getId());
$this->assertEquals('[EMAIL]', $result[0]->getEmail());
$this->assertEquals('789', $result[1]->getId());
$this->assertEquals('[EMAIL]', $result[1]->getEmail());
}
public function testMakeRequestWithGetMethod(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManager = $this->createMock(HubspotTokenManager::class);
$psrResponse = new Response(200, [], json_encode(['status' => 'success']));
$expectedResponse = new HubspotResponse($psrResponse);
$hubspotClientMock = $this->createMock(HubspotClient::class);
$hubspotClientMock->expects($this->once())
->method('request')
->with(
'GET',
'https://api.hubapi.com/crm/v3/objects/contacts',
[],
null,
true
)
->willReturn($expectedResponse);
$factoryMock = $this->createMock(Factory::class);
$factoryMock->method('getClient')->willReturn($hubspotClientMock);
$client = $this->getMockBuilder(Client::class)
->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])
->onlyMethods(['getInstance'])
->getMock();
$client->method('getInstance')->willReturn($factoryMock);
$client->setAccessToken(new AccessToken(['access_token' => 'test_token']));
$result = $client->makeRequest('/crm/v3/objects/contacts', 'GET');
$this->assertSame($expectedResponse, $result);
}
public function testMakeRequestWithGetMethodAndQueryString(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8597075,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ClientTest","depth":6,"bounds":{"left":0.875,"top":0.019952115,"width":0.04055851,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Rerun 'PHPUnit: ClientTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ClientTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Stop 'ClientTest'","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9494681,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.50265956,"top":0.17478053,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"144","depth":4,"bounds":{"left":0.5142952,"top":0.17478053,"width":0.011968086,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":4,"bounds":{"left":0.52825797,"top":0.17478053,"width":0.008976064,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.53889626,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.5462101,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Hubspot;\n\nuse GuzzleHttp\\Psr7\\Response;\nuse HubSpot\\Client\\Crm\\Associations\\Api\\BatchApi;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\BatchResponsePublicAssociationMulti;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\PublicObjectId;\nuse HubSpot\\Client\\Crm\\Companies\\ApiException as CompanyApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\ApiException as ContactApiException;\nuse HubSpot\\Client\\Crm\\Deals\\Api\\BasicApi as DealsBasicApi;\nuse HubSpot\\Client\\Crm\\Deals\\ApiException as DealApiException;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectWithAssociations as DealWithAssociations;\nuse HubSpot\\Client\\Crm\\Pipelines\\Api\\PipelinesApi;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\CollectionResponsePipeline;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Error;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Pipeline;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\PipelineStage;\nuse HubSpot\\Client\\Crm\\Properties\\Api\\CoreApi;\nuse HubSpot\\Client\\Crm\\Properties\\Model\\Property;\nuse HubSpot\\Discovery as HubSpotDiscovery;\nuse HubSpot\\Discovery\\Crm\\Deals\\Discovery as DealsDiscovery;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\HubspotTokenManager;\nuse Jiminny\\Services\\Crm\\Hubspot\\Pagination\\HubspotPaginationService;\nuse Jiminny\\Services\\SocialAccountService;\nuse League\\OAuth2\\Client\\Token\\AccessToken;\nuse Mockery;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse PHPUnit\\Framework\\TestCase;\nuse Psr\\Log\\NullLogger;\nuse SevenShores\\Hubspot\\Endpoints\\Engagements;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Factory;\nuse SevenShores\\Hubspot\\Http\\Client as HubspotClient;\nuse SevenShores\\Hubspot\\Http\\Response as HubspotResponse;\n\n/**\n * @runTestsInSeparateProcesses\n *\n * @preserveGlobalState disabled\n */\nclass ClientTest extends TestCase\n{\n private const string RESPONSE_TYPE_STAGE_FIELD = 'stage';\n private const string RESPONSE_TYPE_PIPELINE_FIELD = 'pipeline';\n private const string RESPONSE_TYPE_REGULAR_FIELD = 'regular';\n /**\n * @var Client&MockObject\n */\n private Client $client;\n private Configuration $config;\n\n /**\n * @var SocialAccountService&MockObject\n */\n private SocialAccountService $socialAccountServiceMock;\n\n /**\n * @var HubspotPaginationService&MockObject\n */\n private HubspotPaginationService $paginationServiceMock;\n\n /**\n * @var HubspotTokenManager&MockObject\n */\n private HubspotTokenManager $tokenManagerMock;\n\n /**\n * @var CoreApi&MockObject\n */\n private CoreApi $coreApiMock;\n\n /**\n * @var PipelinesApi&MockObject\n */\n private PipelinesApi $pipelinesApiMock;\n\n /**\n * @var BatchApi&MockObject\n */\n private BatchApi $associationsBatchApiMock;\n\n /**\n * @var DealsBasicApi&MockObject\n */\n private DealsBasicApi $dealsBasicApiMock;\n\n /**\n * @var Engagements&MockObject\n */\n private $engagementsMock;\n\n /**\n * @var HubspotClient&MockObject\n */\n private HubspotClient $hubspotClientMock;\n\n protected function setUp(): void\n {\n // Create mocks for dependencies\n $this->socialAccountServiceMock = $this->createMock(SocialAccountService::class);\n $this->paginationServiceMock = $this->createMock(HubspotPaginationService::class);\n $this->tokenManagerMock = $this->createMock(HubspotTokenManager::class);\n\n // Create a real Client instance with mocked dependencies\n // Create a partial mock only for the methods we need to mock\n $client = $this->createPartialMock(Client::class, ['getInstance', 'getNewInstance', 'makeRequest']);\n $client->setLogger(new NullLogger());\n\n // Inject the real dependencies using reflection\n $reflection = new \\ReflectionClass(Client::class);\n\n $accountServiceProperty = $reflection->getProperty('accountService');\n $accountServiceProperty->setAccessible(true);\n $accountServiceProperty->setValue($client, $this->socialAccountServiceMock);\n\n $paginationServiceProperty = $reflection->getProperty('paginationService');\n $paginationServiceProperty->setAccessible(true);\n $paginationServiceProperty->setValue($client, $this->paginationServiceMock);\n\n $tokenManagerProperty = $reflection->getProperty('tokenManager');\n $tokenManagerProperty->setAccessible(true);\n $tokenManagerProperty->setValue($client, $this->tokenManagerMock);\n $factoryMock = $this->createMock(Factory::class);\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $engagementsMock = $this->createMock(\\SevenShores\\Hubspot\\Endpoints\\Engagements::class);\n\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n $factoryMock->method('__call')->with('engagements')->willReturn($engagementsMock);\n\n $client->method('getInstance')->willReturn($factoryMock);\n\n $discoveryMock = $this->createMock(HubSpotDiscovery\\Discovery::class);\n $crmMock = $this->createMock(HubSpotDiscovery\\Crm\\Discovery::class);\n $associationsMock = $this->createMock(HubSpotDiscovery\\Crm\\Associations\\Discovery::class);\n $propertiesMock = $this->createMock(HubSpotDiscovery\\Crm\\Properties\\Discovery::class);\n $pipelinesMock = $this->createMock(HubSpotDiscovery\\Crm\\Pipelines\\Discovery::class);\n $coreApiMock = $this->createMock(CoreApi::class);\n $pipelinesApiMock = $this->createMock(PipelinesApi::class);\n $associationsBatchApiMock = $this->createMock(BatchApi::class);\n $dealsBasicApiMock = $this->createMock(DealsBasicApi::class);\n\n $propertiesMock->method('__call')->with('coreApi')->willReturn($coreApiMock);\n $pipelinesMock->method('__call')->with('pipelinesApi')->willReturn($pipelinesApiMock);\n $associationsMock->method('__call')->with('batchApi')->willReturn($associationsBatchApiMock);\n $dealsDiscoveryMock = $this->createMock(DealsDiscovery::class);\n $dealsDiscoveryMock->method('__call')->with('basicApi')->willReturn($dealsBasicApiMock);\n\n $returnMap = ['properties' => $propertiesMock, 'pipelines' => $pipelinesMock, 'associations' => $associationsMock, 'deals' => $dealsDiscoveryMock];\n $crmMock->method('__call')\n ->willReturnCallback(static fn (string $name) => $returnMap[$name])\n ;\n\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n\n $this->client = $client;\n $this->config = $this->createMock(Configuration::class);\n $this->client->setConfiguration($this->config);\n $this->coreApiMock = $coreApiMock;\n $this->pipelinesApiMock = $pipelinesApiMock;\n $this->associationsBatchApiMock = $associationsBatchApiMock;\n $this->dealsBasicApiMock = $dealsBasicApiMock;\n $this->engagementsMock = $engagementsMock;\n $this->hubspotClientMock = $hubspotClientMock;\n }\n\n public function testGetMinimumApiVersion(): void\n {\n $this->assertIsString($this->client->getMinimumApiVersion());\n }\n\n public function testGetInstance(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $client = new Client($socialAccountService, $paginationService, $tokenManager);\n $client->setBaseUrl('https://example.com');\n $client->setAccessToken(new AccessToken(['access_token' => 'foo']));\n $factory = $client->getInstance();\n\n $this->assertEquals('foo', $factory->getClient()->key);\n }\n\n public function testGetNewInstance(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $client = new Client($socialAccountService, $paginationService, $tokenManager);\n $client->setAccessToken(new AccessToken(['access_token' => 'foo']));\n\n $factory = $client->getNewInstance();\n $token = $factory->auth()->oAuth()->accessTokensApi()->getConfig()->getAccessToken();\n $this->assertEquals('foo', $token);\n }\n\n public function testFetchProperty(): void\n {\n $this->coreApiMock->method('getByName')->willReturn($this->generateProperty());\n\n $this->assertEquals(\n 'some_property',\n $this->client->fetchProperty('foo', 'test')['name']\n );\n }\n\n public function testFetchPropertyOptions(): void\n {\n $this->coreApiMock->method('getByName')->willReturn($this->generateProperty());\n\n $this->assertEquals(\n [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n $this->client->fetchPropertyOptions('foo', 'test')\n );\n }\n\n public function testFetchCallDispositions(): void\n {\n $this->engagementsMock\n ->method('getCallDispositions')\n ->willReturn($this->generateHubSpotResponse([\n [\n 'id' => 1,\n 'label' => 'foo',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'bar',\n 'deleted' => false,\n ],\n ]))\n ;\n\n $this->assertEquals(\n [\n [\n 'id' => 1,\n 'label' => 'foo',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'bar',\n 'deleted' => false,\n ],\n ],\n $this->client->fetchCallDispositions()\n );\n }\n\n public function testFetchDispositionFieldOptions(): void\n {\n $this->engagementsMock->method('getCallDispositions')\n ->willReturn($this->generateHubSpotResponse(\n [\n [\n 'id' => 1,\n 'label' => 'Label 1',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'Label 2',\n 'deleted' => true,\n ],\n ]\n ))\n ;\n\n $this->assertEquals(\n [\n [\n 'value' => 1,\n 'id' => 1,\n 'label' => 'Label 1',\n ],\n ],\n $this->client->fetchDispositionFieldOptions()\n );\n }\n\n public function testFetchOpportunityPipelineStages(): void\n {\n /**\n * @TODO: The class CollectionResponsePipeline will be deprecated in the next Hubspot version\n */\n $this->pipelinesApiMock\n ->method('getAll')\n ->with('deals')\n ->willReturn(new CollectionResponsePipeline([\n 'results' => [$this->generatePipeline()],\n ]))\n ;\n\n $this->assertEquals(\n [\n ['id' => 'foo', 'label' => 'bar'],\n ['id' => 'baz', 'label' => 'qux'],\n ],\n $this->client->fetchOpportunityPipelineStages()\n );\n }\n\n public function testFetchOpportunityPipelineStagesErrorResponse(): void\n {\n $this->pipelinesApiMock\n ->method('getAll')\n ->with('deals')\n ->willReturn(new Error(['message' => 'test error']))\n ;\n\n $this->assertEmpty($this->client->fetchOpportunityPipelineStages());\n }\n\n #[\\PHPUnit\\Framework\\Attributes\\DataProvider('meetingOutcomeFieldProvider')]\n public function testFetchMeetingOutcomeFieldOptions(string $fieldId, string $expectedEndpoint): void\n {\n $field = new Field(['crm_provider_id' => $fieldId]);\n\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->with('GET', $expectedEndpoint)\n ->willReturn($this->generateHubSpotResponse(\n [\n 'options' => [\n ['value' => 'option_1', 'label' => 'Option 1', 'displayOrder' => 0],\n ['value' => 'option_2', 'label' => 'Option 2', 'displayOrder' => 1],\n ['value' => 'option_3', 'label' => 'Option 3', 'displayOrder' => 2],\n ],\n ]\n ))\n ;\n\n $this->assertEquals(\n [\n ['id' => 'option_1', 'value' => 'option_1', 'label' => 'Option 1', 'display_order' => 0],\n ['id' => 'option_2', 'value' => 'option_2', 'label' => 'Option 2', 'display_order' => 1],\n ['id' => 'option_3', 'value' => 'option_3', 'label' => 'Option 3', 'display_order' => 2],\n ],\n $this->client->fetchMeetingOutcomeFieldOptions($field)\n );\n }\n\n public static function meetingOutcomeFieldProvider(): array\n {\n return [\n 'meeting outcome field' => [\n 'meetingOutcome',\n 'https://api.hubapi.com/crm/v3/properties/meeting/hs_meeting_outcome',\n ],\n 'activity type field' => [\n 'foobar',\n 'https://api.hubapi.com/crm/v3/properties/call/hs_activity_type',\n ],\n ];\n }\n\n #[\\PHPUnit\\Framework\\Attributes\\DataProvider('opportunityFieldOptionsProvider')]\n public function testFetchOpportunityFieldOptions(Field $field, string $type): void\n {\n $responses = [\n self::RESPONSE_TYPE_STAGE_FIELD => [\n ['id' => 'foo', 'label' => 'bar'],\n ['id' => 'baz', 'label' => 'qux'],\n ],\n self::RESPONSE_TYPE_PIPELINE_FIELD => [\n ['id' => '123', 'label' => 'Sales'],\n ['id' => 'default', 'label' => 'CS'],\n ],\n self::RESPONSE_TYPE_REGULAR_FIELD => [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n ];\n\n $field = $this->createMock(Field::class);\n\n if ($type === self::RESPONSE_TYPE_REGULAR_FIELD) {\n $field->method('isStageField')->willReturn(false);\n $field->method('isPipelineField')->willReturn(false);\n $propertyResponse = $this->generateProperty();\n $this->coreApiMock->method('getByName')->willReturn($propertyResponse);\n }\n\n if ($type === self::RESPONSE_TYPE_STAGE_FIELD) {\n $field->method('isStageField')->willReturn(true);\n /**\n * @TODO: The class CollectionResponsePipeline will be deprecated in the next Hubspot version\n */\n\n $pipelineStagesResponse = new CollectionResponsePipeline([\n 'results' => [$this->generatePipeline()],\n ]);\n $this->pipelinesApiMock\n ->method('getAll')\n ->willReturn($pipelineStagesResponse);\n }\n\n if ($type === self::RESPONSE_TYPE_PIPELINE_FIELD) {\n $field->method('isStageField')->willReturn(false);\n $field->method('isPipelineField')->willReturn(true);\n $pipelineResponse = $this->generateHubSpotResponse([\n 'results' => [\n ['id' => '123', 'label' => 'Sales'],\n ['id' => 'default', 'label' => 'CS'],\n ],\n ]);\n $this->client\n ->method('makeRequest')\n ->with('/crm/v3/pipelines/deals')\n ->willReturn($pipelineResponse);\n }\n\n $this->assertEquals(\n $responses[$type],\n $this->client->fetchOpportunityFieldOptions($field)\n );\n }\n\n public static function opportunityFieldOptionsProvider(): array\n {\n return [\n 'stage field' => [\n 'field' => new Field(['crm_provider_id' => 'dealstage']),\n 'type' => self::RESPONSE_TYPE_STAGE_FIELD,\n ],\n 'pipeline field' => [\n 'field' => new Field(['crm_provider_id' => 'pipeline']),\n 'type' => self::RESPONSE_TYPE_PIPELINE_FIELD,\n ],\n 'regular field' => [\n 'field' => new Field(['crm_provider_id' => 'some_property']),\n 'type' => self::RESPONSE_TYPE_REGULAR_FIELD,\n ],\n ];\n }\n\n private function generateHubSpotResponse(array $data): HubspotResponse\n {\n return new HubspotResponse(new Response(200, [], json_encode($data)));\n }\n\n private function generateProperty(): Property\n {\n return new Property([\n 'name' => 'some_property',\n 'options' => [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n ]);\n }\n\n private function generatePipeline(): Pipeline\n {\n return new Pipeline(['stages' => [\n new PipelineStage(['id' => 'foo', 'label' => 'bar']),\n new PipelineStage(['id' => 'baz', 'label' => 'qux']),\n ]]);\n }\n\n public function testFetchOpportunityPipelines(): void\n {\n $this->client\n ->method('makeRequest')\n ->with('/crm/v3/pipelines/deals')\n ->willReturn($this->generateHubSpotResponse([\n 'results' => [\n ['id' => 'id_1', 'label' => 'Option 1', 'displayOrder' => 0],\n ['id' => 'id_2', 'label' => 'Option 2', 'displayOrder' => 1],\n ['id' => 'id_3', 'label' => 'Option 3', 'displayOrder' => 2],\n ],\n ]));\n\n $this->assertEquals(\n [\n ['id' => 'id_1', 'label' => 'Option 1'],\n ['id' => 'id_2', 'label' => 'Option 2'],\n ['id' => 'id_3', 'label' => 'Option 3'],\n ],\n $this->client->fetchOpportunityPipelines()\n );\n }\n\n public function testGetPaginatedData(): void\n {\n $expectedResults = [\n ['id' => 'id_1', 'properties' => []],\n ['id' => 'id_2', 'properties' => []],\n ['id' => 'id_3', 'properties' => []],\n ];\n\n // Mock the pagination service to return a generator and modify reference parameters\n $this->paginationServiceMock\n ->expects($this->once())\n ->method('getPaginatedDataGenerator')\n ->with(\n $this->client,\n ['payload_key' => 'payload_value'],\n 'foobar',\n 0,\n $this->anything(),\n $this->anything()\n )\n ->willReturnCallback(function ($client, $payload, $type, $offset, &$total, &$lastRecordId) use ($expectedResults) {\n $total = 3;\n $lastRecordId = 'id_3';\n foreach ($expectedResults as $result) {\n yield $result;\n }\n });\n\n $this->assertEquals(\n [\n 'results' => $expectedResults,\n 'total' => 3,\n 'last_record' => 'id_3',\n ],\n $this->client->getPaginatedData(['payload_key' => 'payload_value'], 'foobar')\n );\n }\n\n public function testGetAssociationsData(): void\n {\n $ids = ['1', '2', '3'];\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $responseResults = [];\n foreach ($ids as $id) {\n $from = new PublicObjectId();\n $from->setId($id);\n\n $to1 = new PublicObjectId();\n $to1->setId('contact_' . $id . '_1');\n $to2 = new PublicObjectId();\n $to2->setId('contact_' . $id . '_2');\n\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to1, $to2]);\n\n $responseResults[] = $result;\n }\n\n $batchResponse = new BatchResponsePublicAssociationMulti();\n $batchResponse->setResults($responseResults);\n\n $this->associationsBatchApiMock->expects($this->once())\n ->method('read')\n ->with(\n $this->equalTo($fromObject),\n $this->equalTo($toObject),\n $this->callback(function (BatchInputPublicObjectId $batchInput) use ($ids) {\n $inputIds = array_map(\n fn ($input) => $input->getId(),\n $batchInput->getInputs()\n );\n\n return $inputIds === $ids;\n })\n )\n ->willReturn($batchResponse);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $expectedResult = [\n '1' => ['contact_1_1', 'contact_1_2'],\n '2' => ['contact_2_1', 'contact_2_2'],\n '3' => ['contact_3_1', 'contact_3_2'],\n ];\n\n $this->assertEquals($expectedResult, $result);\n }\n\n public function testGetAssociationsDataHandlesException(): void\n {\n $ids = ['1', '2', '3'];\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $exception = new \\Exception('API Error');\n\n $this->associationsBatchApiMock->expects($this->once())\n ->method('read')\n ->with(\n $this->equalTo($fromObject),\n $this->equalTo($toObject),\n $this->callback(function ($batchInput) use ($ids) {\n return $batchInput instanceof \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId\n && count($batchInput->getInputs()) === count($ids);\n })\n )\n ->willThrowException($exception);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with(\n '[Hubspot] Failed to fetch associations',\n [\n 'from_object' => $fromObject,\n 'to_object' => $toObject,\n 'reason' => 'API Error',\n ]\n );\n\n $this->client->setLogger($loggerMock);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $this->assertEmpty($result);\n $this->assertIsArray($result);\n }\n\n public function testGetAssociationsDataWithLargeDataSet(): void\n {\n $ids = array_map(fn ($i) => (string) $i, range(1, 2500)); // More than 1000 items\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $firstBatchResponse = new BatchResponsePublicAssociationMulti();\n $firstBatchResults = array_map(function ($id) {\n $from = new PublicObjectId();\n $from->setId($id);\n $to = new PublicObjectId();\n $to->setId('contact_' . $id);\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to]);\n\n return $result;\n }, array_slice($ids, 0, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));\n $firstBatchResponse->setResults($firstBatchResults);\n\n $secondBatchResponse = new BatchResponsePublicAssociationMulti();\n $secondBatchResults = array_map(function ($id) {\n $from = new PublicObjectId();\n $from->setId($id);\n $to = new PublicObjectId();\n $to->setId('contact_' . $id);\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to]);\n\n return $result;\n }, array_slice($ids, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));\n $secondBatchResponse->setResults($secondBatchResults);\n\n $this->associationsBatchApiMock->expects($this->exactly(3))\n ->method('read')\n ->willReturnOnConsecutiveCalls($firstBatchResponse, $secondBatchResponse);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $this->assertCount(2500, $result);\n $this->assertArrayHasKey('1', $result);\n $this->assertArrayHasKey('2500', $result);\n $this->assertEquals(['contact_1'], $result['1']);\n $this->assertEquals(['contact_2500'], $result['2500']);\n }\n\n public function testGetContactByEmailSuccess(): void\n {\n $email = 'test@example.com';\n $fields = ['firstname', 'lastname', 'email'];\n\n $contactMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $contactMock->method('getId')->willReturn('12345');\n $contactMock->method('getProperties')->willReturn([\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n 'email' => 'test@example.com',\n ]);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, 'firstname,lastname,email', null, false, 'email')\n ->willReturn($contactMock);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new NullLogger());\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([\n 'id' => '12345',\n 'properties' => [\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n 'email' => 'test@example.com',\n ],\n ], $result);\n }\n\n public function testGetContactByEmailWithEmptyFields(): void\n {\n $email = 'test@example.com';\n $fields = [];\n\n $contactMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $contactMock->method('getId')->willReturn('12345');\n $contactMock->method('getProperties')->willReturn(['email' => 'test@example.com']);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, '', null, false, 'email')\n ->willReturn($contactMock);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new NullLogger());\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([\n 'id' => '12345',\n 'properties' => ['email' => 'test@example.com'],\n ], $result);\n }\n\n public function testGetContactByEmailApiException(): void\n {\n $email = 'nonexistent@example.com';\n $fields = ['firstname', 'lastname'];\n\n $exception = new \\HubSpot\\Client\\Crm\\Contacts\\ApiException('Contact not found', 404);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, 'firstname,lastname', null, false, 'email')\n ->willThrowException($exception);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('info')\n ->with(\n '[Hubspot] Failed to fetch contact',\n [\n 'email' => $email,\n 'reason' => 'Contact not found',\n ]\n );\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger($loggerMock);\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([], $result);\n }\n\n public function testGetOpportunityById(): void\n {\n $opportunityId = '12345';\n $expectedProperties = [\n 'dealname' => 'Test Opportunity',\n 'amount' => '1000.00',\n 'closedate' => '2024-12-31T23:59:59.999Z',\n 'dealstage' => 'presentationscheduled',\n 'pipeline' => 'default',\n ];\n\n $mockHubspotOpportunity = $this->createMock(DealWithAssociations::class);\n $mockHubspotOpportunity->method('getProperties')->willReturn((object) $expectedProperties);\n $mockHubspotOpportunity->method('getId')->willReturn($opportunityId);\n $now = new \\DateTimeImmutable();\n $mockHubspotOpportunity->method('getCreatedAt')->willReturn($now);\n $mockHubspotOpportunity->method('getUpdatedAt')->willReturn($now);\n $mockHubspotOpportunity->method('getArchived')->willReturn(false);\n\n $this->dealsBasicApiMock\n ->expects($this->once())\n ->method('getById')\n ->willReturn($mockHubspotOpportunity);\n\n // Assuming Client::getOpportunityById processes the SimplePublicObject and returns an associative array.\n // The structure might be like: ['id' => ..., 'properties' => [...], 'createdAt' => ..., ...]\n // Adjust assertions below based on the actual return structure of your method.\n $result = $this->client->getOpportunityById($opportunityId, ['test', 'test']);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertEquals($opportunityId, $result['id']);\n }\n\n public function testGetContactById(): void\n {\n $crmId = 'contact-123';\n $fields = ['firstname', 'lastname'];\n $expectedProperties = ['firstname' => 'John', 'lastname' => 'Doe'];\n\n $mockContact = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $mockContact->method('getId')->willReturn($crmId);\n $mockContact->method('getProperties')->willReturn((object) $expectedProperties);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($crmId, 'firstname,lastname')\n ->willReturn($mockContact);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new \\Psr\\Log\\NullLogger());\n\n $result = $client->getContactById($crmId, $fields);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertArrayHasKey('properties', $result);\n $this->assertEquals($crmId, $result['id']);\n $this->assertEquals((object) $expectedProperties, $result['properties']);\n }\n\n public function testGetAccountById(): void\n {\n $crmId = 'account-123';\n $fields = ['name', 'industry'];\n $expectedProperties = ['name' => 'Acme Corp', 'industry' => 'Technology'];\n\n $mockCompany = $this->createMock(\\HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectWithAssociations::class);\n $mockCompany->method('getId')->willReturn($crmId);\n $mockCompany->method('getProperties')->willReturn((object) $expectedProperties);\n\n $companiesApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Companies\\Api\\BasicApi::class);\n $companiesApiMock->expects($this->once())\n ->method('getById')\n ->with($crmId, 'name,industry')\n ->willReturn($mockCompany);\n\n $companiesDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Companies\\Discovery::class);\n $companiesDiscoveryMock->method('__call')->with('basicApi')->willReturn($companiesApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($companiesDiscoveryMock) {\n if ($name === 'companies') {\n return $companiesDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new \\Psr\\Log\\NullLogger());\n\n $result = $client->getAccountById($crmId, $fields);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertArrayHasKey('properties', $result);\n $this->assertEquals($crmId, $result['id']);\n $this->assertEquals((object) $expectedProperties, $result['properties']);\n }\n\n public function testEnsureValidTokenWithNoTokenUpdate(): void\n {\n $socialAccountMock = $this->createMock(SocialAccount::class);\n\n // Set up OAuth account\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, $socialAccountMock);\n\n $originalToken = 'original_token';\n $accessTokenProperty = $reflection->getProperty('accessToken');\n $accessTokenProperty->setAccessible(true);\n $accessTokenProperty->setValue($this->client, $originalToken);\n\n // Mock token manager to return null (no refresh needed)\n $this->tokenManagerMock\n ->expects($this->once())\n ->method('ensureValidToken')\n ->with($socialAccountMock)\n ->willReturn(null);\n\n // Call ensureValidToken\n $this->client->ensureValidToken();\n\n // Verify access token was not changed\n $this->assertEquals($originalToken, $accessTokenProperty->getValue($this->client));\n }\n\n\n\n\n\n\n public function testGetPaginatedDataGeneratorDelegatesToPaginationService(): void\n {\n $payload = ['filters' => []];\n $type = 'contacts';\n $offset = 0;\n $total = 0;\n $lastRecordId = null;\n\n $expectedResults = [\n ['id' => 'id_1', 'properties' => []],\n ['id' => 'id_2', 'properties' => []],\n ];\n\n // Mock the pagination service to return a generator\n $this->paginationServiceMock\n ->expects($this->once())\n ->method('getPaginatedDataGenerator')\n ->with(\n $this->client,\n $payload,\n $type,\n $offset,\n $this->anything(),\n $this->anything()\n )\n ->willReturnCallback(function () use ($expectedResults) {\n foreach ($expectedResults as $result) {\n yield $result;\n }\n });\n\n // Execute the pagination\n $results = [];\n foreach ($this->client->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastRecordId) as $result) {\n $results[] = $result;\n }\n\n $this->assertCount(2, $results);\n $this->assertEquals('id_1', $results[0]['id']);\n $this->assertEquals('id_2', $results[1]['id']);\n }\n\n public function testEnsureValidTokenDelegatesToTokenManager(): void\n {\n $socialAccountMock = $this->createMock(SocialAccount::class);\n\n // Set up OAuth account\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, $socialAccountMock);\n\n // Mock token manager to return new token\n $this->tokenManagerMock\n ->expects($this->once())\n ->method('ensureValidToken')\n ->with($socialAccountMock)\n ->willReturn('new_access_token');\n\n // Call ensureValidToken\n $this->client->ensureValidToken();\n\n // Verify access token was updated\n $accessTokenProperty = $reflection->getProperty('accessToken');\n $accessTokenProperty->setAccessible(true);\n $this->assertEquals('new_access_token', $accessTokenProperty->getValue($this->client));\n }\n\n public function testGetOwnersArchivedWithValidResponse(): void\n {\n $responseData = [\n 'results' => [\n [\n 'id' => '123',\n 'email' => 'owner1@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'John',\n 'lastName' => 'Doe',\n 'userId' => 456,\n 'userIdIncludingInactive' => 789,\n 'createdAt' => '2023-01-01T12:00:00Z',\n 'updatedAt' => '2023-01-02T12:00:00Z',\n 'archived' => true,\n ],\n ],\n ];\n\n // Create a mock response object\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn($responseData);\n\n // Set up the client to return our test data\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=true'\n )\n ->willReturn($response);\n\n // Call the method\n $result = $this->client->getOwnersArchived(true);\n\n // Assert the results\n $this->assertCount(1, $result);\n $this->assertEquals('123', $result[0]->getId());\n $this->assertEquals('owner1@example.com', $result[0]->getEmail());\n $this->assertEquals('John Doe', $result[0]->getFullName());\n $this->assertTrue($result[0]->isArchived());\n }\n\n public function testGetOwnersArchivedWithEmptyResponse(): void\n {\n // Create a mock response object with empty results\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn(['results' => []]);\n\n // Set up the client to return empty results\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=false'\n )\n ->willReturn($response);\n\n // Call the method\n $result = $this->client->getOwnersArchived(false);\n\n // Assert the results\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithInvalidResponse(): void\n {\n // Create a mock response that will throw an exception when toArray is called\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willThrowException(new \\InvalidArgumentException('Invalid JSON'));\n\n // Set up the client to return the problematic response\n $this->client->method('makeRequest')\n ->willReturn($response);\n\n // Mock the logger to expect an error message\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with($this->stringContains('Failed to fetch owners'));\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n // Call the method and expect an empty array on error\n $result = $this->client->getOwnersArchived(true);\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithHttpError(): void\n {\n // Set up the client to throw an exception\n $this->client->method('makeRequest')\n ->willThrowException(new \\Exception('HTTP Error'));\n\n // Mock the logger to expect an error message\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with($this->stringContains('Failed to fetch owners'));\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n // Call the method and expect an empty array on error\n $result = $this->client->getOwnersArchived(false);\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithOwnerCreationException(): void\n {\n $responseData = [\n 'results' => [\n [\n 'id' => '123',\n 'email' => 'valid@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'John',\n 'lastName' => 'Doe',\n 'userId' => 456,\n 'userIdIncludingInactive' => 789,\n 'createdAt' => '2023-01-01T12:00:00Z',\n 'updatedAt' => '2023-01-02T12:00:00Z',\n 'archived' => false,\n ],\n [\n 'id' => '456',\n 'email' => 'invalid@example.com',\n 'type' => 'PERSON',\n 'createdAt' => 'invalid-date-format',\n ],\n [\n 'id' => '789',\n 'email' => 'another@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'Jane',\n 'lastName' => 'Smith',\n 'userId' => 999,\n 'userIdIncludingInactive' => 888,\n 'createdAt' => '2023-01-03T12:00:00Z',\n 'updatedAt' => '2023-01-04T12:00:00Z',\n 'archived' => false,\n ],\n ],\n ];\n\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn($responseData);\n\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=false'\n )\n ->willReturn($response);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with(\n '[HubSpot] Failed to process owner data',\n $this->callback(function ($context) {\n return isset($context['result']) &&\n isset($context['error']) &&\n $context['result']['id'] === '456' &&\n $context['result']['email'] === 'invalid@example.com' &&\n str_contains($context['error'], 'invalid-date-format');\n })\n );\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n $result = $this->client->getOwnersArchived(false);\n\n $this->assertIsArray($result);\n $this->assertCount(2, $result);\n $this->assertEquals('123', $result[0]->getId());\n $this->assertEquals('valid@example.com', $result[0]->getEmail());\n $this->assertEquals('789', $result[1]->getId());\n $this->assertEquals('another@example.com', $result[1]->getEmail());\n }\n\n public function testMakeRequestWithGetMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'GET',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n [],\n null,\n true\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'GET');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithGetMethodAndQueryString(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'GET',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n [],\n 'limit=10&offset=0',\n true\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'GET', [], 'limit=10&offset=0');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPostMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'POST',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'POST', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPutMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'firstname' => 'Jane',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345', 'updated' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'PUT',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'PUT', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPatchMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'email' => 'newemail@example.com',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345', 'patched' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'PATCH',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'PATCH', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithDeleteMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['deleted' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'DELETE',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => []]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'DELETE');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithEmptyPayload(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'ok']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'POST',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n ['json' => []]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'POST', []);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestPrependsBaseUrl(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n $this->anything(),\n 'https://api.hubapi.com/test/endpoint',\n $this->anything()\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $client->makeRequest('/test/endpoint', 'GET');\n }\n\n public function testGetOpportunitiesByIds(): void\n {\n $crmIds = ['deal1', 'deal2', 'deal3'];\n $fields = ['dealname', 'amount'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getOpportunitiesByIds'));\n }\n\n public function testGetCompaniesByIds(): void\n {\n $crmIds = ['company1', 'company2'];\n $fields = ['name', 'industry'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getCompaniesByIds'));\n }\n\n public function testGetContactsByIds(): void\n {\n $crmIds = ['contact1', 'contact2'];\n $fields = ['firstname', 'lastname'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getContactsByIds'));\n }\n\n public function testBatchReadObjectsEmptyIds(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $result = $method->invokeArgs($this->client, ['deals', [], ['dealname']]);\n\n $this->assertEquals([], $result);\n }\n\n public function testBatchReadObjectsExceedsBatchSize(): void\n {\n $crmIds = array_fill(0, 101, 'deal_id'); // 101 IDs, exceeds limit of 100\n\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $this->expectException(\\InvalidArgumentException::class);\n $this->expectExceptionMessage('Batch size cannot exceed 100 deals');\n\n $method->invokeArgs($this->client, ['deals', $crmIds, ['dealname']]);\n }\n\n public function testBatchReadObjectsUnsupportedObjectType(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $this->expectException(\\Jiminny\\Exceptions\\CrmException::class);\n $this->expectExceptionMessage('Failed to batch fetch unsupported');\n\n $method->invokeArgs($this->client, ['unsupported', ['id1'], ['field1']]);\n }\n\n public function testIsUnauthorizedExceptionWithBadRequest401(): void\n {\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\BadRequest('Unauthorized', 401);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithBadRequestNon401(): void\n {\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\BadRequest('Bad Request', 400);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertFalse($result);\n }\n\n public function testIsUnauthorizedExceptionWithGuzzleRequestException(): void\n {\n $response = new Response(401, [], 'Unauthorized');\n $exception = new \\GuzzleHttp\\Exception\\RequestException('Unauthorized', new \\GuzzleHttp\\Psr7\\Request('GET', 'test'), $response);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithGuzzleClientException(): void\n {\n $exception = new \\GuzzleHttp\\Exception\\ClientException('Unauthorized', new \\GuzzleHttp\\Psr7\\Request('GET', 'test'), new Response(401));\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithStringMatching(): void\n {\n $exception = new \\Exception('HTTP 401 Unauthorized error occurred');\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithNonUnauthorized(): void\n {\n $exception = new \\Exception('Some other error');\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertFalse($result);\n }\n\n public function testEnsureValidTokenWithNullOauthAccount(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, null);\n\n // Should not throw exception and not call token manager\n $this->tokenManagerMock->expects($this->never())->method('ensureValidToken');\n\n $this->client->ensureValidToken();\n\n $this->assertTrue(true); // Test passes if no exception is thrown\n }\n\n public function testGetConfig(): void\n {\n $result = $this->client->getConfig();\n\n $this->assertSame($this->config, $result);\n }\n\n public function testGetOwners(): void\n {\n // Test that the method exists and can be called\n $this->assertTrue(method_exists($this->client, 'getOwners'));\n\n // Since getOwners has complex HubSpot API dependencies,\n // we'll just verify the method exists for now\n $this->assertIsCallable([$this->client, 'getOwners']);\n }\n\n public function testCreateMeeting(): void\n {\n $payload = [\n 'properties' => [\n 'hs_meeting_title' => 'Test Meeting',\n 'hs_meeting_start_time' => '2024-01-01T10:00:00Z',\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['id' => 'meeting123']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with('/crm/v3/objects/meetings', 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->createMeeting($payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testUpdateMeeting(): void\n {\n $meetingId = 'meeting123';\n $payload = [\n 'properties' => [\n 'hs_meeting_title' => 'Updated Meeting',\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['id' => $meetingId, 'updated' => true]);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v3/objects/meetings/{$meetingId}\", 'PATCH', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->updateMeeting($meetingId, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testAddAssociations(): void\n {\n $objectType = 'deals';\n $associationType = 'contacts';\n $payload = [\n 'inputs' => [\n ['from' => ['id' => 'deal1'], 'to' => ['id' => 'contact1']],\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['status' => 'success']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v4/associations/{$objectType}/{$associationType}/batch/create\", 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->addAssociations($objectType, $associationType, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testRemoveAssociations(): void\n {\n $objectType = 'deals';\n $associationType = 'contacts';\n $payload = [\n 'inputs' => [\n ['from' => ['id' => 'deal1'], 'to' => ['id' => 'contact1']],\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['status' => 'success']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v4/associations/{$objectType}/{$associationType}/batch/archive\", 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->removeAssociations($objectType, $associationType, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n // -------------------------------------------------------------------------\n // search() / executeRequest() tests\n // -------------------------------------------------------------------------\n\n public function testSearchReturnsDecodedArrayOnSuccess(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n\n $this->config->method('getId')->willReturn(42);\n\n $payload = ['filters' => []];\n $responseData = ['results' => [['id' => '1']], 'total' => 1, 'paging' => []];\n $hubspotResponse = new HubspotResponse(new Response(200, [], json_encode($responseData)));\n\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->with('POST', Client::BASE_URL . '/crm/v3/objects/contacts/search', ['json' => $payload])\n ->willReturn($hubspotResponse);\n\n $result = $this->client->search('contacts', $payload);\n\n $this->assertSame($responseData, $result);\n\n Mockery::close();\n }\n\n public function testSearchThrowsRateLimitExceptionWhenCircuitBreakerActive(): void\n {\n $futureTimestamp = (string) (time() + 30);\n\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn($futureTimestamp);\n\n $this->config->method('getId')->willReturn(42);\n\n $this->hubspotClientMock->expects($this->never())->method('request');\n\n $this->expectException(RateLimitException::class);\n $this->expectExceptionMessage('Hubspot rate limit (cached circuit-breaker)');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchThrowsRateLimitExceptionAndSetsNxOnFresh429(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n // NX + TTL option array — exact TTL depends on parseRetryAfter, verified separately\n $redisMock->shouldReceive('set')\n ->once()\n ->with(\n 'hubspot:ratelimit:config:42',\n Mockery::type('string'),\n Mockery::on(fn ($opts) => is_array($opts) && in_array('nx', $opts, true))\n );\n\n $this->config->method('getId')->willReturn(42);\n\n $badRequest = new BadRequest('Rate limit exceeded', 429);\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->willThrowException($badRequest);\n\n $this->expectException(RateLimitException::class);\n $this->expectExceptionMessage('Hubspot returned 429');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchPropagatesNonRateLimitException(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n\n $this->config->method('getId')->willReturn(42);\n\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\HubspotException('Server error', 500);\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->willThrowException($exception);\n\n $this->expectException(\\SevenShores\\Hubspot\\Exceptions\\HubspotException::class);\n $this->expectExceptionMessage('Server error');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchCircuitBreakerRetryAfterComputedFromStoredTimestamp(): void\n {\n $remaining = 45;\n $futureTimestamp = (string) (time() + $remaining);\n\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn($futureTimestamp);\n\n $this->config->method('getId')->willReturn(1);\n\n try {\n $this->client->search('deals', []);\n $this->fail('Expected RateLimitException');\n } catch (RateLimitException $e) {\n $this->assertGreaterThanOrEqual(1, $e->getRetryAfter());\n $this->assertLessThanOrEqual($remaining, $e->getRetryAfter());\n } finally {\n Mockery::close();\n }\n }\n\n // -------------------------------------------------------------------------\n // isHubspotRateLimit() tests (private method — tested via reflection)\n // -------------------------------------------------------------------------\n\n private function callIsHubspotRateLimit(\\Throwable $e): bool\n {\n $method = new \\ReflectionMethod($this->client, 'isHubspotRateLimit');\n $method->setAccessible(true);\n\n return $method->invoke($this->client, $e);\n }\n\n public function testIsHubspotRateLimitReturnsTrueForBadRequest429(): void\n {\n $e = new BadRequest('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForDealApiException429(): void\n {\n $e = new DealApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForContactApiException429(): void\n {\n $e = new ContactApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForCompanyApiException429(): void\n {\n $e = new CompanyApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForGuzzleRequestException429(): void\n {\n $request = new \\GuzzleHttp\\Psr7\\Request('POST', 'https://api.hubapi.com/test');\n $response = new \\GuzzleHttp\\Psr7\\Response(429);\n $e = new \\GuzzleHttp\\Exception\\RequestException('Too Many Requests', $request, $response);\n\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsFalseForBadRequestNon429(): void\n {\n $e = new BadRequest('Bad Request', 400);\n $this->assertFalse($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsFalseForGenericException(): void\n {\n $e = new \\RuntimeException('Something went wrong');\n $this->assertFalse($this->callIsHubspotRateLimit($e));\n }\n\n // -------------------------------------------------------------------------\n // parseRetryAfter() tests (private method — tested via reflection)\n // -------------------------------------------------------------------------\n\n private function callParseRetryAfter(\\Throwable $e): int\n {\n $method = new \\ReflectionMethod($this->client, 'parseRetryAfter');\n $method->setAccessible(true);\n\n return $method->invoke($this->client, $e);\n }\n\n public function testParseRetryAfterReadsRetryAfterHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => ['30']]);\n $this->assertSame(30, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReadsLowercaseRetryAfterHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['retry-after' => ['15']]);\n $this->assertSame(15, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterHandlesScalarHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => '60']);\n $this->assertSame(60, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsDailyLimitDelay(): void\n {\n $e = new BadRequest('Daily rate limit exceeded');\n $this->assertSame(600, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsTenSecondlyDelay(): void\n {\n $e = new BadRequest('Ten secondly rate limit exceeded');\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsSecondlyDelay(): void\n {\n $e = new BadRequest('Secondly rate limit exceeded');\n $this->assertSame(1, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsDefaultWhenNoHint(): void\n {\n $e = new BadRequest('Rate limit exceeded');\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterIgnoresNonNumericHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => ['not-a-number']]);\n // Falls through to message parsing; \"Too Many Requests\" has no known keyword → default 10\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n}","depth":4,"bounds":{"left":0.124667555,"top":0.17158818,"width":0.42852393,"height":0.8284118},"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Hubspot;\n\nuse GuzzleHttp\\Psr7\\Response;\nuse HubSpot\\Client\\Crm\\Associations\\Api\\BatchApi;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\BatchResponsePublicAssociationMulti;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\PublicObjectId;\nuse HubSpot\\Client\\Crm\\Companies\\ApiException as CompanyApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\ApiException as ContactApiException;\nuse HubSpot\\Client\\Crm\\Deals\\Api\\BasicApi as DealsBasicApi;\nuse HubSpot\\Client\\Crm\\Deals\\ApiException as DealApiException;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectWithAssociations as DealWithAssociations;\nuse HubSpot\\Client\\Crm\\Pipelines\\Api\\PipelinesApi;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\CollectionResponsePipeline;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Error;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Pipeline;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\PipelineStage;\nuse HubSpot\\Client\\Crm\\Properties\\Api\\CoreApi;\nuse HubSpot\\Client\\Crm\\Properties\\Model\\Property;\nuse HubSpot\\Discovery as HubSpotDiscovery;\nuse HubSpot\\Discovery\\Crm\\Deals\\Discovery as DealsDiscovery;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\HubspotTokenManager;\nuse Jiminny\\Services\\Crm\\Hubspot\\Pagination\\HubspotPaginationService;\nuse Jiminny\\Services\\SocialAccountService;\nuse League\\OAuth2\\Client\\Token\\AccessToken;\nuse Mockery;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse PHPUnit\\Framework\\TestCase;\nuse Psr\\Log\\NullLogger;\nuse SevenShores\\Hubspot\\Endpoints\\Engagements;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Factory;\nuse SevenShores\\Hubspot\\Http\\Client as HubspotClient;\nuse SevenShores\\Hubspot\\Http\\Response as HubspotResponse;\n\n/**\n * @runTestsInSeparateProcesses\n *\n * @preserveGlobalState disabled\n */\nclass ClientTest extends TestCase\n{\n private const string RESPONSE_TYPE_STAGE_FIELD = 'stage';\n private const string RESPONSE_TYPE_PIPELINE_FIELD = 'pipeline';\n private const string RESPONSE_TYPE_REGULAR_FIELD = 'regular';\n /**\n * @var Client&MockObject\n */\n private Client $client;\n private Configuration $config;\n\n /**\n * @var SocialAccountService&MockObject\n */\n private SocialAccountService $socialAccountServiceMock;\n\n /**\n * @var HubspotPaginationService&MockObject\n */\n private HubspotPaginationService $paginationServiceMock;\n\n /**\n * @var HubspotTokenManager&MockObject\n */\n private HubspotTokenManager $tokenManagerMock;\n\n /**\n * @var CoreApi&MockObject\n */\n private CoreApi $coreApiMock;\n\n /**\n * @var PipelinesApi&MockObject\n */\n private PipelinesApi $pipelinesApiMock;\n\n /**\n * @var BatchApi&MockObject\n */\n private BatchApi $associationsBatchApiMock;\n\n /**\n * @var DealsBasicApi&MockObject\n */\n private DealsBasicApi $dealsBasicApiMock;\n\n /**\n * @var Engagements&MockObject\n */\n private $engagementsMock;\n\n /**\n * @var HubspotClient&MockObject\n */\n private HubspotClient $hubspotClientMock;\n\n protected function setUp(): void\n {\n // Create mocks for dependencies\n $this->socialAccountServiceMock = $this->createMock(SocialAccountService::class);\n $this->paginationServiceMock = $this->createMock(HubspotPaginationService::class);\n $this->tokenManagerMock = $this->createMock(HubspotTokenManager::class);\n\n // Create a real Client instance with mocked dependencies\n // Create a partial mock only for the methods we need to mock\n $client = $this->createPartialMock(Client::class, ['getInstance', 'getNewInstance', 'makeRequest']);\n $client->setLogger(new NullLogger());\n\n // Inject the real dependencies using reflection\n $reflection = new \\ReflectionClass(Client::class);\n\n $accountServiceProperty = $reflection->getProperty('accountService');\n $accountServiceProperty->setAccessible(true);\n $accountServiceProperty->setValue($client, $this->socialAccountServiceMock);\n\n $paginationServiceProperty = $reflection->getProperty('paginationService');\n $paginationServiceProperty->setAccessible(true);\n $paginationServiceProperty->setValue($client, $this->paginationServiceMock);\n\n $tokenManagerProperty = $reflection->getProperty('tokenManager');\n $tokenManagerProperty->setAccessible(true);\n $tokenManagerProperty->setValue($client, $this->tokenManagerMock);\n $factoryMock = $this->createMock(Factory::class);\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $engagementsMock = $this->createMock(\\SevenShores\\Hubspot\\Endpoints\\Engagements::class);\n\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n $factoryMock->method('__call')->with('engagements')->willReturn($engagementsMock);\n\n $client->method('getInstance')->willReturn($factoryMock);\n\n $discoveryMock = $this->createMock(HubSpotDiscovery\\Discovery::class);\n $crmMock = $this->createMock(HubSpotDiscovery\\Crm\\Discovery::class);\n $associationsMock = $this->createMock(HubSpotDiscovery\\Crm\\Associations\\Discovery::class);\n $propertiesMock = $this->createMock(HubSpotDiscovery\\Crm\\Properties\\Discovery::class);\n $pipelinesMock = $this->createMock(HubSpotDiscovery\\Crm\\Pipelines\\Discovery::class);\n $coreApiMock = $this->createMock(CoreApi::class);\n $pipelinesApiMock = $this->createMock(PipelinesApi::class);\n $associationsBatchApiMock = $this->createMock(BatchApi::class);\n $dealsBasicApiMock = $this->createMock(DealsBasicApi::class);\n\n $propertiesMock->method('__call')->with('coreApi')->willReturn($coreApiMock);\n $pipelinesMock->method('__call')->with('pipelinesApi')->willReturn($pipelinesApiMock);\n $associationsMock->method('__call')->with('batchApi')->willReturn($associationsBatchApiMock);\n $dealsDiscoveryMock = $this->createMock(DealsDiscovery::class);\n $dealsDiscoveryMock->method('__call')->with('basicApi')->willReturn($dealsBasicApiMock);\n\n $returnMap = ['properties' => $propertiesMock, 'pipelines' => $pipelinesMock, 'associations' => $associationsMock, 'deals' => $dealsDiscoveryMock];\n $crmMock->method('__call')\n ->willReturnCallback(static fn (string $name) => $returnMap[$name])\n ;\n\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n\n $this->client = $client;\n $this->config = $this->createMock(Configuration::class);\n $this->client->setConfiguration($this->config);\n $this->coreApiMock = $coreApiMock;\n $this->pipelinesApiMock = $pipelinesApiMock;\n $this->associationsBatchApiMock = $associationsBatchApiMock;\n $this->dealsBasicApiMock = $dealsBasicApiMock;\n $this->engagementsMock = $engagementsMock;\n $this->hubspotClientMock = $hubspotClientMock;\n }\n\n public function testGetMinimumApiVersion(): void\n {\n $this->assertIsString($this->client->getMinimumApiVersion());\n }\n\n public function testGetInstance(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $client = new Client($socialAccountService, $paginationService, $tokenManager);\n $client->setBaseUrl('https://example.com');\n $client->setAccessToken(new AccessToken(['access_token' => 'foo']));\n $factory = $client->getInstance();\n\n $this->assertEquals('foo', $factory->getClient()->key);\n }\n\n public function testGetNewInstance(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $client = new Client($socialAccountService, $paginationService, $tokenManager);\n $client->setAccessToken(new AccessToken(['access_token' => 'foo']));\n\n $factory = $client->getNewInstance();\n $token = $factory->auth()->oAuth()->accessTokensApi()->getConfig()->getAccessToken();\n $this->assertEquals('foo', $token);\n }\n\n public function testFetchProperty(): void\n {\n $this->coreApiMock->method('getByName')->willReturn($this->generateProperty());\n\n $this->assertEquals(\n 'some_property',\n $this->client->fetchProperty('foo', 'test')['name']\n );\n }\n\n public function testFetchPropertyOptions(): void\n {\n $this->coreApiMock->method('getByName')->willReturn($this->generateProperty());\n\n $this->assertEquals(\n [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n $this->client->fetchPropertyOptions('foo', 'test')\n );\n }\n\n public function testFetchCallDispositions(): void\n {\n $this->engagementsMock\n ->method('getCallDispositions')\n ->willReturn($this->generateHubSpotResponse([\n [\n 'id' => 1,\n 'label' => 'foo',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'bar',\n 'deleted' => false,\n ],\n ]))\n ;\n\n $this->assertEquals(\n [\n [\n 'id' => 1,\n 'label' => 'foo',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'bar',\n 'deleted' => false,\n ],\n ],\n $this->client->fetchCallDispositions()\n );\n }\n\n public function testFetchDispositionFieldOptions(): void\n {\n $this->engagementsMock->method('getCallDispositions')\n ->willReturn($this->generateHubSpotResponse(\n [\n [\n 'id' => 1,\n 'label' => 'Label 1',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'Label 2',\n 'deleted' => true,\n ],\n ]\n ))\n ;\n\n $this->assertEquals(\n [\n [\n 'value' => 1,\n 'id' => 1,\n 'label' => 'Label 1',\n ],\n ],\n $this->client->fetchDispositionFieldOptions()\n );\n }\n\n public function testFetchOpportunityPipelineStages(): void\n {\n /**\n * @TODO: The class CollectionResponsePipeline will be deprecated in the next Hubspot version\n */\n $this->pipelinesApiMock\n ->method('getAll')\n ->with('deals')\n ->willReturn(new CollectionResponsePipeline([\n 'results' => [$this->generatePipeline()],\n ]))\n ;\n\n $this->assertEquals(\n [\n ['id' => 'foo', 'label' => 'bar'],\n ['id' => 'baz', 'label' => 'qux'],\n ],\n $this->client->fetchOpportunityPipelineStages()\n );\n }\n\n public function testFetchOpportunityPipelineStagesErrorResponse(): void\n {\n $this->pipelinesApiMock\n ->method('getAll')\n ->with('deals')\n ->willReturn(new Error(['message' => 'test error']))\n ;\n\n $this->assertEmpty($this->client->fetchOpportunityPipelineStages());\n }\n\n #[\\PHPUnit\\Framework\\Attributes\\DataProvider('meetingOutcomeFieldProvider')]\n public function testFetchMeetingOutcomeFieldOptions(string $fieldId, string $expectedEndpoint): void\n {\n $field = new Field(['crm_provider_id' => $fieldId]);\n\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->with('GET', $expectedEndpoint)\n ->willReturn($this->generateHubSpotResponse(\n [\n 'options' => [\n ['value' => 'option_1', 'label' => 'Option 1', 'displayOrder' => 0],\n ['value' => 'option_2', 'label' => 'Option 2', 'displayOrder' => 1],\n ['value' => 'option_3', 'label' => 'Option 3', 'displayOrder' => 2],\n ],\n ]\n ))\n ;\n\n $this->assertEquals(\n [\n ['id' => 'option_1', 'value' => 'option_1', 'label' => 'Option 1', 'display_order' => 0],\n ['id' => 'option_2', 'value' => 'option_2', 'label' => 'Option 2', 'display_order' => 1],\n ['id' => 'option_3', 'value' => 'option_3', 'label' => 'Option 3', 'display_order' => 2],\n ],\n $this->client->fetchMeetingOutcomeFieldOptions($field)\n );\n }\n\n public static function meetingOutcomeFieldProvider(): array\n {\n return [\n 'meeting outcome field' => [\n 'meetingOutcome',\n 'https://api.hubapi.com/crm/v3/properties/meeting/hs_meeting_outcome',\n ],\n 'activity type field' => [\n 'foobar',\n 'https://api.hubapi.com/crm/v3/properties/call/hs_activity_type',\n ],\n ];\n }\n\n #[\\PHPUnit\\Framework\\Attributes\\DataProvider('opportunityFieldOptionsProvider')]\n public function testFetchOpportunityFieldOptions(Field $field, string $type): void\n {\n $responses = [\n self::RESPONSE_TYPE_STAGE_FIELD => [\n ['id' => 'foo', 'label' => 'bar'],\n ['id' => 'baz', 'label' => 'qux'],\n ],\n self::RESPONSE_TYPE_PIPELINE_FIELD => [\n ['id' => '123', 'label' => 'Sales'],\n ['id' => 'default', 'label' => 'CS'],\n ],\n self::RESPONSE_TYPE_REGULAR_FIELD => [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n ];\n\n $field = $this->createMock(Field::class);\n\n if ($type === self::RESPONSE_TYPE_REGULAR_FIELD) {\n $field->method('isStageField')->willReturn(false);\n $field->method('isPipelineField')->willReturn(false);\n $propertyResponse = $this->generateProperty();\n $this->coreApiMock->method('getByName')->willReturn($propertyResponse);\n }\n\n if ($type === self::RESPONSE_TYPE_STAGE_FIELD) {\n $field->method('isStageField')->willReturn(true);\n /**\n * @TODO: The class CollectionResponsePipeline will be deprecated in the next Hubspot version\n */\n\n $pipelineStagesResponse = new CollectionResponsePipeline([\n 'results' => [$this->generatePipeline()],\n ]);\n $this->pipelinesApiMock\n ->method('getAll')\n ->willReturn($pipelineStagesResponse);\n }\n\n if ($type === self::RESPONSE_TYPE_PIPELINE_FIELD) {\n $field->method('isStageField')->willReturn(false);\n $field->method('isPipelineField')->willReturn(true);\n $pipelineResponse = $this->generateHubSpotResponse([\n 'results' => [\n ['id' => '123', 'label' => 'Sales'],\n ['id' => 'default', 'label' => 'CS'],\n ],\n ]);\n $this->client\n ->method('makeRequest')\n ->with('/crm/v3/pipelines/deals')\n ->willReturn($pipelineResponse);\n }\n\n $this->assertEquals(\n $responses[$type],\n $this->client->fetchOpportunityFieldOptions($field)\n );\n }\n\n public static function opportunityFieldOptionsProvider(): array\n {\n return [\n 'stage field' => [\n 'field' => new Field(['crm_provider_id' => 'dealstage']),\n 'type' => self::RESPONSE_TYPE_STAGE_FIELD,\n ],\n 'pipeline field' => [\n 'field' => new Field(['crm_provider_id' => 'pipeline']),\n 'type' => self::RESPONSE_TYPE_PIPELINE_FIELD,\n ],\n 'regular field' => [\n 'field' => new Field(['crm_provider_id' => 'some_property']),\n 'type' => self::RESPONSE_TYPE_REGULAR_FIELD,\n ],\n ];\n }\n\n private function generateHubSpotResponse(array $data): HubspotResponse\n {\n return new HubspotResponse(new Response(200, [], json_encode($data)));\n }\n\n private function generateProperty(): Property\n {\n return new Property([\n 'name' => 'some_property',\n 'options' => [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n ]);\n }\n\n private function generatePipeline(): Pipeline\n {\n return new Pipeline(['stages' => [\n new PipelineStage(['id' => 'foo', 'label' => 'bar']),\n new PipelineStage(['id' => 'baz', 'label' => 'qux']),\n ]]);\n }\n\n public function testFetchOpportunityPipelines(): void\n {\n $this->client\n ->method('makeRequest')\n ->with('/crm/v3/pipelines/deals')\n ->willReturn($this->generateHubSpotResponse([\n 'results' => [\n ['id' => 'id_1', 'label' => 'Option 1', 'displayOrder' => 0],\n ['id' => 'id_2', 'label' => 'Option 2', 'displayOrder' => 1],\n ['id' => 'id_3', 'label' => 'Option 3', 'displayOrder' => 2],\n ],\n ]));\n\n $this->assertEquals(\n [\n ['id' => 'id_1', 'label' => 'Option 1'],\n ['id' => 'id_2', 'label' => 'Option 2'],\n ['id' => 'id_3', 'label' => 'Option 3'],\n ],\n $this->client->fetchOpportunityPipelines()\n );\n }\n\n public function testGetPaginatedData(): void\n {\n $expectedResults = [\n ['id' => 'id_1', 'properties' => []],\n ['id' => 'id_2', 'properties' => []],\n ['id' => 'id_3', 'properties' => []],\n ];\n\n // Mock the pagination service to return a generator and modify reference parameters\n $this->paginationServiceMock\n ->expects($this->once())\n ->method('getPaginatedDataGenerator')\n ->with(\n $this->client,\n ['payload_key' => 'payload_value'],\n 'foobar',\n 0,\n $this->anything(),\n $this->anything()\n )\n ->willReturnCallback(function ($client, $payload, $type, $offset, &$total, &$lastRecordId) use ($expectedResults) {\n $total = 3;\n $lastRecordId = 'id_3';\n foreach ($expectedResults as $result) {\n yield $result;\n }\n });\n\n $this->assertEquals(\n [\n 'results' => $expectedResults,\n 'total' => 3,\n 'last_record' => 'id_3',\n ],\n $this->client->getPaginatedData(['payload_key' => 'payload_value'], 'foobar')\n );\n }\n\n public function testGetAssociationsData(): void\n {\n $ids = ['1', '2', '3'];\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $responseResults = [];\n foreach ($ids as $id) {\n $from = new PublicObjectId();\n $from->setId($id);\n\n $to1 = new PublicObjectId();\n $to1->setId('contact_' . $id . '_1');\n $to2 = new PublicObjectId();\n $to2->setId('contact_' . $id . '_2');\n\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to1, $to2]);\n\n $responseResults[] = $result;\n }\n\n $batchResponse = new BatchResponsePublicAssociationMulti();\n $batchResponse->setResults($responseResults);\n\n $this->associationsBatchApiMock->expects($this->once())\n ->method('read')\n ->with(\n $this->equalTo($fromObject),\n $this->equalTo($toObject),\n $this->callback(function (BatchInputPublicObjectId $batchInput) use ($ids) {\n $inputIds = array_map(\n fn ($input) => $input->getId(),\n $batchInput->getInputs()\n );\n\n return $inputIds === $ids;\n })\n )\n ->willReturn($batchResponse);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $expectedResult = [\n '1' => ['contact_1_1', 'contact_1_2'],\n '2' => ['contact_2_1', 'contact_2_2'],\n '3' => ['contact_3_1', 'contact_3_2'],\n ];\n\n $this->assertEquals($expectedResult, $result);\n }\n\n public function testGetAssociationsDataHandlesException(): void\n {\n $ids = ['1', '2', '3'];\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $exception = new \\Exception('API Error');\n\n $this->associationsBatchApiMock->expects($this->once())\n ->method('read')\n ->with(\n $this->equalTo($fromObject),\n $this->equalTo($toObject),\n $this->callback(function ($batchInput) use ($ids) {\n return $batchInput instanceof \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId\n && count($batchInput->getInputs()) === count($ids);\n })\n )\n ->willThrowException($exception);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with(\n '[Hubspot] Failed to fetch associations',\n [\n 'from_object' => $fromObject,\n 'to_object' => $toObject,\n 'reason' => 'API Error',\n ]\n );\n\n $this->client->setLogger($loggerMock);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $this->assertEmpty($result);\n $this->assertIsArray($result);\n }\n\n public function testGetAssociationsDataWithLargeDataSet(): void\n {\n $ids = array_map(fn ($i) => (string) $i, range(1, 2500)); // More than 1000 items\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $firstBatchResponse = new BatchResponsePublicAssociationMulti();\n $firstBatchResults = array_map(function ($id) {\n $from = new PublicObjectId();\n $from->setId($id);\n $to = new PublicObjectId();\n $to->setId('contact_' . $id);\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to]);\n\n return $result;\n }, array_slice($ids, 0, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));\n $firstBatchResponse->setResults($firstBatchResults);\n\n $secondBatchResponse = new BatchResponsePublicAssociationMulti();\n $secondBatchResults = array_map(function ($id) {\n $from = new PublicObjectId();\n $from->setId($id);\n $to = new PublicObjectId();\n $to->setId('contact_' . $id);\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to]);\n\n return $result;\n }, array_slice($ids, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));\n $secondBatchResponse->setResults($secondBatchResults);\n\n $this->associationsBatchApiMock->expects($this->exactly(3))\n ->method('read')\n ->willReturnOnConsecutiveCalls($firstBatchResponse, $secondBatchResponse);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $this->assertCount(2500, $result);\n $this->assertArrayHasKey('1', $result);\n $this->assertArrayHasKey('2500', $result);\n $this->assertEquals(['contact_1'], $result['1']);\n $this->assertEquals(['contact_2500'], $result['2500']);\n }\n\n public function testGetContactByEmailSuccess(): void\n {\n $email = 'test@example.com';\n $fields = ['firstname', 'lastname', 'email'];\n\n $contactMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $contactMock->method('getId')->willReturn('12345');\n $contactMock->method('getProperties')->willReturn([\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n 'email' => 'test@example.com',\n ]);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, 'firstname,lastname,email', null, false, 'email')\n ->willReturn($contactMock);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new NullLogger());\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([\n 'id' => '12345',\n 'properties' => [\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n 'email' => 'test@example.com',\n ],\n ], $result);\n }\n\n public function testGetContactByEmailWithEmptyFields(): void\n {\n $email = 'test@example.com';\n $fields = [];\n\n $contactMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $contactMock->method('getId')->willReturn('12345');\n $contactMock->method('getProperties')->willReturn(['email' => 'test@example.com']);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, '', null, false, 'email')\n ->willReturn($contactMock);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new NullLogger());\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([\n 'id' => '12345',\n 'properties' => ['email' => 'test@example.com'],\n ], $result);\n }\n\n public function testGetContactByEmailApiException(): void\n {\n $email = 'nonexistent@example.com';\n $fields = ['firstname', 'lastname'];\n\n $exception = new \\HubSpot\\Client\\Crm\\Contacts\\ApiException('Contact not found', 404);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, 'firstname,lastname', null, false, 'email')\n ->willThrowException($exception);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('info')\n ->with(\n '[Hubspot] Failed to fetch contact',\n [\n 'email' => $email,\n 'reason' => 'Contact not found',\n ]\n );\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger($loggerMock);\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([], $result);\n }\n\n public function testGetOpportunityById(): void\n {\n $opportunityId = '12345';\n $expectedProperties = [\n 'dealname' => 'Test Opportunity',\n 'amount' => '1000.00',\n 'closedate' => '2024-12-31T23:59:59.999Z',\n 'dealstage' => 'presentationscheduled',\n 'pipeline' => 'default',\n ];\n\n $mockHubspotOpportunity = $this->createMock(DealWithAssociations::class);\n $mockHubspotOpportunity->method('getProperties')->willReturn((object) $expectedProperties);\n $mockHubspotOpportunity->method('getId')->willReturn($opportunityId);\n $now = new \\DateTimeImmutable();\n $mockHubspotOpportunity->method('getCreatedAt')->willReturn($now);\n $mockHubspotOpportunity->method('getUpdatedAt')->willReturn($now);\n $mockHubspotOpportunity->method('getArchived')->willReturn(false);\n\n $this->dealsBasicApiMock\n ->expects($this->once())\n ->method('getById')\n ->willReturn($mockHubspotOpportunity);\n\n // Assuming Client::getOpportunityById processes the SimplePublicObject and returns an associative array.\n // The structure might be like: ['id' => ..., 'properties' => [...], 'createdAt' => ..., ...]\n // Adjust assertions below based on the actual return structure of your method.\n $result = $this->client->getOpportunityById($opportunityId, ['test', 'test']);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertEquals($opportunityId, $result['id']);\n }\n\n public function testGetContactById(): void\n {\n $crmId = 'contact-123';\n $fields = ['firstname', 'lastname'];\n $expectedProperties = ['firstname' => 'John', 'lastname' => 'Doe'];\n\n $mockContact = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $mockContact->method('getId')->willReturn($crmId);\n $mockContact->method('getProperties')->willReturn((object) $expectedProperties);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($crmId, 'firstname,lastname')\n ->willReturn($mockContact);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new \\Psr\\Log\\NullLogger());\n\n $result = $client->getContactById($crmId, $fields);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertArrayHasKey('properties', $result);\n $this->assertEquals($crmId, $result['id']);\n $this->assertEquals((object) $expectedProperties, $result['properties']);\n }\n\n public function testGetAccountById(): void\n {\n $crmId = 'account-123';\n $fields = ['name', 'industry'];\n $expectedProperties = ['name' => 'Acme Corp', 'industry' => 'Technology'];\n\n $mockCompany = $this->createMock(\\HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectWithAssociations::class);\n $mockCompany->method('getId')->willReturn($crmId);\n $mockCompany->method('getProperties')->willReturn((object) $expectedProperties);\n\n $companiesApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Companies\\Api\\BasicApi::class);\n $companiesApiMock->expects($this->once())\n ->method('getById')\n ->with($crmId, 'name,industry')\n ->willReturn($mockCompany);\n\n $companiesDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Companies\\Discovery::class);\n $companiesDiscoveryMock->method('__call')->with('basicApi')->willReturn($companiesApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($companiesDiscoveryMock) {\n if ($name === 'companies') {\n return $companiesDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new \\Psr\\Log\\NullLogger());\n\n $result = $client->getAccountById($crmId, $fields);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertArrayHasKey('properties', $result);\n $this->assertEquals($crmId, $result['id']);\n $this->assertEquals((object) $expectedProperties, $result['properties']);\n }\n\n public function testEnsureValidTokenWithNoTokenUpdate(): void\n {\n $socialAccountMock = $this->createMock(SocialAccount::class);\n\n // Set up OAuth account\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, $socialAccountMock);\n\n $originalToken = 'original_token';\n $accessTokenProperty = $reflection->getProperty('accessToken');\n $accessTokenProperty->setAccessible(true);\n $accessTokenProperty->setValue($this->client, $originalToken);\n\n // Mock token manager to return null (no refresh needed)\n $this->tokenManagerMock\n ->expects($this->once())\n ->method('ensureValidToken')\n ->with($socialAccountMock)\n ->willReturn(null);\n\n // Call ensureValidToken\n $this->client->ensureValidToken();\n\n // Verify access token was not changed\n $this->assertEquals($originalToken, $accessTokenProperty->getValue($this->client));\n }\n\n\n\n\n\n\n public function testGetPaginatedDataGeneratorDelegatesToPaginationService(): void\n {\n $payload = ['filters' => []];\n $type = 'contacts';\n $offset = 0;\n $total = 0;\n $lastRecordId = null;\n\n $expectedResults = [\n ['id' => 'id_1', 'properties' => []],\n ['id' => 'id_2', 'properties' => []],\n ];\n\n // Mock the pagination service to return a generator\n $this->paginationServiceMock\n ->expects($this->once())\n ->method('getPaginatedDataGenerator')\n ->with(\n $this->client,\n $payload,\n $type,\n $offset,\n $this->anything(),\n $this->anything()\n )\n ->willReturnCallback(function () use ($expectedResults) {\n foreach ($expectedResults as $result) {\n yield $result;\n }\n });\n\n // Execute the pagination\n $results = [];\n foreach ($this->client->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastRecordId) as $result) {\n $results[] = $result;\n }\n\n $this->assertCount(2, $results);\n $this->assertEquals('id_1', $results[0]['id']);\n $this->assertEquals('id_2', $results[1]['id']);\n }\n\n public function testEnsureValidTokenDelegatesToTokenManager(): void\n {\n $socialAccountMock = $this->createMock(SocialAccount::class);\n\n // Set up OAuth account\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, $socialAccountMock);\n\n // Mock token manager to return new token\n $this->tokenManagerMock\n ->expects($this->once())\n ->method('ensureValidToken')\n ->with($socialAccountMock)\n ->willReturn('new_access_token');\n\n // Call ensureValidToken\n $this->client->ensureValidToken();\n\n // Verify access token was updated\n $accessTokenProperty = $reflection->getProperty('accessToken');\n $accessTokenProperty->setAccessible(true);\n $this->assertEquals('new_access_token', $accessTokenProperty->getValue($this->client));\n }\n\n public function testGetOwnersArchivedWithValidResponse(): void\n {\n $responseData = [\n 'results' => [\n [\n 'id' => '123',\n 'email' => 'owner1@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'John',\n 'lastName' => 'Doe',\n 'userId' => 456,\n 'userIdIncludingInactive' => 789,\n 'createdAt' => '2023-01-01T12:00:00Z',\n 'updatedAt' => '2023-01-02T12:00:00Z',\n 'archived' => true,\n ],\n ],\n ];\n\n // Create a mock response object\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn($responseData);\n\n // Set up the client to return our test data\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=true'\n )\n ->willReturn($response);\n\n // Call the method\n $result = $this->client->getOwnersArchived(true);\n\n // Assert the results\n $this->assertCount(1, $result);\n $this->assertEquals('123', $result[0]->getId());\n $this->assertEquals('owner1@example.com', $result[0]->getEmail());\n $this->assertEquals('John Doe', $result[0]->getFullName());\n $this->assertTrue($result[0]->isArchived());\n }\n\n public function testGetOwnersArchivedWithEmptyResponse(): void\n {\n // Create a mock response object with empty results\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn(['results' => []]);\n\n // Set up the client to return empty results\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=false'\n )\n ->willReturn($response);\n\n // Call the method\n $result = $this->client->getOwnersArchived(false);\n\n // Assert the results\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithInvalidResponse(): void\n {\n // Create a mock response that will throw an exception when toArray is called\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willThrowException(new \\InvalidArgumentException('Invalid JSON'));\n\n // Set up the client to return the problematic response\n $this->client->method('makeRequest')\n ->willReturn($response);\n\n // Mock the logger to expect an error message\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with($this->stringContains('Failed to fetch owners'));\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n // Call the method and expect an empty array on error\n $result = $this->client->getOwnersArchived(true);\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithHttpError(): void\n {\n // Set up the client to throw an exception\n $this->client->method('makeRequest')\n ->willThrowException(new \\Exception('HTTP Error'));\n\n // Mock the logger to expect an error message\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with($this->stringContains('Failed to fetch owners'));\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n // Call the method and expect an empty array on error\n $result = $this->client->getOwnersArchived(false);\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithOwnerCreationException(): void\n {\n $responseData = [\n 'results' => [\n [\n 'id' => '123',\n 'email' => 'valid@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'John',\n 'lastName' => 'Doe',\n 'userId' => 456,\n 'userIdIncludingInactive' => 789,\n 'createdAt' => '2023-01-01T12:00:00Z',\n 'updatedAt' => '2023-01-02T12:00:00Z',\n 'archived' => false,\n ],\n [\n 'id' => '456',\n 'email' => 'invalid@example.com',\n 'type' => 'PERSON',\n 'createdAt' => 'invalid-date-format',\n ],\n [\n 'id' => '789',\n 'email' => 'another@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'Jane',\n 'lastName' => 'Smith',\n 'userId' => 999,\n 'userIdIncludingInactive' => 888,\n 'createdAt' => '2023-01-03T12:00:00Z',\n 'updatedAt' => '2023-01-04T12:00:00Z',\n 'archived' => false,\n ],\n ],\n ];\n\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn($responseData);\n\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=false'\n )\n ->willReturn($response);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with(\n '[HubSpot] Failed to process owner data',\n $this->callback(function ($context) {\n return isset($context['result']) &&\n isset($context['error']) &&\n $context['result']['id'] === '456' &&\n $context['result']['email'] === 'invalid@example.com' &&\n str_contains($context['error'], 'invalid-date-format');\n })\n );\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n $result = $this->client->getOwnersArchived(false);\n\n $this->assertIsArray($result);\n $this->assertCount(2, $result);\n $this->assertEquals('123', $result[0]->getId());\n $this->assertEquals('valid@example.com', $result[0]->getEmail());\n $this->assertEquals('789', $result[1]->getId());\n $this->assertEquals('another@example.com', $result[1]->getEmail());\n }\n\n public function testMakeRequestWithGetMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'GET',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n [],\n null,\n true\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'GET');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithGetMethodAndQueryString(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'GET',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n [],\n 'limit=10&offset=0',\n true\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'GET', [], 'limit=10&offset=0');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPostMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'POST',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'POST', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPutMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'firstname' => 'Jane',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345', 'updated' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'PUT',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'PUT', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPatchMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'email' => 'newemail@example.com',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345', 'patched' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'PATCH',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'PATCH', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithDeleteMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['deleted' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'DELETE',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => []]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'DELETE');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithEmptyPayload(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'ok']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'POST',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n ['json' => []]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'POST', []);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestPrependsBaseUrl(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n $this->anything(),\n 'https://api.hubapi.com/test/endpoint',\n $this->anything()\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $client->makeRequest('/test/endpoint', 'GET');\n }\n\n public function testGetOpportunitiesByIds(): void\n {\n $crmIds = ['deal1', 'deal2', 'deal3'];\n $fields = ['dealname', 'amount'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getOpportunitiesByIds'));\n }\n\n public function testGetCompaniesByIds(): void\n {\n $crmIds = ['company1', 'company2'];\n $fields = ['name', 'industry'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getCompaniesByIds'));\n }\n\n public function testGetContactsByIds(): void\n {\n $crmIds = ['contact1', 'contact2'];\n $fields = ['firstname', 'lastname'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getContactsByIds'));\n }\n\n public function testBatchReadObjectsEmptyIds(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $result = $method->invokeArgs($this->client, ['deals', [], ['dealname']]);\n\n $this->assertEquals([], $result);\n }\n\n public function testBatchReadObjectsExceedsBatchSize(): void\n {\n $crmIds = array_fill(0, 101, 'deal_id'); // 101 IDs, exceeds limit of 100\n\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $this->expectException(\\InvalidArgumentException::class);\n $this->expectExceptionMessage('Batch size cannot exceed 100 deals');\n\n $method->invokeArgs($this->client, ['deals', $crmIds, ['dealname']]);\n }\n\n public function testBatchReadObjectsUnsupportedObjectType(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $this->expectException(\\Jiminny\\Exceptions\\CrmException::class);\n $this->expectExceptionMessage('Failed to batch fetch unsupported');\n\n $method->invokeArgs($this->client, ['unsupported', ['id1'], ['field1']]);\n }\n\n public function testIsUnauthorizedExceptionWithBadRequest401(): void\n {\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\BadRequest('Unauthorized', 401);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithBadRequestNon401(): void\n {\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\BadRequest('Bad Request', 400);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertFalse($result);\n }\n\n public function testIsUnauthorizedExceptionWithGuzzleRequestException(): void\n {\n $response = new Response(401, [], 'Unauthorized');\n $exception = new \\GuzzleHttp\\Exception\\RequestException('Unauthorized', new \\GuzzleHttp\\Psr7\\Request('GET', 'test'), $response);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithGuzzleClientException(): void\n {\n $exception = new \\GuzzleHttp\\Exception\\ClientException('Unauthorized', new \\GuzzleHttp\\Psr7\\Request('GET', 'test'), new Response(401));\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithStringMatching(): void\n {\n $exception = new \\Exception('HTTP 401 Unauthorized error occurred');\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithNonUnauthorized(): void\n {\n $exception = new \\Exception('Some other error');\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertFalse($result);\n }\n\n public function testEnsureValidTokenWithNullOauthAccount(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, null);\n\n // Should not throw exception and not call token manager\n $this->tokenManagerMock->expects($this->never())->method('ensureValidToken');\n\n $this->client->ensureValidToken();\n\n $this->assertTrue(true); // Test passes if no exception is thrown\n }\n\n public function testGetConfig(): void\n {\n $result = $this->client->getConfig();\n\n $this->assertSame($this->config, $result);\n }\n\n public function testGetOwners(): void\n {\n // Test that the method exists and can be called\n $this->assertTrue(method_exists($this->client, 'getOwners'));\n\n // Since getOwners has complex HubSpot API dependencies,\n // we'll just verify the method exists for now\n $this->assertIsCallable([$this->client, 'getOwners']);\n }\n\n public function testCreateMeeting(): void\n {\n $payload = [\n 'properties' => [\n 'hs_meeting_title' => 'Test Meeting',\n 'hs_meeting_start_time' => '2024-01-01T10:00:00Z',\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['id' => 'meeting123']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with('/crm/v3/objects/meetings', 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->createMeeting($payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testUpdateMeeting(): void\n {\n $meetingId = 'meeting123';\n $payload = [\n 'properties' => [\n 'hs_meeting_title' => 'Updated Meeting',\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['id' => $meetingId, 'updated' => true]);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v3/objects/meetings/{$meetingId}\", 'PATCH', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->updateMeeting($meetingId, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testAddAssociations(): void\n {\n $objectType = 'deals';\n $associationType = 'contacts';\n $payload = [\n 'inputs' => [\n ['from' => ['id' => 'deal1'], 'to' => ['id' => 'contact1']],\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['status' => 'success']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v4/associations/{$objectType}/{$associationType}/batch/create\", 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->addAssociations($objectType, $associationType, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testRemoveAssociations(): void\n {\n $objectType = 'deals';\n $associationType = 'contacts';\n $payload = [\n 'inputs' => [\n ['from' => ['id' => 'deal1'], 'to' => ['id' => 'contact1']],\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['status' => 'success']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v4/associations/{$objectType}/{$associationType}/batch/archive\", 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->removeAssociations($objectType, $associationType, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n // -------------------------------------------------------------------------\n // search() / executeRequest() tests\n // -------------------------------------------------------------------------\n\n public function testSearchReturnsDecodedArrayOnSuccess(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n\n $this->config->method('getId')->willReturn(42);\n\n $payload = ['filters' => []];\n $responseData = ['results' => [['id' => '1']], 'total' => 1, 'paging' => []];\n $hubspotResponse = new HubspotResponse(new Response(200, [], json_encode($responseData)));\n\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->with('POST', Client::BASE_URL . '/crm/v3/objects/contacts/search', ['json' => $payload])\n ->willReturn($hubspotResponse);\n\n $result = $this->client->search('contacts', $payload);\n\n $this->assertSame($responseData, $result);\n\n Mockery::close();\n }\n\n public function testSearchThrowsRateLimitExceptionWhenCircuitBreakerActive(): void\n {\n $futureTimestamp = (string) (time() + 30);\n\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn($futureTimestamp);\n\n $this->config->method('getId')->willReturn(42);\n\n $this->hubspotClientMock->expects($this->never())->method('request');\n\n $this->expectException(RateLimitException::class);\n $this->expectExceptionMessage('Hubspot rate limit (cached circuit-breaker)');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchThrowsRateLimitExceptionAndSetsNxOnFresh429(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n // NX + TTL option array — exact TTL depends on parseRetryAfter, verified separately\n $redisMock->shouldReceive('set')\n ->once()\n ->with(\n 'hubspot:ratelimit:config:42',\n Mockery::type('string'),\n Mockery::on(fn ($opts) => is_array($opts) && in_array('nx', $opts, true))\n );\n\n $this->config->method('getId')->willReturn(42);\n\n $badRequest = new BadRequest('Rate limit exceeded', 429);\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->willThrowException($badRequest);\n\n $this->expectException(RateLimitException::class);\n $this->expectExceptionMessage('Hubspot returned 429');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchPropagatesNonRateLimitException(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n\n $this->config->method('getId')->willReturn(42);\n\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\HubspotException('Server error', 500);\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->willThrowException($exception);\n\n $this->expectException(\\SevenShores\\Hubspot\\Exceptions\\HubspotException::class);\n $this->expectExceptionMessage('Server error');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchCircuitBreakerRetryAfterComputedFromStoredTimestamp(): void\n {\n $remaining = 45;\n $futureTimestamp = (string) (time() + $remaining);\n\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn($futureTimestamp);\n\n $this->config->method('getId')->willReturn(1);\n\n try {\n $this->client->search('deals', []);\n $this->fail('Expected RateLimitException');\n } catch (RateLimitException $e) {\n $this->assertGreaterThanOrEqual(1, $e->getRetryAfter());\n $this->assertLessThanOrEqual($remaining, $e->getRetryAfter());\n } finally {\n Mockery::close();\n }\n }\n\n // -------------------------------------------------------------------------\n // isHubspotRateLimit() tests (private method — tested via reflection)\n // -------------------------------------------------------------------------\n\n private function callIsHubspotRateLimit(\\Throwable $e): bool\n {\n $method = new \\ReflectionMethod($this->client, 'isHubspotRateLimit');\n $method->setAccessible(true);\n\n return $method->invoke($this->client, $e);\n }\n\n public function testIsHubspotRateLimitReturnsTrueForBadRequest429(): void\n {\n $e = new BadRequest('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForDealApiException429(): void\n {\n $e = new DealApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForContactApiException429(): void\n {\n $e = new ContactApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForCompanyApiException429(): void\n {\n $e = new CompanyApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForGuzzleRequestException429(): void\n {\n $request = new \\GuzzleHttp\\Psr7\\Request('POST', 'https://api.hubapi.com/test');\n $response = new \\GuzzleHttp\\Psr7\\Response(429);\n $e = new \\GuzzleHttp\\Exception\\RequestException('Too Many Requests', $request, $response);\n\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsFalseForBadRequestNon429(): void\n {\n $e = new BadRequest('Bad Request', 400);\n $this->assertFalse($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsFalseForGenericException(): void\n {\n $e = new \\RuntimeException('Something went wrong');\n $this->assertFalse($this->callIsHubspotRateLimit($e));\n }\n\n // -------------------------------------------------------------------------\n // parseRetryAfter() tests (private method — tested via reflection)\n // -------------------------------------------------------------------------\n\n private function callParseRetryAfter(\\Throwable $e): int\n {\n $method = new \\ReflectionMethod($this->client, 'parseRetryAfter');\n $method->setAccessible(true);\n\n return $method->invoke($this->client, $e);\n }\n\n public function testParseRetryAfterReadsRetryAfterHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => ['30']]);\n $this->assertSame(30, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReadsLowercaseRetryAfterHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['retry-after' => ['15']]);\n $this->assertSame(15, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterHandlesScalarHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => '60']);\n $this->assertSame(60, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsDailyLimitDelay(): void\n {\n $e = new BadRequest('Daily rate limit exceeded');\n $this->assertSame(600, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsTenSecondlyDelay(): void\n {\n $e = new BadRequest('Ten secondly rate limit exceeded');\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsSecondlyDelay(): void\n {\n $e = new BadRequest('Secondly rate limit exceeded');\n $this->assertSame(1, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsDefaultWhenNoHint(): void\n {\n $e = new BadRequest('Rate limit exceeded');\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterIgnoresNonNumericHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => ['not-a-number']]);\n // Falls through to message parsing; \"Too Many Requests\" has no known keyword → default 10\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.96276593,"top":0.07581804,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.9740692,"top":0.074221864,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.98138297,"top":0.074221864,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.5724734,"top":0.0726257,"width":0.4275266,"height":0.9066241},"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-8016712486324069616
|
4446428687123393012
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
ClientTest
Rerun 'PHPUnit: ClientTest'
Debug 'ClientTest'
Stop 'ClientTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
144
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Hubspot;
use GuzzleHttp\Psr7\Response;
use HubSpot\Client\Crm\Associations\Api\BatchApi;
use HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId;
use HubSpot\Client\Crm\Associations\Model\BatchResponsePublicAssociationMulti;
use HubSpot\Client\Crm\Associations\Model\PublicObjectId;
use HubSpot\Client\Crm\Companies\ApiException as CompanyApiException;
use HubSpot\Client\Crm\Contacts\ApiException as ContactApiException;
use HubSpot\Client\Crm\Deals\Api\BasicApi as DealsBasicApi;
use HubSpot\Client\Crm\Deals\ApiException as DealApiException;
use HubSpot\Client\Crm\Deals\Model\SimplePublicObjectWithAssociations as DealWithAssociations;
use HubSpot\Client\Crm\Pipelines\Api\PipelinesApi;
use HubSpot\Client\Crm\Pipelines\Model\CollectionResponsePipeline;
use HubSpot\Client\Crm\Pipelines\Model\Error;
use HubSpot\Client\Crm\Pipelines\Model\Pipeline;
use HubSpot\Client\Crm\Pipelines\Model\PipelineStage;
use HubSpot\Client\Crm\Properties\Api\CoreApi;
use HubSpot\Client\Crm\Properties\Model\Property;
use HubSpot\Discovery as HubSpotDiscovery;
use HubSpot\Discovery\Crm\Deals\Discovery as DealsDiscovery;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\SocialAccount;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\HubspotTokenManager;
use Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService;
use Jiminny\Services\SocialAccountService;
use League\OAuth2\Client\Token\AccessToken;
use Mockery;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use SevenShores\Hubspot\Endpoints\Engagements;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Factory;
use SevenShores\Hubspot\Http\Client as HubspotClient;
use SevenShores\Hubspot\Http\Response as HubspotResponse;
/**
* @runTestsInSeparateProcesses
*
* @preserveGlobalState disabled
*/
class ClientTest extends TestCase
{
private const string RESPONSE_TYPE_STAGE_FIELD = 'stage';
private const string RESPONSE_TYPE_PIPELINE_FIELD = 'pipeline';
private const string RESPONSE_TYPE_REGULAR_FIELD = 'regular';
/**
* @var Client&MockObject
*/
private Client $client;
private Configuration $config;
/**
* @var SocialAccountService&MockObject
*/
private SocialAccountService $socialAccountServiceMock;
/**
* @var HubspotPaginationService&MockObject
*/
private HubspotPaginationService $paginationServiceMock;
/**
* @var HubspotTokenManager&MockObject
*/
private HubspotTokenManager $tokenManagerMock;
/**
* @var CoreApi&MockObject
*/
private CoreApi $coreApiMock;
/**
* @var PipelinesApi&MockObject
*/
private PipelinesApi $pipelinesApiMock;
/**
* @var BatchApi&MockObject
*/
private BatchApi $associationsBatchApiMock;
/**
* @var DealsBasicApi&MockObject
*/
private DealsBasicApi $dealsBasicApiMock;
/**
* @var Engagements&MockObject
*/
private $engagementsMock;
/**
* @var HubspotClient&MockObject
*/
private HubspotClient $hubspotClientMock;
protected function setUp(): void
{
// Create mocks for dependencies
$this->socialAccountServiceMock = $this->createMock(SocialAccountService::class);
$this->paginationServiceMock = $this->createMock(HubspotPaginationService::class);
$this->tokenManagerMock = $this->createMock(HubspotTokenManager::class);
// Create a real Client instance with mocked dependencies
// Create a partial mock only for the methods we need to mock
$client = $this->createPartialMock(Client::class, ['getInstance', 'getNewInstance', 'makeRequest']);
$client->setLogger(new NullLogger());
// Inject the real dependencies using reflection
$reflection = new \ReflectionClass(Client::class);
$accountServiceProperty = $reflection->getProperty('accountService');
$accountServiceProperty->setAccessible(true);
$accountServiceProperty->setValue($client, $this->socialAccountServiceMock);
$paginationServiceProperty = $reflection->getProperty('paginationService');
$paginationServiceProperty->setAccessible(true);
$paginationServiceProperty->setValue($client, $this->paginationServiceMock);
$tokenManagerProperty = $reflection->getProperty('tokenManager');
$tokenManagerProperty->setAccessible(true);
$tokenManagerProperty->setValue($client, $this->tokenManagerMock);
$factoryMock = $this->createMock(Factory::class);
$hubspotClientMock = $this->createMock(HubspotClient::class);
$engagementsMock = $this->createMock(\SevenShores\Hubspot\Endpoints\Engagements::class);
$factoryMock->method('getClient')->willReturn($hubspotClientMock);
$factoryMock->method('__call')->with('engagements')->willReturn($engagementsMock);
$client->method('getInstance')->willReturn($factoryMock);
$discoveryMock = $this->createMock(HubSpotDiscovery\Discovery::class);
$crmMock = $this->createMock(HubSpotDiscovery\Crm\Discovery::class);
$associationsMock = $this->createMock(HubSpotDiscovery\Crm\Associations\Discovery::class);
$propertiesMock = $this->createMock(HubSpotDiscovery\Crm\Properties\Discovery::class);
$pipelinesMock = $this->createMock(HubSpotDiscovery\Crm\Pipelines\Discovery::class);
$coreApiMock = $this->createMock(CoreApi::class);
$pipelinesApiMock = $this->createMock(PipelinesApi::class);
$associationsBatchApiMock = $this->createMock(BatchApi::class);
$dealsBasicApiMock = $this->createMock(DealsBasicApi::class);
$propertiesMock->method('__call')->with('coreApi')->willReturn($coreApiMock);
$pipelinesMock->method('__call')->with('pipelinesApi')->willReturn($pipelinesApiMock);
$associationsMock->method('__call')->with('batchApi')->willReturn($associationsBatchApiMock);
$dealsDiscoveryMock = $this->createMock(DealsDiscovery::class);
$dealsDiscoveryMock->method('__call')->with('basicApi')->willReturn($dealsBasicApiMock);
$returnMap = ['properties' => $propertiesMock, 'pipelines' => $pipelinesMock, 'associations' => $associationsMock, 'deals' => $dealsDiscoveryMock];
$crmMock->method('__call')
->willReturnCallback(static fn (string $name) => $returnMap[$name])
;
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client->method('getNewInstance')->willReturn($discoveryMock);
$this->client = $client;
$this->config = $this->createMock(Configuration::class);
$this->client->setConfiguration($this->config);
$this->coreApiMock = $coreApiMock;
$this->pipelinesApiMock = $pipelinesApiMock;
$this->associationsBatchApiMock = $associationsBatchApiMock;
$this->dealsBasicApiMock = $dealsBasicApiMock;
$this->engagementsMock = $engagementsMock;
$this->hubspotClientMock = $hubspotClientMock;
}
public function testGetMinimumApiVersion(): void
{
$this->assertIsString($this->client->getMinimumApiVersion());
}
public function testGetInstance(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManager = $this->createMock(HubspotTokenManager::class);
$client = new Client($socialAccountService, $paginationService, $tokenManager);
$client->setBaseUrl('[URL_WITH_CREDENTIALS] The class CollectionResponsePipeline will be deprecated in the next Hubspot version
*/
$this->pipelinesApiMock
->method('getAll')
->with('deals')
->willReturn(new CollectionResponsePipeline([
'results' => [$this->generatePipeline()],
]))
;
$this->assertEquals(
[
['id' => 'foo', 'label' => 'bar'],
['id' => 'baz', 'label' => 'qux'],
],
$this->client->fetchOpportunityPipelineStages()
);
}
public function testFetchOpportunityPipelineStagesErrorResponse(): void
{
$this->pipelinesApiMock
->method('getAll')
->with('deals')
->willReturn(new Error(['message' => 'test error']))
;
$this->assertEmpty($this->client->fetchOpportunityPipelineStages());
}
#[\PHPUnit\Framework\Attributes\DataProvider('meetingOutcomeFieldProvider')]
public function testFetchMeetingOutcomeFieldOptions(string $fieldId, string $expectedEndpoint): void
{
$field = new Field(['crm_provider_id' => $fieldId]);
$this->hubspotClientMock
->expects($this->once())
->method('request')
->with('GET', $expectedEndpoint)
->willReturn($this->generateHubSpotResponse(
[
'options' => [
['value' => 'option_1', 'label' => 'Option 1', 'displayOrder' => 0],
['value' => 'option_2', 'label' => 'Option 2', 'displayOrder' => 1],
['value' => 'option_3', 'label' => 'Option 3', 'displayOrder' => 2],
],
]
))
;
$this->assertEquals(
[
['id' => 'option_1', 'value' => 'option_1', 'label' => 'Option 1', 'display_order' => 0],
['id' => 'option_2', 'value' => 'option_2', 'label' => 'Option 2', 'display_order' => 1],
['id' => 'option_3', 'value' => 'option_3', 'label' => 'Option 3', 'display_order' => 2],
],
$this->client->fetchMeetingOutcomeFieldOptions($field)
);
}
public static function meetingOutcomeFieldProvider(): array
{
return [
'meeting outcome field' => [
'meetingOutcome',
'[URL_WITH_CREDENTIALS] The class CollectionResponsePipeline will be deprecated in the next Hubspot version
*/
$pipelineStagesResponse = new CollectionResponsePipeline([
'results' => [$this->generatePipeline()],
]);
$this->pipelinesApiMock
->method('getAll')
->willReturn($pipelineStagesResponse);
}
if ($type === self::RESPONSE_TYPE_PIPELINE_FIELD) {
$field->method('isStageField')->willReturn(false);
$field->method('isPipelineField')->willReturn(true);
$pipelineResponse = $this->generateHubSpotResponse([
'results' => [
['id' => '123', 'label' => 'Sales'],
['id' => 'default', 'label' => 'CS'],
],
]);
$this->client
->method('makeRequest')
->with('/crm/v3/pipelines/deals')
->willReturn($pipelineResponse);
}
$this->assertEquals(
$responses[$type],
$this->client->fetchOpportunityFieldOptions($field)
);
}
public static function opportunityFieldOptionsProvider(): array
{
return [
'stage field' => [
'field' => new Field(['crm_provider_id' => 'dealstage']),
'type' => self::RESPONSE_TYPE_STAGE_FIELD,
],
'pipeline field' => [
'field' => new Field(['crm_provider_id' => 'pipeline']),
'type' => self::RESPONSE_TYPE_PIPELINE_FIELD,
],
'regular field' => [
'field' => new Field(['crm_provider_id' => 'some_property']),
'type' => self::RESPONSE_TYPE_REGULAR_FIELD,
],
];
}
private function generateHubSpotResponse(array $data): HubspotResponse
{
return new HubspotResponse(new Response(200, [], json_encode($data)));
}
private function generateProperty(): Property
{
return new Property([
'name' => 'some_property',
'options' => [
[
'label' => 'label_1',
'value' => 'value_1',
],
[
'label' => 'label_2',
'value' => 'value_2',
],
],
]);
}
private function generatePipeline(): Pipeline
{
return new Pipeline(['stages' => [
new PipelineStage(['id' => 'foo', 'label' => 'bar']),
new PipelineStage(['id' => 'baz', 'label' => 'qux']),
]]);
}
public function testFetchOpportunityPipelines(): void
{
$this->client
->method('makeRequest')
->with('/crm/v3/pipelines/deals')
->willReturn($this->generateHubSpotResponse([
'results' => [
['id' => 'id_1', 'label' => 'Option 1', 'displayOrder' => 0],
['id' => 'id_2', 'label' => 'Option 2', 'displayOrder' => 1],
['id' => 'id_3', 'label' => 'Option 3', 'displayOrder' => 2],
],
]));
$this->assertEquals(
[
['id' => 'id_1', 'label' => 'Option 1'],
['id' => 'id_2', 'label' => 'Option 2'],
['id' => 'id_3', 'label' => 'Option 3'],
],
$this->client->fetchOpportunityPipelines()
);
}
public function testGetPaginatedData(): void
{
$expectedResults = [
['id' => 'id_1', 'properties' => []],
['id' => 'id_2', 'properties' => []],
['id' => 'id_3', 'properties' => []],
];
// Mock the pagination service to return a generator and modify reference parameters
$this->paginationServiceMock
->expects($this->once())
->method('getPaginatedDataGenerator')
->with(
$this->client,
['payload_key' => 'payload_value'],
'foobar',
0,
$this->anything(),
$this->anything()
)
->willReturnCallback(function ($client, $payload, $type, $offset, &$total, &$lastRecordId) use ($expectedResults) {
$total = 3;
$lastRecordId = 'id_3';
foreach ($expectedResults as $result) {
yield $result;
}
});
$this->assertEquals(
[
'results' => $expectedResults,
'total' => 3,
'last_record' => 'id_3',
],
$this->client->getPaginatedData(['payload_key' => 'payload_value'], 'foobar')
);
}
public function testGetAssociationsData(): void
{
$ids = ['1', '2', '3'];
$fromObject = 'deals';
$toObject = 'contacts';
$responseResults = [];
foreach ($ids as $id) {
$from = new PublicObjectId();
$from->setId($id);
$to1 = new PublicObjectId();
$to1->setId('contact_' . $id . '_1');
$to2 = new PublicObjectId();
$to2->setId('contact_' . $id . '_2');
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to1, $to2]);
$responseResults[] = $result;
}
$batchResponse = new BatchResponsePublicAssociationMulti();
$batchResponse->setResults($responseResults);
$this->associationsBatchApiMock->expects($this->once())
->method('read')
->with(
$this->equalTo($fromObject),
$this->equalTo($toObject),
$this->callback(function (BatchInputPublicObjectId $batchInput) use ($ids) {
$inputIds = array_map(
fn ($input) => $input->getId(),
$batchInput->getInputs()
);
return $inputIds === $ids;
})
)
->willReturn($batchResponse);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$expectedResult = [
'1' => ['contact_1_1', 'contact_1_2'],
'2' => ['contact_2_1', 'contact_2_2'],
'3' => ['contact_3_1', 'contact_3_2'],
];
$this->assertEquals($expectedResult, $result);
}
public function testGetAssociationsDataHandlesException(): void
{
$ids = ['1', '2', '3'];
$fromObject = 'deals';
$toObject = 'contacts';
$exception = new \Exception('API Error');
$this->associationsBatchApiMock->expects($this->once())
->method('read')
->with(
$this->equalTo($fromObject),
$this->equalTo($toObject),
$this->callback(function ($batchInput) use ($ids) {
return $batchInput instanceof \HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId
&& count($batchInput->getInputs()) === count($ids);
})
)
->willThrowException($exception);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with(
'[Hubspot] Failed to fetch associations',
[
'from_object' => $fromObject,
'to_object' => $toObject,
'reason' => 'API Error',
]
);
$this->client->setLogger($loggerMock);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$this->assertEmpty($result);
$this->assertIsArray($result);
}
public function testGetAssociationsDataWithLargeDataSet(): void
{
$ids = array_map(fn ($i) => (string) $i, range(1, 2500)); // More than 1000 items
$fromObject = 'deals';
$toObject = 'contacts';
$firstBatchResponse = new BatchResponsePublicAssociationMulti();
$firstBatchResults = array_map(function ($id) {
$from = new PublicObjectId();
$from->setId($id);
$to = new PublicObjectId();
$to->setId('contact_' . $id);
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to]);
return $result;
}, array_slice($ids, 0, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));
$firstBatchResponse->setResults($firstBatchResults);
$secondBatchResponse = new BatchResponsePublicAssociationMulti();
$secondBatchResults = array_map(function ($id) {
$from = new PublicObjectId();
$from->setId($id);
$to = new PublicObjectId();
$to->setId('contact_' . $id);
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to]);
return $result;
}, array_slice($ids, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));
$secondBatchResponse->setResults($secondBatchResults);
$this->associationsBatchApiMock->expects($this->exactly(3))
->method('read')
->willReturnOnConsecutiveCalls($firstBatchResponse, $secondBatchResponse);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$this->assertCount(2500, $result);
$this->assertArrayHasKey('1', $result);
$this->assertArrayHasKey('2500', $result);
$this->assertEquals(['contact_1'], $result['1']);
$this->assertEquals(['contact_2500'], $result['2500']);
}
public function testGetContactByEmailSuccess(): void
{
$email = '[EMAIL]';
$fields = ['firstname', 'lastname', 'email'];
$contactMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$contactMock->method('getId')->willReturn('12345');
$contactMock->method('getProperties')->willReturn([
'firstname' => 'John',
'lastname' => 'Doe',
'email' => '[EMAIL]',
]);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, 'firstname,lastname,email', null, false, 'email')
->willReturn($contactMock);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new NullLogger());
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([
'id' => '12345',
'properties' => [
'firstname' => 'John',
'lastname' => 'Doe',
'email' => '[EMAIL]',
],
], $result);
}
public function testGetContactByEmailWithEmptyFields(): void
{
$email = '[EMAIL]';
$fields = [];
$contactMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$contactMock->method('getId')->willReturn('12345');
$contactMock->method('getProperties')->willReturn(['email' => '[EMAIL]']);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, '', null, false, 'email')
->willReturn($contactMock);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new NullLogger());
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([
'id' => '12345',
'properties' => ['email' => '[EMAIL]'],
], $result);
}
public function testGetContactByEmailApiException(): void
{
$email = '[EMAIL]';
$fields = ['firstname', 'lastname'];
$exception = new \HubSpot\Client\Crm\Contacts\ApiException('Contact not found', 404);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, 'firstname,lastname', null, false, 'email')
->willThrowException($exception);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('info')
->with(
'[Hubspot] Failed to fetch contact',
[
'email' => $email,
'reason' => 'Contact not found',
]
);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger($loggerMock);
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([], $result);
}
public function testGetOpportunityById(): void
{
$opportunityId = '12345';
$expectedProperties = [
'dealname' => 'Test Opportunity',
'amount' => '1000.00',
'closedate' => '2024-12-31T23:59:59.999Z',
'dealstage' => 'presentationscheduled',
'pipeline' => 'default',
];
$mockHubspotOpportunity = $this->createMock(DealWithAssociations::class);
$mockHubspotOpportunity->method('getProperties')->willReturn((object) $expectedProperties);
$mockHubspotOpportunity->method('getId')->willReturn($opportunityId);
$now = new \DateTimeImmutable();
$mockHubspotOpportunity->method('getCreatedAt')->willReturn($now);
$mockHubspotOpportunity->method('getUpdatedAt')->willReturn($now);
$mockHubspotOpportunity->method('getArchived')->willReturn(false);
$this->dealsBasicApiMock
->expects($this->once())
->method('getById')
->willReturn($mockHubspotOpportunity);
// Assuming Client::getOpportunityById processes the SimplePublicObject and returns an associative array.
// The structure might be like: ['id' => ..., 'properties' => [...], 'createdAt' => ..., ...]
// Adjust assertions below based on the actual return structure of your method.
$result = $this->client->getOpportunityById($opportunityId, ['test', 'test']);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertEquals($opportunityId, $result['id']);
}
public function testGetContactById(): void
{
$crmId = 'contact-123';
$fields = ['firstname', 'lastname'];
$expectedProperties = ['firstname' => 'John', 'lastname' => 'Doe'];
$mockContact = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$mockContact->method('getId')->willReturn($crmId);
$mockContact->method('getProperties')->willReturn((object) $expectedProperties);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($crmId, 'firstname,lastname')
->willReturn($mockContact);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new \Psr\Log\NullLogger());
$result = $client->getContactById($crmId, $fields);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('properties', $result);
$this->assertEquals($crmId, $result['id']);
$this->assertEquals((object) $expectedProperties, $result['properties']);
}
public function testGetAccountById(): void
{
$crmId = 'account-123';
$fields = ['name', 'industry'];
$expectedProperties = ['name' => 'Acme Corp', 'industry' => 'Technology'];
$mockCompany = $this->createMock(\HubSpot\Client\Crm\Companies\Model\SimplePublicObjectWithAssociations::class);
$mockCompany->method('getId')->willReturn($crmId);
$mockCompany->method('getProperties')->willReturn((object) $expectedProperties);
$companiesApiMock = $this->createMock(\HubSpot\Client\Crm\Companies\Api\BasicApi::class);
$companiesApiMock->expects($this->once())
->method('getById')
->with($crmId, 'name,industry')
->willReturn($mockCompany);
$companiesDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Companies\Discovery::class);
$companiesDiscoveryMock->method('__call')->with('basicApi')->willReturn($companiesApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($companiesDiscoveryMock) {
if ($name === 'companies') {
return $companiesDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new \Psr\Log\NullLogger());
$result = $client->getAccountById($crmId, $fields);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('properties', $result);
$this->assertEquals($crmId, $result['id']);
$this->assertEquals((object) $expectedProperties, $result['properties']);
}
public function testEnsureValidTokenWithNoTokenUpdate(): void
{
$socialAccountMock = $this->createMock(SocialAccount::class);
// Set up OAuth account
$reflection = new \ReflectionClass($this->client);
$oauthAccountProperty = $reflection->getProperty('oauthAccount');
$oauthAccountProperty->setAccessible(true);
$oauthAccountProperty->setValue($this->client, $socialAccountMock);
$originalToken = 'original_token';
$accessTokenProperty = $reflection->getProperty('accessToken');
$accessTokenProperty->setAccessible(true);
$accessTokenProperty->setValue($this->client, $originalToken);
// Mock token manager to return null (no refresh needed)
$this->tokenManagerMock
->expects($this->once())
->method('ensureValidToken')
->with($socialAccountMock)
->willReturn(null);
// Call ensureValidToken
$this->client->ensureValidToken();
// Verify access token was not changed
$this->assertEquals($originalToken, $accessTokenProperty->getValue($this->client));
}
public function testGetPaginatedDataGeneratorDelegatesToPaginationService(): void
{
$payload = ['filters' => []];
$type = 'contacts';
$offset = 0;
$total = 0;
$lastRecordId = null;
$expectedResults = [
['id' => 'id_1', 'properties' => []],
['id' => 'id_2', 'properties' => []],
];
// Mock the pagination service to return a generator
$this->paginationServiceMock
->expects($this->once())
->method('getPaginatedDataGenerator')
->with(
$this->client,
$payload,
$type,
$offset,
$this->anything(),
$this->anything()
)
->willReturnCallback(function () use ($expectedResults) {
foreach ($expectedResults as $result) {
yield $result;
}
});
// Execute the pagination
$results = [];
foreach ($this->client->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastRecordId) as $result) {
$results[] = $result;
}
$this->assertCount(2, $results);
$this->assertEquals('id_1', $results[0]['id']);
$this->assertEquals('id_2', $results[1]['id']);
}
public function testEnsureValidTokenDelegatesToTokenManager(): void
{
$socialAccountMock = $this->createMock(SocialAccount::class);
// Set up OAuth account
$reflection = new \ReflectionClass($this->client);
$oauthAccountProperty = $reflection->getProperty('oauthAccount');
$oauthAccountProperty->setAccessible(true);
$oauthAccountProperty->setValue($this->client, $socialAccountMock);
// Mock token manager to return new token
$this->tokenManagerMock
->expects($this->once())
->method('ensureValidToken')
->with($socialAccountMock)
->willReturn('new_access_token');
// Call ensureValidToken
$this->client->ensureValidToken();
// Verify access token was updated
$accessTokenProperty = $reflection->getProperty('accessToken');
$accessTokenProperty->setAccessible(true);
$this->assertEquals('new_access_token', $accessTokenProperty->getValue($this->client));
}
public function testGetOwnersArchivedWithValidResponse(): void
{
$responseData = [
'results' => [
[
'id' => '123',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'John',
'lastName' => 'Doe',
'userId' => 456,
'userIdIncludingInactive' => 789,
'createdAt' => '2023-01-01T12:00:00Z',
'updatedAt' => '2023-01-02T12:00:00Z',
'archived' => true,
],
],
];
// Create a mock response object
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn($responseData);
// Set up the client to return our test data
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=true'
)
->willReturn($response);
// Call the method
$result = $this->client->getOwnersArchived(true);
// Assert the results
$this->assertCount(1, $result);
$this->assertEquals('123', $result[0]->getId());
$this->assertEquals('[EMAIL]', $result[0]->getEmail());
$this->assertEquals('John Doe', $result[0]->getFullName());
$this->assertTrue($result[0]->isArchived());
}
public function testGetOwnersArchivedWithEmptyResponse(): void
{
// Create a mock response object with empty results
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn(['results' => []]);
// Set up the client to return empty results
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=false'
)
->willReturn($response);
// Call the method
$result = $this->client->getOwnersArchived(false);
// Assert the results
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithInvalidResponse(): void
{
// Create a mock response that will throw an exception when toArray is called
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willThrowException(new \InvalidArgumentException('Invalid JSON'));
// Set up the client to return the problematic response
$this->client->method('makeRequest')
->willReturn($response);
// Mock the logger to expect an error message
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with($this->stringContains('Failed to fetch owners'));
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
// Call the method and expect an empty array on error
$result = $this->client->getOwnersArchived(true);
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithHttpError(): void
{
// Set up the client to throw an exception
$this->client->method('makeRequest')
->willThrowException(new \Exception('HTTP Error'));
// Mock the logger to expect an error message
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with($this->stringContains('Failed to fetch owners'));
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
// Call the method and expect an empty array on error
$result = $this->client->getOwnersArchived(false);
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithOwnerCreationException(): void
{
$responseData = [
'results' => [
[
'id' => '123',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'John',
'lastName' => 'Doe',
'userId' => 456,
'userIdIncludingInactive' => 789,
'createdAt' => '2023-01-01T12:00:00Z',
'updatedAt' => '2023-01-02T12:00:00Z',
'archived' => false,
],
[
'id' => '456',
'email' => '[EMAIL]',
'type' => 'PERSON',
'createdAt' => 'invalid-date-format',
],
[
'id' => '789',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'Jane',
'lastName' => 'Smith',
'userId' => 999,
'userIdIncludingInactive' => 888,
'createdAt' => '2023-01-03T12:00:00Z',
'updatedAt' => '2023-01-04T12:00:00Z',
'archived' => false,
],
],
];
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn($responseData);
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=false'
)
->willReturn($response);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with(
'[HubSpot] Failed to process owner data',
$this->callback(function ($context) {
return isset($context['result']) &&
isset($context['error']) &&
$context['result']['id'] === '456' &&
$context['result']['email'] === '[EMAIL]' &&
str_contains($context['error'], 'invalid-date-format');
})
);
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
$result = $this->client->getOwnersArchived(false);
$this->assertIsArray($result);
$this->assertCount(2, $result);
$this->assertEquals('123', $result[0]->getId());
$this->assertEquals('[EMAIL]', $result[0]->getEmail());
$this->assertEquals('789', $result[1]->getId());
$this->assertEquals('[EMAIL]', $result[1]->getEmail());
}
public function testMakeRequestWithGetMethod(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManager = $this->createMock(HubspotTokenManager::class);
$psrResponse = new Response(200, [], json_encode(['status' => 'success']));
$expectedResponse = new HubspotResponse($psrResponse);
$hubspotClientMock = $this->createMock(HubspotClient::class);
$hubspotClientMock->expects($this->once())
->method('request')
->with(
'GET',
'https://api.hubapi.com/crm/v3/objects/contacts',
[],
null,
true
)
->willReturn($expectedResponse);
$factoryMock = $this->createMock(Factory::class);
$factoryMock->method('getClient')->willReturn($hubspotClientMock);
$client = $this->getMockBuilder(Client::class)
->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])
->onlyMethods(['getInstance'])
->getMock();
$client->method('getInstance')->willReturn($factoryMock);
$client->setAccessToken(new AccessToken(['access_token' => 'test_token']));
$result = $client->makeRequest('/crm/v3/objects/contacts', 'GET');
$this->assertSame($expectedResponse, $result);
}
public function testMakeRequestWithGetMethodAndQueryString(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20558
|
893
|
3
|
2026-05-11T15:51:34.723215+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778514694723_m2.jpg...
|
PhpStorm
|
faVsco.js – ClientTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
ClientTest
Run 'ClientTest'
Debug 'ClientTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
144
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Hubspot;
use GuzzleHttp\Psr7\Response;
use HubSpot\Client\Crm\Associations\Api\BatchApi;
use HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId;
use HubSpot\Client\Crm\Associations\Model\BatchResponsePublicAssociationMulti;
use HubSpot\Client\Crm\Associations\Model\PublicObjectId;
use HubSpot\Client\Crm\Companies\ApiException as CompanyApiException;
use HubSpot\Client\Crm\Contacts\ApiException as ContactApiException;
use HubSpot\Client\Crm\Deals\Api\BasicApi as DealsBasicApi;
use HubSpot\Client\Crm\Deals\ApiException as DealApiException;
use HubSpot\Client\Crm\Deals\Model\SimplePublicObjectWithAssociations as DealWithAssociations;
use HubSpot\Client\Crm\Pipelines\Api\PipelinesApi;
use HubSpot\Client\Crm\Pipelines\Model\CollectionResponsePipeline;
use HubSpot\Client\Crm\Pipelines\Model\Error;
use HubSpot\Client\Crm\Pipelines\Model\Pipeline;
use HubSpot\Client\Crm\Pipelines\Model\PipelineStage;
use HubSpot\Client\Crm\Properties\Api\CoreApi;
use HubSpot\Client\Crm\Properties\Model\Property;
use HubSpot\Discovery as HubSpotDiscovery;
use HubSpot\Discovery\Crm\Deals\Discovery as DealsDiscovery;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\SocialAccount;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\HubspotTokenManager;
use Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService;
use Jiminny\Services\SocialAccountService;
use League\OAuth2\Client\Token\AccessToken;
use Mockery;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use SevenShores\Hubspot\Endpoints\Engagements;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Factory;
use SevenShores\Hubspot\Http\Client as HubspotClient;
use SevenShores\Hubspot\Http\Response as HubspotResponse;
/**
* @runTestsInSeparateProcesses
*
* @preserveGlobalState disabled
*/
class ClientTest extends TestCase
{
private const string RESPONSE_TYPE_STAGE_FIELD = 'stage';
private const string RESPONSE_TYPE_PIPELINE_FIELD = 'pipeline';
private const string RESPONSE_TYPE_REGULAR_FIELD = 'regular';
/**
* @var Client&MockObject
*/
private Client $client;
private Configuration $config;
/**
* @var SocialAccountService&MockObject
*/
private SocialAccountService $socialAccountServiceMock;
/**
* @var HubspotPaginationService&MockObject
*/
private HubspotPaginationService $paginationServiceMock;
/**
* @var HubspotTokenManager&MockObject
*/
private HubspotTokenManager $tokenManagerMock;
/**
* @var CoreApi&MockObject
*/
private CoreApi $coreApiMock;
/**
* @var PipelinesApi&MockObject
*/
private PipelinesApi $pipelinesApiMock;
/**
* @var BatchApi&MockObject
*/
private BatchApi $associationsBatchApiMock;
/**
* @var DealsBasicApi&MockObject
*/
private DealsBasicApi $dealsBasicApiMock;
/**
* @var Engagements&MockObject
*/
private $engagementsMock;
/**
* @var HubspotClient&MockObject
*/
private HubspotClient $hubspotClientMock;
protected function setUp(): void
{
// Create mocks for dependencies
$this->socialAccountServiceMock = $this->createMock(SocialAccountService::class);
$this->paginationServiceMock = $this->createMock(HubspotPaginationService::class);
$this->tokenManagerMock = $this->createMock(HubspotTokenManager::class);
// Create a real Client instance with mocked dependencies
// Create a partial mock only for the methods we need to mock
$client = $this->createPartialMock(Client::class, ['getInstance', 'getNewInstance', 'makeRequest']);
$client->setLogger(new NullLogger());
// Inject the real dependencies using reflection
$reflection = new \ReflectionClass(Client::class);
$accountServiceProperty = $reflection->getProperty('accountService');
$accountServiceProperty->setAccessible(true);
$accountServiceProperty->setValue($client, $this->socialAccountServiceMock);
$paginationServiceProperty = $reflection->getProperty('paginationService');
$paginationServiceProperty->setAccessible(true);
$paginationServiceProperty->setValue($client, $this->paginationServiceMock);
$tokenManagerProperty = $reflection->getProperty('tokenManager');
$tokenManagerProperty->setAccessible(true);
$tokenManagerProperty->setValue($client, $this->tokenManagerMock);
$factoryMock = $this->createMock(Factory::class);
$hubspotClientMock = $this->createMock(HubspotClient::class);
$engagementsMock = $this->createMock(\SevenShores\Hubspot\Endpoints\Engagements::class);
$factoryMock->method('getClient')->willReturn($hubspotClientMock);
$factoryMock->method('__call')->with('engagements')->willReturn($engagementsMock);
$client->method('getInstance')->willReturn($factoryMock);
$discoveryMock = $this->createMock(HubSpotDiscovery\Discovery::class);
$crmMock = $this->createMock(HubSpotDiscovery\Crm\Discovery::class);
$associationsMock = $this->createMock(HubSpotDiscovery\Crm\Associations\Discovery::class);
$propertiesMock = $this->createMock(HubSpotDiscovery\Crm\Properties\Discovery::class);
$pipelinesMock = $this->createMock(HubSpotDiscovery\Crm\Pipelines\Discovery::class);
$coreApiMock = $this->createMock(CoreApi::class);
$pipelinesApiMock = $this->createMock(PipelinesApi::class);
$associationsBatchApiMock = $this->createMock(BatchApi::class);
$dealsBasicApiMock = $this->createMock(DealsBasicApi::class);
$propertiesMock->method('__call')->with('coreApi')->willReturn($coreApiMock);
$pipelinesMock->method('__call')->with('pipelinesApi')->willReturn($pipelinesApiMock);
$associationsMock->method('__call')->with('batchApi')->willReturn($associationsBatchApiMock);
$dealsDiscoveryMock = $this->createMock(DealsDiscovery::class);
$dealsDiscoveryMock->method('__call')->with('basicApi')->willReturn($dealsBasicApiMock);
$returnMap = ['properties' => $propertiesMock, 'pipelines' => $pipelinesMock, 'associations' => $associationsMock, 'deals' => $dealsDiscoveryMock];
$crmMock->method('__call')
->willReturnCallback(static fn (string $name) => $returnMap[$name])
;
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client->method('getNewInstance')->willReturn($discoveryMock);
$this->client = $client;
$this->config = $this->createMock(Configuration::class);
$this->client->setConfiguration($this->config);
$this->coreApiMock = $coreApiMock;
$this->pipelinesApiMock = $pipelinesApiMock;
$this->associationsBatchApiMock = $associationsBatchApiMock;
$this->dealsBasicApiMock = $dealsBasicApiMock;
$this->engagementsMock = $engagementsMock;
$this->hubspotClientMock = $hubspotClientMock;
}
public function testGetMinimumApiVersion(): void
{
$this->assertIsString($this->client->getMinimumApiVersion());
}
public function testGetInstance(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManager = $this->createMock(HubspotTokenManager::class);
$client = new Client($socialAccountService, $paginationService, $tokenManager);
$client->setBaseUrl('[URL_WITH_CREDENTIALS] The class CollectionResponsePipeline will be deprecated in the next Hubspot version
*/
$this->pipelinesApiMock
->method('getAll')
->with('deals')
->willReturn(new CollectionResponsePipeline([
'results' => [$this->generatePipeline()],
]))
;
$this->assertEquals(
[
['id' => 'foo', 'label' => 'bar'],
['id' => 'baz', 'label' => 'qux'],
],
$this->client->fetchOpportunityPipelineStages()
);
}
public function testFetchOpportunityPipelineStagesErrorResponse(): void
{
$this->pipelinesApiMock
->method('getAll')
->with('deals')
->willReturn(new Error(['message' => 'test error']))
;
$this->assertEmpty($this->client->fetchOpportunityPipelineStages());
}
#[\PHPUnit\Framework\Attributes\DataProvider('meetingOutcomeFieldProvider')]
public function testFetchMeetingOutcomeFieldOptions(string $fieldId, string $expectedEndpoint): void
{
$field = new Field(['crm_provider_id' => $fieldId]);
$this->hubspotClientMock
->expects($this->once())
->method('request')
->with('GET', $expectedEndpoint)
->willReturn($this->generateHubSpotResponse(
[
'options' => [
['value' => 'option_1', 'label' => 'Option 1', 'displayOrder' => 0],
['value' => 'option_2', 'label' => 'Option 2', 'displayOrder' => 1],
['value' => 'option_3', 'label' => 'Option 3', 'displayOrder' => 2],
],
]
))
;
$this->assertEquals(
[
['id' => 'option_1', 'value' => 'option_1', 'label' => 'Option 1', 'display_order' => 0],
['id' => 'option_2', 'value' => 'option_2', 'label' => 'Option 2', 'display_order' => 1],
['id' => 'option_3', 'value' => 'option_3', 'label' => 'Option 3', 'display_order' => 2],
],
$this->client->fetchMeetingOutcomeFieldOptions($field)
);
}
public static function meetingOutcomeFieldProvider(): array
{
return [
'meeting outcome field' => [
'meetingOutcome',
'[URL_WITH_CREDENTIALS] The class CollectionResponsePipeline will be deprecated in the next Hubspot version
*/
$pipelineStagesResponse = new CollectionResponsePipeline([
'results' => [$this->generatePipeline()],
]);
$this->pipelinesApiMock
->method('getAll')
->willReturn($pipelineStagesResponse);
}
if ($type === self::RESPONSE_TYPE_PIPELINE_FIELD) {
$field->method('isStageField')->willReturn(false);
$field->method('isPipelineField')->willReturn(true);
$pipelineResponse = $this->generateHubSpotResponse([
'results' => [
['id' => '123', 'label' => 'Sales'],
['id' => 'default', 'label' => 'CS'],
],
]);
$this->client
->method('makeRequest')
->with('/crm/v3/pipelines/deals')
->willReturn($pipelineResponse);
}
$this->assertEquals(
$responses[$type],
$this->client->fetchOpportunityFieldOptions($field)
);
}
public static function opportunityFieldOptionsProvider(): array
{
return [
'stage field' => [
'field' => new Field(['crm_provider_id' => 'dealstage']),
'type' => self::RESPONSE_TYPE_STAGE_FIELD,
],
'pipeline field' => [
'field' => new Field(['crm_provider_id' => 'pipeline']),
'type' => self::RESPONSE_TYPE_PIPELINE_FIELD,
],
'regular field' => [
'field' => new Field(['crm_provider_id' => 'some_property']),
'type' => self::RESPONSE_TYPE_REGULAR_FIELD,
],
];
}
private function generateHubSpotResponse(array $data): HubspotResponse
{
return new HubspotResponse(new Response(200, [], json_encode($data)));
}
private function generateProperty(): Property
{
return new Property([
'name' => 'some_property',
'options' => [
[
'label' => 'label_1',
'value' => 'value_1',
],
[
'label' => 'label_2',
'value' => 'value_2',
],
],
]);
}
private function generatePipeline(): Pipeline
{
return new Pipeline(['stages' => [
new PipelineStage(['id' => 'foo', 'label' => 'bar']),
new PipelineStage(['id' => 'baz', 'label' => 'qux']),
]]);
}
public function testFetchOpportunityPipelines(): void
{
$this->client
->method('makeRequest')
->with('/crm/v3/pipelines/deals')
->willReturn($this->generateHubSpotResponse([
'results' => [
['id' => 'id_1', 'label' => 'Option 1', 'displayOrder' => 0],
['id' => 'id_2', 'label' => 'Option 2', 'displayOrder' => 1],
['id' => 'id_3', 'label' => 'Option 3', 'displayOrder' => 2],
],
]));
$this->assertEquals(
[
['id' => 'id_1', 'label' => 'Option 1'],
['id' => 'id_2', 'label' => 'Option 2'],
['id' => 'id_3', 'label' => 'Option 3'],
],
$this->client->fetchOpportunityPipelines()
);
}
public function testGetPaginatedData(): void
{
$expectedResults = [
['id' => 'id_1', 'properties' => []],
['id' => 'id_2', 'properties' => []],
['id' => 'id_3', 'properties' => []],
];
// Mock the pagination service to return a generator and modify reference parameters
$this->paginationServiceMock
->expects($this->once())
->method('getPaginatedDataGenerator')
->with(
$this->client,
['payload_key' => 'payload_value'],
'foobar',
0,
$this->anything(),
$this->anything()
)
->willReturnCallback(function ($client, $payload, $type, $offset, &$total, &$lastRecordId) use ($expectedResults) {
$total = 3;
$lastRecordId = 'id_3';
foreach ($expectedResults as $result) {
yield $result;
}
});
$this->assertEquals(
[
'results' => $expectedResults,
'total' => 3,
'last_record' => 'id_3',
],
$this->client->getPaginatedData(['payload_key' => 'payload_value'], 'foobar')
);
}
public function testGetAssociationsData(): void
{
$ids = ['1', '2', '3'];
$fromObject = 'deals';
$toObject = 'contacts';
$responseResults = [];
foreach ($ids as $id) {
$from = new PublicObjectId();
$from->setId($id);
$to1 = new PublicObjectId();
$to1->setId('contact_' . $id . '_1');
$to2 = new PublicObjectId();
$to2->setId('contact_' . $id . '_2');
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to1, $to2]);
$responseResults[] = $result;
}
$batchResponse = new BatchResponsePublicAssociationMulti();
$batchResponse->setResults($responseResults);
$this->associationsBatchApiMock->expects($this->once())
->method('read')
->with(
$this->equalTo($fromObject),
$this->equalTo($toObject),
$this->callback(function (BatchInputPublicObjectId $batchInput) use ($ids) {
$inputIds = array_map(
fn ($input) => $input->getId(),
$batchInput->getInputs()
);
return $inputIds === $ids;
})
)
->willReturn($batchResponse);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$expectedResult = [
'1' => ['contact_1_1', 'contact_1_2'],
'2' => ['contact_2_1', 'contact_2_2'],
'3' => ['contact_3_1', 'contact_3_2'],
];
$this->assertEquals($expectedResult, $result);
}
public function testGetAssociationsDataHandlesException(): void
{
$ids = ['1', '2', '3'];
$fromObject = 'deals';
$toObject = 'contacts';
$exception = new \Exception('API Error');
$this->associationsBatchApiMock->expects($this->once())
->method('read')
->with(
$this->equalTo($fromObject),
$this->equalTo($toObject),
$this->callback(function ($batchInput) use ($ids) {
return $batchInput instanceof \HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId
&& count($batchInput->getInputs()) === count($ids);
})
)
->willThrowException($exception);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with(
'[Hubspot] Failed to fetch associations',
[
'from_object' => $fromObject,
'to_object' => $toObject,
'reason' => 'API Error',
]
);
$this->client->setLogger($loggerMock);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$this->assertEmpty($result);
$this->assertIsArray($result);
}
public function testGetAssociationsDataWithLargeDataSet(): void
{
$ids = array_map(fn ($i) => (string) $i, range(1, 2500)); // More than 1000 items
$fromObject = 'deals';
$toObject = 'contacts';
$firstBatchResponse = new BatchResponsePublicAssociationMulti();
$firstBatchResults = array_map(function ($id) {
$from = new PublicObjectId();
$from->setId($id);
$to = new PublicObjectId();
$to->setId('contact_' . $id);
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to]);
return $result;
}, array_slice($ids, 0, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));
$firstBatchResponse->setResults($firstBatchResults);
$secondBatchResponse = new BatchResponsePublicAssociationMulti();
$secondBatchResults = array_map(function ($id) {
$from = new PublicObjectId();
$from->setId($id);
$to = new PublicObjectId();
$to->setId('contact_' . $id);
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to]);
return $result;
}, array_slice($ids, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));
$secondBatchResponse->setResults($secondBatchResults);
$this->associationsBatchApiMock->expects($this->exactly(3))
->method('read')
->willReturnOnConsecutiveCalls($firstBatchResponse, $secondBatchResponse);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$this->assertCount(2500, $result);
$this->assertArrayHasKey('1', $result);
$this->assertArrayHasKey('2500', $result);
$this->assertEquals(['contact_1'], $result['1']);
$this->assertEquals(['contact_2500'], $result['2500']);
}
public function testGetContactByEmailSuccess(): void
{
$email = '[EMAIL]';
$fields = ['firstname', 'lastname', 'email'];
$contactMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$contactMock->method('getId')->willReturn('12345');
$contactMock->method('getProperties')->willReturn([
'firstname' => 'John',
'lastname' => 'Doe',
'email' => '[EMAIL]',
]);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, 'firstname,lastname,email', null, false, 'email')
->willReturn($contactMock);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new NullLogger());
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([
'id' => '12345',
'properties' => [
'firstname' => 'John',
'lastname' => 'Doe',
'email' => '[EMAIL]',
],
], $result);
}
public function testGetContactByEmailWithEmptyFields(): void
{
$email = '[EMAIL]';
$fields = [];
$contactMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$contactMock->method('getId')->willReturn('12345');
$contactMock->method('getProperties')->willReturn(['email' => '[EMAIL]']);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, '', null, false, 'email')
->willReturn($contactMock);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new NullLogger());
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([
'id' => '12345',
'properties' => ['email' => '[EMAIL]'],
], $result);
}
public function testGetContactByEmailApiException(): void
{
$email = '[EMAIL]';
$fields = ['firstname', 'lastname'];
$exception = new \HubSpot\Client\Crm\Contacts\ApiException('Contact not found', 404);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, 'firstname,lastname', null, false, 'email')
->willThrowException($exception);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('info')
->with(
'[Hubspot] Failed to fetch contact',
[
'email' => $email,
'reason' => 'Contact not found',
]
);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger($loggerMock);
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([], $result);
}
public function testGetOpportunityById(): void
{
$opportunityId = '12345';
$expectedProperties = [
'dealname' => 'Test Opportunity',
'amount' => '1000.00',
'closedate' => '2024-12-31T23:59:59.999Z',
'dealstage' => 'presentationscheduled',
'pipeline' => 'default',
];
$mockHubspotOpportunity = $this->createMock(DealWithAssociations::class);
$mockHubspotOpportunity->method('getProperties')->willReturn((object) $expectedProperties);
$mockHubspotOpportunity->method('getId')->willReturn($opportunityId);
$now = new \DateTimeImmutable();
$mockHubspotOpportunity->method('getCreatedAt')->willReturn($now);
$mockHubspotOpportunity->method('getUpdatedAt')->willReturn($now);
$mockHubspotOpportunity->method('getArchived')->willReturn(false);
$this->dealsBasicApiMock
->expects($this->once())
->method('getById')
->willReturn($mockHubspotOpportunity);
// Assuming Client::getOpportunityById processes the SimplePublicObject and returns an associative array.
// The structure might be like: ['id' => ..., 'properties' => [...], 'createdAt' => ..., ...]
// Adjust assertions below based on the actual return structure of your method.
$result = $this->client->getOpportunityById($opportunityId, ['test', 'test']);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertEquals($opportunityId, $result['id']);
}
public function testGetContactById(): void
{
$crmId = 'contact-123';
$fields = ['firstname', 'lastname'];
$expectedProperties = ['firstname' => 'John', 'lastname' => 'Doe'];
$mockContact = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$mockContact->method('getId')->willReturn($crmId);
$mockContact->method('getProperties')->willReturn((object) $expectedProperties);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($crmId, 'firstname,lastname')
->willReturn($mockContact);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new \Psr\Log\NullLogger());
$result = $client->getContactById($crmId, $fields);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('properties', $result);
$this->assertEquals($crmId, $result['id']);
$this->assertEquals((object) $expectedProperties, $result['properties']);
}
public function testGetAccountById(): void
{
$crmId = 'account-123';
$fields = ['name', 'industry'];
$expectedProperties = ['name' => 'Acme Corp', 'industry' => 'Technology'];
$mockCompany = $this->createMock(\HubSpot\Client\Crm\Companies\Model\SimplePublicObjectWithAssociations::class);
$mockCompany->method('getId')->willReturn($crmId);
$mockCompany->method('getProperties')->willReturn((object) $expectedProperties);
$companiesApiMock = $this->createMock(\HubSpot\Client\Crm\Companies\Api\BasicApi::class);
$companiesApiMock->expects($this->once())
->method('getById')
->with($crmId, 'name,industry')
->willReturn($mockCompany);
$companiesDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Companies\Discovery::class);
$companiesDiscoveryMock->method('__call')->with('basicApi')->willReturn($companiesApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($companiesDiscoveryMock) {
if ($name === 'companies') {
return $companiesDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new \Psr\Log\NullLogger());
$result = $client->getAccountById($crmId, $fields);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('properties', $result);
$this->assertEquals($crmId, $result['id']);
$this->assertEquals((object) $expectedProperties, $result['properties']);
}
public function testEnsureValidTokenWithNoTokenUpdate(): void
{
$socialAccountMock = $this->createMock(SocialAccount::class);
// Set up OAuth account
$reflection = new \ReflectionClass($this->client);
$oauthAccountProperty = $reflection->getProperty('oauthAccount');
$oauthAccountProperty->setAccessible(true);
$oauthAccountProperty->setValue($this->client, $socialAccountMock);
$originalToken = 'original_token';
$accessTokenProperty = $reflection->getProperty('accessToken');
$accessTokenProperty->setAccessible(true);
$accessTokenProperty->setValue($this->client, $originalToken);
// Mock token manager to return null (no refresh needed)
$this->tokenManagerMock
->expects($this->once())
->method('ensureValidToken')
->with($socialAccountMock)
->willReturn(null);
// Call ensureValidToken
$this->client->ensureValidToken();
// Verify access token was not changed
$this->assertEquals($originalToken, $accessTokenProperty->getValue($this->client));
}
public function testGetPaginatedDataGeneratorDelegatesToPaginationService(): void
{
$payload = ['filters' => []];
$type = 'contacts';
$offset = 0;
$total = 0;
$lastRecordId = null;
$expectedResults = [
['id' => 'id_1', 'properties' => []],
['id' => 'id_2', 'properties' => []],
];
// Mock the pagination service to return a generator
$this->paginationServiceMock
->expects($this->once())
->method('getPaginatedDataGenerator')
->with(
$this->client,
$payload,
$type,
$offset,
$this->anything(),
$this->anything()
)
->willReturnCallback(function () use ($expectedResults) {
foreach ($expectedResults as $result) {
yield $result;
}
});
// Execute the pagination
$results = [];
foreach ($this->client->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastRecordId) as $result) {
$results[] = $result;
}
$this->assertCount(2, $results);
$this->assertEquals('id_1', $results[0]['id']);
$this->assertEquals('id_2', $results[1]['id']);
}
public function testEnsureValidTokenDelegatesToTokenManager(): void
{
$socialAccountMock = $this->createMock(SocialAccount::class);
// Set up OAuth account
$reflection = new \ReflectionClass($this->client);
$oauthAccountProperty = $reflection->getProperty('oauthAccount');
$oauthAccountProperty->setAccessible(true);
$oauthAccountProperty->setValue($this->client, $socialAccountMock);
// Mock token manager to return new token
$this->tokenManagerMock
->expects($this->once())
->method('ensureValidToken')
->with($socialAccountMock)
->willReturn('new_access_token');
// Call ensureValidToken
$this->client->ensureValidToken();
// Verify access token was updated
$accessTokenProperty = $reflection->getProperty('accessToken');
$accessTokenProperty->setAccessible(true);
$this->assertEquals('new_access_token', $accessTokenProperty->getValue($this->client));
}
public function testGetOwnersArchivedWithValidResponse(): void
{
$responseData = [
'results' => [
[
'id' => '123',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'John',
'lastName' => 'Doe',
'userId' => 456,
'userIdIncludingInactive' => 789,
'createdAt' => '2023-01-01T12:00:00Z',
'updatedAt' => '2023-01-02T12:00:00Z',
'archived' => true,
],
],
];
// Create a mock response object
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn($responseData);
// Set up the client to return our test data
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=true'
)
->willReturn($response);
// Call the method
$result = $this->client->getOwnersArchived(true);
// Assert the results
$this->assertCount(1, $result);
$this->assertEquals('123', $result[0]->getId());
$this->assertEquals('[EMAIL]', $result[0]->getEmail());
$this->assertEquals('John Doe', $result[0]->getFullName());
$this->assertTrue($result[0]->isArchived());
}
public function testGetOwnersArchivedWithEmptyResponse(): void
{
// Create a mock response object with empty results
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn(['results' => []]);
// Set up the client to return empty results
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=false'
)
->willReturn($response);
// Call the method
$result = $this->client->getOwnersArchived(false);
// Assert the results
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithInvalidResponse(): void
{
// Create a mock response that will throw an exception when toArray is called
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willThrowException(new \InvalidArgumentException('Invalid JSON'));
// Set up the client to return the problematic response
$this->client->method('makeRequest')
->willReturn($response);
// Mock the logger to expect an error message
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with($this->stringContains('Failed to fetch owners'));
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
// Call the method and expect an empty array on error
$result = $this->client->getOwnersArchived(true);
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithHttpError(): void
{
// Set up the client to throw an exception
$this->client->method('makeRequest')
->willThrowException(new \Exception('HTTP Error'));
// Mock the logger to expect an error message
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with($this->stringContains('Failed to fetch owners'));
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
// Call the method and expect an empty array on error
$result = $this->client->getOwnersArchived(false);
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithOwnerCreationException(): void
{
$responseData = [
'results' => [
[
'id' => '123',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'John',
'lastName' => 'Doe',
'userId' => 456,
'userIdIncludingInactive' => 789,
'createdAt' => '2023-01-01T12:00:00Z',
'updatedAt' => '2023-01-02T12:00:00Z',
'archived' => false,
],
[
'id' => '456',
'email' => '[EMAIL]',
'type' => 'PERSON',
'createdAt' => 'invalid-date-format',
],
[
'id' => '789',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'Jane',
'lastName' => 'Smith',
'userId' => 999,
'userIdIncludingInactive' => 888,
'createdAt' => '2023-01-03T12:00:00Z',
'updatedAt' => '2023-01-04T12:00:00Z',
'archived' => false,
],
],
];
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn($responseData);
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=false'
)
->willReturn($response);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with(
'[HubSpot] Failed to process owner data',
$this->callback(function ($context) {
return isset($context['result']) &&
isset($context['error']) &&
$context['result']['id'] === '456' &&
$context['result']['email'] === '[EMAIL]' &&
str_contains($context['error'], 'invalid-date-format');
})
);
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
$result = $this->client->getOwnersArchived(false);
$this->assertIsArray($result);
$this->assertCount(2, $result);
$this->assertEquals('123', $result[0]->getId());
$this->assertEquals('[EMAIL]', $result[0]->getEmail());
$this->assertEquals('789', $result[1]->getId());
$this->assertEquals('[EMAIL]', $result[1]->getEmail());
}
public function testMakeRequestWithGetMethod(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManager = $this->createMock(HubspotTokenManager::class);
$psrResponse = new Response(200, [], json_encode(['status' => 'success']));
$expectedResponse = new HubspotResponse($psrResponse);
$hubspotClientMock = $this->createMock(HubspotClient::class);
$hubspotClientMock->expects($this->once())
->method('request')
->with(
'GET',
'https://api.hubapi.com/crm/v3/objects/contacts',
[],
null,
true
)
->willReturn($expectedResponse);
$factoryMock = $this->createMock(Factory::class);
$factoryMock->method('getClient')->willReturn($hubspotClientMock);
$client = $this->getMockBuilder(Client::class)
->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])
->onlyMethods(['getInstance'])
->getMock();
$client->method('getInstance')->willReturn($factoryMock);
$client->setAccessToken(new AccessToken(['access_token' => 'test_token']));
$result = $client->makeRequest('/crm/v3/objects/contacts', 'GET');
$this->assertSame($expectedResponse, $result);
}
public function testMakeRequestWithGetMethodAndQueryString(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManag...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8597075,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ClientTest","depth":6,"bounds":{"left":0.875,"top":0.019952115,"width":0.04055851,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ClientTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ClientTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.50265956,"top":0.17478053,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"144","depth":4,"bounds":{"left":0.5142952,"top":0.17478053,"width":0.011968086,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":4,"bounds":{"left":0.52825797,"top":0.17478053,"width":0.008976064,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.53889626,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.5462101,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Hubspot;\n\nuse GuzzleHttp\\Psr7\\Response;\nuse HubSpot\\Client\\Crm\\Associations\\Api\\BatchApi;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\BatchResponsePublicAssociationMulti;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\PublicObjectId;\nuse HubSpot\\Client\\Crm\\Companies\\ApiException as CompanyApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\ApiException as ContactApiException;\nuse HubSpot\\Client\\Crm\\Deals\\Api\\BasicApi as DealsBasicApi;\nuse HubSpot\\Client\\Crm\\Deals\\ApiException as DealApiException;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectWithAssociations as DealWithAssociations;\nuse HubSpot\\Client\\Crm\\Pipelines\\Api\\PipelinesApi;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\CollectionResponsePipeline;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Error;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Pipeline;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\PipelineStage;\nuse HubSpot\\Client\\Crm\\Properties\\Api\\CoreApi;\nuse HubSpot\\Client\\Crm\\Properties\\Model\\Property;\nuse HubSpot\\Discovery as HubSpotDiscovery;\nuse HubSpot\\Discovery\\Crm\\Deals\\Discovery as DealsDiscovery;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\HubspotTokenManager;\nuse Jiminny\\Services\\Crm\\Hubspot\\Pagination\\HubspotPaginationService;\nuse Jiminny\\Services\\SocialAccountService;\nuse League\\OAuth2\\Client\\Token\\AccessToken;\nuse Mockery;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse PHPUnit\\Framework\\TestCase;\nuse Psr\\Log\\NullLogger;\nuse SevenShores\\Hubspot\\Endpoints\\Engagements;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Factory;\nuse SevenShores\\Hubspot\\Http\\Client as HubspotClient;\nuse SevenShores\\Hubspot\\Http\\Response as HubspotResponse;\n\n/**\n * @runTestsInSeparateProcesses\n *\n * @preserveGlobalState disabled\n */\nclass ClientTest extends TestCase\n{\n private const string RESPONSE_TYPE_STAGE_FIELD = 'stage';\n private const string RESPONSE_TYPE_PIPELINE_FIELD = 'pipeline';\n private const string RESPONSE_TYPE_REGULAR_FIELD = 'regular';\n /**\n * @var Client&MockObject\n */\n private Client $client;\n private Configuration $config;\n\n /**\n * @var SocialAccountService&MockObject\n */\n private SocialAccountService $socialAccountServiceMock;\n\n /**\n * @var HubspotPaginationService&MockObject\n */\n private HubspotPaginationService $paginationServiceMock;\n\n /**\n * @var HubspotTokenManager&MockObject\n */\n private HubspotTokenManager $tokenManagerMock;\n\n /**\n * @var CoreApi&MockObject\n */\n private CoreApi $coreApiMock;\n\n /**\n * @var PipelinesApi&MockObject\n */\n private PipelinesApi $pipelinesApiMock;\n\n /**\n * @var BatchApi&MockObject\n */\n private BatchApi $associationsBatchApiMock;\n\n /**\n * @var DealsBasicApi&MockObject\n */\n private DealsBasicApi $dealsBasicApiMock;\n\n /**\n * @var Engagements&MockObject\n */\n private $engagementsMock;\n\n /**\n * @var HubspotClient&MockObject\n */\n private HubspotClient $hubspotClientMock;\n\n protected function setUp(): void\n {\n // Create mocks for dependencies\n $this->socialAccountServiceMock = $this->createMock(SocialAccountService::class);\n $this->paginationServiceMock = $this->createMock(HubspotPaginationService::class);\n $this->tokenManagerMock = $this->createMock(HubspotTokenManager::class);\n\n // Create a real Client instance with mocked dependencies\n // Create a partial mock only for the methods we need to mock\n $client = $this->createPartialMock(Client::class, ['getInstance', 'getNewInstance', 'makeRequest']);\n $client->setLogger(new NullLogger());\n\n // Inject the real dependencies using reflection\n $reflection = new \\ReflectionClass(Client::class);\n\n $accountServiceProperty = $reflection->getProperty('accountService');\n $accountServiceProperty->setAccessible(true);\n $accountServiceProperty->setValue($client, $this->socialAccountServiceMock);\n\n $paginationServiceProperty = $reflection->getProperty('paginationService');\n $paginationServiceProperty->setAccessible(true);\n $paginationServiceProperty->setValue($client, $this->paginationServiceMock);\n\n $tokenManagerProperty = $reflection->getProperty('tokenManager');\n $tokenManagerProperty->setAccessible(true);\n $tokenManagerProperty->setValue($client, $this->tokenManagerMock);\n $factoryMock = $this->createMock(Factory::class);\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $engagementsMock = $this->createMock(\\SevenShores\\Hubspot\\Endpoints\\Engagements::class);\n\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n $factoryMock->method('__call')->with('engagements')->willReturn($engagementsMock);\n\n $client->method('getInstance')->willReturn($factoryMock);\n\n $discoveryMock = $this->createMock(HubSpotDiscovery\\Discovery::class);\n $crmMock = $this->createMock(HubSpotDiscovery\\Crm\\Discovery::class);\n $associationsMock = $this->createMock(HubSpotDiscovery\\Crm\\Associations\\Discovery::class);\n $propertiesMock = $this->createMock(HubSpotDiscovery\\Crm\\Properties\\Discovery::class);\n $pipelinesMock = $this->createMock(HubSpotDiscovery\\Crm\\Pipelines\\Discovery::class);\n $coreApiMock = $this->createMock(CoreApi::class);\n $pipelinesApiMock = $this->createMock(PipelinesApi::class);\n $associationsBatchApiMock = $this->createMock(BatchApi::class);\n $dealsBasicApiMock = $this->createMock(DealsBasicApi::class);\n\n $propertiesMock->method('__call')->with('coreApi')->willReturn($coreApiMock);\n $pipelinesMock->method('__call')->with('pipelinesApi')->willReturn($pipelinesApiMock);\n $associationsMock->method('__call')->with('batchApi')->willReturn($associationsBatchApiMock);\n $dealsDiscoveryMock = $this->createMock(DealsDiscovery::class);\n $dealsDiscoveryMock->method('__call')->with('basicApi')->willReturn($dealsBasicApiMock);\n\n $returnMap = ['properties' => $propertiesMock, 'pipelines' => $pipelinesMock, 'associations' => $associationsMock, 'deals' => $dealsDiscoveryMock];\n $crmMock->method('__call')\n ->willReturnCallback(static fn (string $name) => $returnMap[$name])\n ;\n\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n\n $this->client = $client;\n $this->config = $this->createMock(Configuration::class);\n $this->client->setConfiguration($this->config);\n $this->coreApiMock = $coreApiMock;\n $this->pipelinesApiMock = $pipelinesApiMock;\n $this->associationsBatchApiMock = $associationsBatchApiMock;\n $this->dealsBasicApiMock = $dealsBasicApiMock;\n $this->engagementsMock = $engagementsMock;\n $this->hubspotClientMock = $hubspotClientMock;\n }\n\n public function testGetMinimumApiVersion(): void\n {\n $this->assertIsString($this->client->getMinimumApiVersion());\n }\n\n public function testGetInstance(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $client = new Client($socialAccountService, $paginationService, $tokenManager);\n $client->setBaseUrl('https://example.com');\n $client->setAccessToken(new AccessToken(['access_token' => 'foo']));\n $factory = $client->getInstance();\n\n $this->assertEquals('foo', $factory->getClient()->key);\n }\n\n public function testGetNewInstance(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $client = new Client($socialAccountService, $paginationService, $tokenManager);\n $client->setAccessToken(new AccessToken(['access_token' => 'foo']));\n\n $factory = $client->getNewInstance();\n $token = $factory->auth()->oAuth()->accessTokensApi()->getConfig()->getAccessToken();\n $this->assertEquals('foo', $token);\n }\n\n public function testFetchProperty(): void\n {\n $this->coreApiMock->method('getByName')->willReturn($this->generateProperty());\n\n $this->assertEquals(\n 'some_property',\n $this->client->fetchProperty('foo', 'test')['name']\n );\n }\n\n public function testFetchPropertyOptions(): void\n {\n $this->coreApiMock->method('getByName')->willReturn($this->generateProperty());\n\n $this->assertEquals(\n [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n $this->client->fetchPropertyOptions('foo', 'test')\n );\n }\n\n public function testFetchCallDispositions(): void\n {\n $this->engagementsMock\n ->method('getCallDispositions')\n ->willReturn($this->generateHubSpotResponse([\n [\n 'id' => 1,\n 'label' => 'foo',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'bar',\n 'deleted' => false,\n ],\n ]))\n ;\n\n $this->assertEquals(\n [\n [\n 'id' => 1,\n 'label' => 'foo',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'bar',\n 'deleted' => false,\n ],\n ],\n $this->client->fetchCallDispositions()\n );\n }\n\n public function testFetchDispositionFieldOptions(): void\n {\n $this->engagementsMock->method('getCallDispositions')\n ->willReturn($this->generateHubSpotResponse(\n [\n [\n 'id' => 1,\n 'label' => 'Label 1',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'Label 2',\n 'deleted' => true,\n ],\n ]\n ))\n ;\n\n $this->assertEquals(\n [\n [\n 'value' => 1,\n 'id' => 1,\n 'label' => 'Label 1',\n ],\n ],\n $this->client->fetchDispositionFieldOptions()\n );\n }\n\n public function testFetchOpportunityPipelineStages(): void\n {\n /**\n * @TODO: The class CollectionResponsePipeline will be deprecated in the next Hubspot version\n */\n $this->pipelinesApiMock\n ->method('getAll')\n ->with('deals')\n ->willReturn(new CollectionResponsePipeline([\n 'results' => [$this->generatePipeline()],\n ]))\n ;\n\n $this->assertEquals(\n [\n ['id' => 'foo', 'label' => 'bar'],\n ['id' => 'baz', 'label' => 'qux'],\n ],\n $this->client->fetchOpportunityPipelineStages()\n );\n }\n\n public function testFetchOpportunityPipelineStagesErrorResponse(): void\n {\n $this->pipelinesApiMock\n ->method('getAll')\n ->with('deals')\n ->willReturn(new Error(['message' => 'test error']))\n ;\n\n $this->assertEmpty($this->client->fetchOpportunityPipelineStages());\n }\n\n #[\\PHPUnit\\Framework\\Attributes\\DataProvider('meetingOutcomeFieldProvider')]\n public function testFetchMeetingOutcomeFieldOptions(string $fieldId, string $expectedEndpoint): void\n {\n $field = new Field(['crm_provider_id' => $fieldId]);\n\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->with('GET', $expectedEndpoint)\n ->willReturn($this->generateHubSpotResponse(\n [\n 'options' => [\n ['value' => 'option_1', 'label' => 'Option 1', 'displayOrder' => 0],\n ['value' => 'option_2', 'label' => 'Option 2', 'displayOrder' => 1],\n ['value' => 'option_3', 'label' => 'Option 3', 'displayOrder' => 2],\n ],\n ]\n ))\n ;\n\n $this->assertEquals(\n [\n ['id' => 'option_1', 'value' => 'option_1', 'label' => 'Option 1', 'display_order' => 0],\n ['id' => 'option_2', 'value' => 'option_2', 'label' => 'Option 2', 'display_order' => 1],\n ['id' => 'option_3', 'value' => 'option_3', 'label' => 'Option 3', 'display_order' => 2],\n ],\n $this->client->fetchMeetingOutcomeFieldOptions($field)\n );\n }\n\n public static function meetingOutcomeFieldProvider(): array\n {\n return [\n 'meeting outcome field' => [\n 'meetingOutcome',\n 'https://api.hubapi.com/crm/v3/properties/meeting/hs_meeting_outcome',\n ],\n 'activity type field' => [\n 'foobar',\n 'https://api.hubapi.com/crm/v3/properties/call/hs_activity_type',\n ],\n ];\n }\n\n #[\\PHPUnit\\Framework\\Attributes\\DataProvider('opportunityFieldOptionsProvider')]\n public function testFetchOpportunityFieldOptions(Field $field, string $type): void\n {\n $responses = [\n self::RESPONSE_TYPE_STAGE_FIELD => [\n ['id' => 'foo', 'label' => 'bar'],\n ['id' => 'baz', 'label' => 'qux'],\n ],\n self::RESPONSE_TYPE_PIPELINE_FIELD => [\n ['id' => '123', 'label' => 'Sales'],\n ['id' => 'default', 'label' => 'CS'],\n ],\n self::RESPONSE_TYPE_REGULAR_FIELD => [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n ];\n\n $field = $this->createMock(Field::class);\n\n if ($type === self::RESPONSE_TYPE_REGULAR_FIELD) {\n $field->method('isStageField')->willReturn(false);\n $field->method('isPipelineField')->willReturn(false);\n $propertyResponse = $this->generateProperty();\n $this->coreApiMock->method('getByName')->willReturn($propertyResponse);\n }\n\n if ($type === self::RESPONSE_TYPE_STAGE_FIELD) {\n $field->method('isStageField')->willReturn(true);\n /**\n * @TODO: The class CollectionResponsePipeline will be deprecated in the next Hubspot version\n */\n\n $pipelineStagesResponse = new CollectionResponsePipeline([\n 'results' => [$this->generatePipeline()],\n ]);\n $this->pipelinesApiMock\n ->method('getAll')\n ->willReturn($pipelineStagesResponse);\n }\n\n if ($type === self::RESPONSE_TYPE_PIPELINE_FIELD) {\n $field->method('isStageField')->willReturn(false);\n $field->method('isPipelineField')->willReturn(true);\n $pipelineResponse = $this->generateHubSpotResponse([\n 'results' => [\n ['id' => '123', 'label' => 'Sales'],\n ['id' => 'default', 'label' => 'CS'],\n ],\n ]);\n $this->client\n ->method('makeRequest')\n ->with('/crm/v3/pipelines/deals')\n ->willReturn($pipelineResponse);\n }\n\n $this->assertEquals(\n $responses[$type],\n $this->client->fetchOpportunityFieldOptions($field)\n );\n }\n\n public static function opportunityFieldOptionsProvider(): array\n {\n return [\n 'stage field' => [\n 'field' => new Field(['crm_provider_id' => 'dealstage']),\n 'type' => self::RESPONSE_TYPE_STAGE_FIELD,\n ],\n 'pipeline field' => [\n 'field' => new Field(['crm_provider_id' => 'pipeline']),\n 'type' => self::RESPONSE_TYPE_PIPELINE_FIELD,\n ],\n 'regular field' => [\n 'field' => new Field(['crm_provider_id' => 'some_property']),\n 'type' => self::RESPONSE_TYPE_REGULAR_FIELD,\n ],\n ];\n }\n\n private function generateHubSpotResponse(array $data): HubspotResponse\n {\n return new HubspotResponse(new Response(200, [], json_encode($data)));\n }\n\n private function generateProperty(): Property\n {\n return new Property([\n 'name' => 'some_property',\n 'options' => [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n ]);\n }\n\n private function generatePipeline(): Pipeline\n {\n return new Pipeline(['stages' => [\n new PipelineStage(['id' => 'foo', 'label' => 'bar']),\n new PipelineStage(['id' => 'baz', 'label' => 'qux']),\n ]]);\n }\n\n public function testFetchOpportunityPipelines(): void\n {\n $this->client\n ->method('makeRequest')\n ->with('/crm/v3/pipelines/deals')\n ->willReturn($this->generateHubSpotResponse([\n 'results' => [\n ['id' => 'id_1', 'label' => 'Option 1', 'displayOrder' => 0],\n ['id' => 'id_2', 'label' => 'Option 2', 'displayOrder' => 1],\n ['id' => 'id_3', 'label' => 'Option 3', 'displayOrder' => 2],\n ],\n ]));\n\n $this->assertEquals(\n [\n ['id' => 'id_1', 'label' => 'Option 1'],\n ['id' => 'id_2', 'label' => 'Option 2'],\n ['id' => 'id_3', 'label' => 'Option 3'],\n ],\n $this->client->fetchOpportunityPipelines()\n );\n }\n\n public function testGetPaginatedData(): void\n {\n $expectedResults = [\n ['id' => 'id_1', 'properties' => []],\n ['id' => 'id_2', 'properties' => []],\n ['id' => 'id_3', 'properties' => []],\n ];\n\n // Mock the pagination service to return a generator and modify reference parameters\n $this->paginationServiceMock\n ->expects($this->once())\n ->method('getPaginatedDataGenerator')\n ->with(\n $this->client,\n ['payload_key' => 'payload_value'],\n 'foobar',\n 0,\n $this->anything(),\n $this->anything()\n )\n ->willReturnCallback(function ($client, $payload, $type, $offset, &$total, &$lastRecordId) use ($expectedResults) {\n $total = 3;\n $lastRecordId = 'id_3';\n foreach ($expectedResults as $result) {\n yield $result;\n }\n });\n\n $this->assertEquals(\n [\n 'results' => $expectedResults,\n 'total' => 3,\n 'last_record' => 'id_3',\n ],\n $this->client->getPaginatedData(['payload_key' => 'payload_value'], 'foobar')\n );\n }\n\n public function testGetAssociationsData(): void\n {\n $ids = ['1', '2', '3'];\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $responseResults = [];\n foreach ($ids as $id) {\n $from = new PublicObjectId();\n $from->setId($id);\n\n $to1 = new PublicObjectId();\n $to1->setId('contact_' . $id . '_1');\n $to2 = new PublicObjectId();\n $to2->setId('contact_' . $id . '_2');\n\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to1, $to2]);\n\n $responseResults[] = $result;\n }\n\n $batchResponse = new BatchResponsePublicAssociationMulti();\n $batchResponse->setResults($responseResults);\n\n $this->associationsBatchApiMock->expects($this->once())\n ->method('read')\n ->with(\n $this->equalTo($fromObject),\n $this->equalTo($toObject),\n $this->callback(function (BatchInputPublicObjectId $batchInput) use ($ids) {\n $inputIds = array_map(\n fn ($input) => $input->getId(),\n $batchInput->getInputs()\n );\n\n return $inputIds === $ids;\n })\n )\n ->willReturn($batchResponse);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $expectedResult = [\n '1' => ['contact_1_1', 'contact_1_2'],\n '2' => ['contact_2_1', 'contact_2_2'],\n '3' => ['contact_3_1', 'contact_3_2'],\n ];\n\n $this->assertEquals($expectedResult, $result);\n }\n\n public function testGetAssociationsDataHandlesException(): void\n {\n $ids = ['1', '2', '3'];\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $exception = new \\Exception('API Error');\n\n $this->associationsBatchApiMock->expects($this->once())\n ->method('read')\n ->with(\n $this->equalTo($fromObject),\n $this->equalTo($toObject),\n $this->callback(function ($batchInput) use ($ids) {\n return $batchInput instanceof \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId\n && count($batchInput->getInputs()) === count($ids);\n })\n )\n ->willThrowException($exception);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with(\n '[Hubspot] Failed to fetch associations',\n [\n 'from_object' => $fromObject,\n 'to_object' => $toObject,\n 'reason' => 'API Error',\n ]\n );\n\n $this->client->setLogger($loggerMock);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $this->assertEmpty($result);\n $this->assertIsArray($result);\n }\n\n public function testGetAssociationsDataWithLargeDataSet(): void\n {\n $ids = array_map(fn ($i) => (string) $i, range(1, 2500)); // More than 1000 items\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $firstBatchResponse = new BatchResponsePublicAssociationMulti();\n $firstBatchResults = array_map(function ($id) {\n $from = new PublicObjectId();\n $from->setId($id);\n $to = new PublicObjectId();\n $to->setId('contact_' . $id);\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to]);\n\n return $result;\n }, array_slice($ids, 0, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));\n $firstBatchResponse->setResults($firstBatchResults);\n\n $secondBatchResponse = new BatchResponsePublicAssociationMulti();\n $secondBatchResults = array_map(function ($id) {\n $from = new PublicObjectId();\n $from->setId($id);\n $to = new PublicObjectId();\n $to->setId('contact_' . $id);\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to]);\n\n return $result;\n }, array_slice($ids, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));\n $secondBatchResponse->setResults($secondBatchResults);\n\n $this->associationsBatchApiMock->expects($this->exactly(3))\n ->method('read')\n ->willReturnOnConsecutiveCalls($firstBatchResponse, $secondBatchResponse);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $this->assertCount(2500, $result);\n $this->assertArrayHasKey('1', $result);\n $this->assertArrayHasKey('2500', $result);\n $this->assertEquals(['contact_1'], $result['1']);\n $this->assertEquals(['contact_2500'], $result['2500']);\n }\n\n public function testGetContactByEmailSuccess(): void\n {\n $email = 'test@example.com';\n $fields = ['firstname', 'lastname', 'email'];\n\n $contactMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $contactMock->method('getId')->willReturn('12345');\n $contactMock->method('getProperties')->willReturn([\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n 'email' => 'test@example.com',\n ]);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, 'firstname,lastname,email', null, false, 'email')\n ->willReturn($contactMock);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new NullLogger());\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([\n 'id' => '12345',\n 'properties' => [\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n 'email' => 'test@example.com',\n ],\n ], $result);\n }\n\n public function testGetContactByEmailWithEmptyFields(): void\n {\n $email = 'test@example.com';\n $fields = [];\n\n $contactMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $contactMock->method('getId')->willReturn('12345');\n $contactMock->method('getProperties')->willReturn(['email' => 'test@example.com']);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, '', null, false, 'email')\n ->willReturn($contactMock);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new NullLogger());\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([\n 'id' => '12345',\n 'properties' => ['email' => 'test@example.com'],\n ], $result);\n }\n\n public function testGetContactByEmailApiException(): void\n {\n $email = 'nonexistent@example.com';\n $fields = ['firstname', 'lastname'];\n\n $exception = new \\HubSpot\\Client\\Crm\\Contacts\\ApiException('Contact not found', 404);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, 'firstname,lastname', null, false, 'email')\n ->willThrowException($exception);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('info')\n ->with(\n '[Hubspot] Failed to fetch contact',\n [\n 'email' => $email,\n 'reason' => 'Contact not found',\n ]\n );\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger($loggerMock);\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([], $result);\n }\n\n public function testGetOpportunityById(): void\n {\n $opportunityId = '12345';\n $expectedProperties = [\n 'dealname' => 'Test Opportunity',\n 'amount' => '1000.00',\n 'closedate' => '2024-12-31T23:59:59.999Z',\n 'dealstage' => 'presentationscheduled',\n 'pipeline' => 'default',\n ];\n\n $mockHubspotOpportunity = $this->createMock(DealWithAssociations::class);\n $mockHubspotOpportunity->method('getProperties')->willReturn((object) $expectedProperties);\n $mockHubspotOpportunity->method('getId')->willReturn($opportunityId);\n $now = new \\DateTimeImmutable();\n $mockHubspotOpportunity->method('getCreatedAt')->willReturn($now);\n $mockHubspotOpportunity->method('getUpdatedAt')->willReturn($now);\n $mockHubspotOpportunity->method('getArchived')->willReturn(false);\n\n $this->dealsBasicApiMock\n ->expects($this->once())\n ->method('getById')\n ->willReturn($mockHubspotOpportunity);\n\n // Assuming Client::getOpportunityById processes the SimplePublicObject and returns an associative array.\n // The structure might be like: ['id' => ..., 'properties' => [...], 'createdAt' => ..., ...]\n // Adjust assertions below based on the actual return structure of your method.\n $result = $this->client->getOpportunityById($opportunityId, ['test', 'test']);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertEquals($opportunityId, $result['id']);\n }\n\n public function testGetContactById(): void\n {\n $crmId = 'contact-123';\n $fields = ['firstname', 'lastname'];\n $expectedProperties = ['firstname' => 'John', 'lastname' => 'Doe'];\n\n $mockContact = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $mockContact->method('getId')->willReturn($crmId);\n $mockContact->method('getProperties')->willReturn((object) $expectedProperties);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($crmId, 'firstname,lastname')\n ->willReturn($mockContact);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new \\Psr\\Log\\NullLogger());\n\n $result = $client->getContactById($crmId, $fields);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertArrayHasKey('properties', $result);\n $this->assertEquals($crmId, $result['id']);\n $this->assertEquals((object) $expectedProperties, $result['properties']);\n }\n\n public function testGetAccountById(): void\n {\n $crmId = 'account-123';\n $fields = ['name', 'industry'];\n $expectedProperties = ['name' => 'Acme Corp', 'industry' => 'Technology'];\n\n $mockCompany = $this->createMock(\\HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectWithAssociations::class);\n $mockCompany->method('getId')->willReturn($crmId);\n $mockCompany->method('getProperties')->willReturn((object) $expectedProperties);\n\n $companiesApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Companies\\Api\\BasicApi::class);\n $companiesApiMock->expects($this->once())\n ->method('getById')\n ->with($crmId, 'name,industry')\n ->willReturn($mockCompany);\n\n $companiesDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Companies\\Discovery::class);\n $companiesDiscoveryMock->method('__call')->with('basicApi')->willReturn($companiesApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($companiesDiscoveryMock) {\n if ($name === 'companies') {\n return $companiesDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new \\Psr\\Log\\NullLogger());\n\n $result = $client->getAccountById($crmId, $fields);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertArrayHasKey('properties', $result);\n $this->assertEquals($crmId, $result['id']);\n $this->assertEquals((object) $expectedProperties, $result['properties']);\n }\n\n public function testEnsureValidTokenWithNoTokenUpdate(): void\n {\n $socialAccountMock = $this->createMock(SocialAccount::class);\n\n // Set up OAuth account\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, $socialAccountMock);\n\n $originalToken = 'original_token';\n $accessTokenProperty = $reflection->getProperty('accessToken');\n $accessTokenProperty->setAccessible(true);\n $accessTokenProperty->setValue($this->client, $originalToken);\n\n // Mock token manager to return null (no refresh needed)\n $this->tokenManagerMock\n ->expects($this->once())\n ->method('ensureValidToken')\n ->with($socialAccountMock)\n ->willReturn(null);\n\n // Call ensureValidToken\n $this->client->ensureValidToken();\n\n // Verify access token was not changed\n $this->assertEquals($originalToken, $accessTokenProperty->getValue($this->client));\n }\n\n\n\n\n\n\n public function testGetPaginatedDataGeneratorDelegatesToPaginationService(): void\n {\n $payload = ['filters' => []];\n $type = 'contacts';\n $offset = 0;\n $total = 0;\n $lastRecordId = null;\n\n $expectedResults = [\n ['id' => 'id_1', 'properties' => []],\n ['id' => 'id_2', 'properties' => []],\n ];\n\n // Mock the pagination service to return a generator\n $this->paginationServiceMock\n ->expects($this->once())\n ->method('getPaginatedDataGenerator')\n ->with(\n $this->client,\n $payload,\n $type,\n $offset,\n $this->anything(),\n $this->anything()\n )\n ->willReturnCallback(function () use ($expectedResults) {\n foreach ($expectedResults as $result) {\n yield $result;\n }\n });\n\n // Execute the pagination\n $results = [];\n foreach ($this->client->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastRecordId) as $result) {\n $results[] = $result;\n }\n\n $this->assertCount(2, $results);\n $this->assertEquals('id_1', $results[0]['id']);\n $this->assertEquals('id_2', $results[1]['id']);\n }\n\n public function testEnsureValidTokenDelegatesToTokenManager(): void\n {\n $socialAccountMock = $this->createMock(SocialAccount::class);\n\n // Set up OAuth account\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, $socialAccountMock);\n\n // Mock token manager to return new token\n $this->tokenManagerMock\n ->expects($this->once())\n ->method('ensureValidToken')\n ->with($socialAccountMock)\n ->willReturn('new_access_token');\n\n // Call ensureValidToken\n $this->client->ensureValidToken();\n\n // Verify access token was updated\n $accessTokenProperty = $reflection->getProperty('accessToken');\n $accessTokenProperty->setAccessible(true);\n $this->assertEquals('new_access_token', $accessTokenProperty->getValue($this->client));\n }\n\n public function testGetOwnersArchivedWithValidResponse(): void\n {\n $responseData = [\n 'results' => [\n [\n 'id' => '123',\n 'email' => 'owner1@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'John',\n 'lastName' => 'Doe',\n 'userId' => 456,\n 'userIdIncludingInactive' => 789,\n 'createdAt' => '2023-01-01T12:00:00Z',\n 'updatedAt' => '2023-01-02T12:00:00Z',\n 'archived' => true,\n ],\n ],\n ];\n\n // Create a mock response object\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn($responseData);\n\n // Set up the client to return our test data\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=true'\n )\n ->willReturn($response);\n\n // Call the method\n $result = $this->client->getOwnersArchived(true);\n\n // Assert the results\n $this->assertCount(1, $result);\n $this->assertEquals('123', $result[0]->getId());\n $this->assertEquals('owner1@example.com', $result[0]->getEmail());\n $this->assertEquals('John Doe', $result[0]->getFullName());\n $this->assertTrue($result[0]->isArchived());\n }\n\n public function testGetOwnersArchivedWithEmptyResponse(): void\n {\n // Create a mock response object with empty results\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn(['results' => []]);\n\n // Set up the client to return empty results\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=false'\n )\n ->willReturn($response);\n\n // Call the method\n $result = $this->client->getOwnersArchived(false);\n\n // Assert the results\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithInvalidResponse(): void\n {\n // Create a mock response that will throw an exception when toArray is called\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willThrowException(new \\InvalidArgumentException('Invalid JSON'));\n\n // Set up the client to return the problematic response\n $this->client->method('makeRequest')\n ->willReturn($response);\n\n // Mock the logger to expect an error message\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with($this->stringContains('Failed to fetch owners'));\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n // Call the method and expect an empty array on error\n $result = $this->client->getOwnersArchived(true);\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithHttpError(): void\n {\n // Set up the client to throw an exception\n $this->client->method('makeRequest')\n ->willThrowException(new \\Exception('HTTP Error'));\n\n // Mock the logger to expect an error message\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with($this->stringContains('Failed to fetch owners'));\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n // Call the method and expect an empty array on error\n $result = $this->client->getOwnersArchived(false);\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithOwnerCreationException(): void\n {\n $responseData = [\n 'results' => [\n [\n 'id' => '123',\n 'email' => 'valid@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'John',\n 'lastName' => 'Doe',\n 'userId' => 456,\n 'userIdIncludingInactive' => 789,\n 'createdAt' => '2023-01-01T12:00:00Z',\n 'updatedAt' => '2023-01-02T12:00:00Z',\n 'archived' => false,\n ],\n [\n 'id' => '456',\n 'email' => 'invalid@example.com',\n 'type' => 'PERSON',\n 'createdAt' => 'invalid-date-format',\n ],\n [\n 'id' => '789',\n 'email' => 'another@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'Jane',\n 'lastName' => 'Smith',\n 'userId' => 999,\n 'userIdIncludingInactive' => 888,\n 'createdAt' => '2023-01-03T12:00:00Z',\n 'updatedAt' => '2023-01-04T12:00:00Z',\n 'archived' => false,\n ],\n ],\n ];\n\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn($responseData);\n\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=false'\n )\n ->willReturn($response);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with(\n '[HubSpot] Failed to process owner data',\n $this->callback(function ($context) {\n return isset($context['result']) &&\n isset($context['error']) &&\n $context['result']['id'] === '456' &&\n $context['result']['email'] === 'invalid@example.com' &&\n str_contains($context['error'], 'invalid-date-format');\n })\n );\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n $result = $this->client->getOwnersArchived(false);\n\n $this->assertIsArray($result);\n $this->assertCount(2, $result);\n $this->assertEquals('123', $result[0]->getId());\n $this->assertEquals('valid@example.com', $result[0]->getEmail());\n $this->assertEquals('789', $result[1]->getId());\n $this->assertEquals('another@example.com', $result[1]->getEmail());\n }\n\n public function testMakeRequestWithGetMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'GET',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n [],\n null,\n true\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'GET');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithGetMethodAndQueryString(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'GET',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n [],\n 'limit=10&offset=0',\n true\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'GET', [], 'limit=10&offset=0');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPostMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'POST',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'POST', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPutMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'firstname' => 'Jane',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345', 'updated' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'PUT',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'PUT', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPatchMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'email' => 'newemail@example.com',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345', 'patched' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'PATCH',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'PATCH', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithDeleteMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['deleted' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'DELETE',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => []]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'DELETE');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithEmptyPayload(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'ok']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'POST',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n ['json' => []]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'POST', []);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestPrependsBaseUrl(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n $this->anything(),\n 'https://api.hubapi.com/test/endpoint',\n $this->anything()\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $client->makeRequest('/test/endpoint', 'GET');\n }\n\n public function testGetOpportunitiesByIds(): void\n {\n $crmIds = ['deal1', 'deal2', 'deal3'];\n $fields = ['dealname', 'amount'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getOpportunitiesByIds'));\n }\n\n public function testGetCompaniesByIds(): void\n {\n $crmIds = ['company1', 'company2'];\n $fields = ['name', 'industry'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getCompaniesByIds'));\n }\n\n public function testGetContactsByIds(): void\n {\n $crmIds = ['contact1', 'contact2'];\n $fields = ['firstname', 'lastname'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getContactsByIds'));\n }\n\n public function testBatchReadObjectsEmptyIds(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $result = $method->invokeArgs($this->client, ['deals', [], ['dealname']]);\n\n $this->assertEquals([], $result);\n }\n\n public function testBatchReadObjectsExceedsBatchSize(): void\n {\n $crmIds = array_fill(0, 101, 'deal_id'); // 101 IDs, exceeds limit of 100\n\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $this->expectException(\\InvalidArgumentException::class);\n $this->expectExceptionMessage('Batch size cannot exceed 100 deals');\n\n $method->invokeArgs($this->client, ['deals', $crmIds, ['dealname']]);\n }\n\n public function testBatchReadObjectsUnsupportedObjectType(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $this->expectException(\\Jiminny\\Exceptions\\CrmException::class);\n $this->expectExceptionMessage('Failed to batch fetch unsupported');\n\n $method->invokeArgs($this->client, ['unsupported', ['id1'], ['field1']]);\n }\n\n public function testIsUnauthorizedExceptionWithBadRequest401(): void\n {\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\BadRequest('Unauthorized', 401);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithBadRequestNon401(): void\n {\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\BadRequest('Bad Request', 400);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertFalse($result);\n }\n\n public function testIsUnauthorizedExceptionWithGuzzleRequestException(): void\n {\n $response = new Response(401, [], 'Unauthorized');\n $exception = new \\GuzzleHttp\\Exception\\RequestException('Unauthorized', new \\GuzzleHttp\\Psr7\\Request('GET', 'test'), $response);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithGuzzleClientException(): void\n {\n $exception = new \\GuzzleHttp\\Exception\\ClientException('Unauthorized', new \\GuzzleHttp\\Psr7\\Request('GET', 'test'), new Response(401));\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithStringMatching(): void\n {\n $exception = new \\Exception('HTTP 401 Unauthorized error occurred');\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithNonUnauthorized(): void\n {\n $exception = new \\Exception('Some other error');\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertFalse($result);\n }\n\n public function testEnsureValidTokenWithNullOauthAccount(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, null);\n\n // Should not throw exception and not call token manager\n $this->tokenManagerMock->expects($this->never())->method('ensureValidToken');\n\n $this->client->ensureValidToken();\n\n $this->assertTrue(true); // Test passes if no exception is thrown\n }\n\n public function testGetConfig(): void\n {\n $result = $this->client->getConfig();\n\n $this->assertSame($this->config, $result);\n }\n\n public function testGetOwners(): void\n {\n // Test that the method exists and can be called\n $this->assertTrue(method_exists($this->client, 'getOwners'));\n\n // Since getOwners has complex HubSpot API dependencies,\n // we'll just verify the method exists for now\n $this->assertIsCallable([$this->client, 'getOwners']);\n }\n\n public function testCreateMeeting(): void\n {\n $payload = [\n 'properties' => [\n 'hs_meeting_title' => 'Test Meeting',\n 'hs_meeting_start_time' => '2024-01-01T10:00:00Z',\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['id' => 'meeting123']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with('/crm/v3/objects/meetings', 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->createMeeting($payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testUpdateMeeting(): void\n {\n $meetingId = 'meeting123';\n $payload = [\n 'properties' => [\n 'hs_meeting_title' => 'Updated Meeting',\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['id' => $meetingId, 'updated' => true]);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v3/objects/meetings/{$meetingId}\", 'PATCH', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->updateMeeting($meetingId, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testAddAssociations(): void\n {\n $objectType = 'deals';\n $associationType = 'contacts';\n $payload = [\n 'inputs' => [\n ['from' => ['id' => 'deal1'], 'to' => ['id' => 'contact1']],\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['status' => 'success']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v4/associations/{$objectType}/{$associationType}/batch/create\", 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->addAssociations($objectType, $associationType, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testRemoveAssociations(): void\n {\n $objectType = 'deals';\n $associationType = 'contacts';\n $payload = [\n 'inputs' => [\n ['from' => ['id' => 'deal1'], 'to' => ['id' => 'contact1']],\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['status' => 'success']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v4/associations/{$objectType}/{$associationType}/batch/archive\", 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->removeAssociations($objectType, $associationType, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n // -------------------------------------------------------------------------\n // search() / executeRequest() tests\n // -------------------------------------------------------------------------\n\n public function testSearchReturnsDecodedArrayOnSuccess(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n\n $this->config->method('getId')->willReturn(42);\n\n $payload = ['filters' => []];\n $responseData = ['results' => [['id' => '1']], 'total' => 1, 'paging' => []];\n $hubspotResponse = new HubspotResponse(new Response(200, [], json_encode($responseData)));\n\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->with('POST', Client::BASE_URL . '/crm/v3/objects/contacts/search', ['json' => $payload])\n ->willReturn($hubspotResponse);\n\n $result = $this->client->search('contacts', $payload);\n\n $this->assertSame($responseData, $result);\n\n Mockery::close();\n }\n\n public function testSearchThrowsRateLimitExceptionWhenCircuitBreakerActive(): void\n {\n $futureTimestamp = (string) (time() + 30);\n\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn($futureTimestamp);\n\n $this->config->method('getId')->willReturn(42);\n\n $this->hubspotClientMock->expects($this->never())->method('request');\n\n $this->expectException(RateLimitException::class);\n $this->expectExceptionMessage('Hubspot rate limit (cached circuit-breaker)');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchThrowsRateLimitExceptionAndSetsNxOnFresh429(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n // NX + TTL option array — exact TTL depends on parseRetryAfter, verified separately\n $redisMock->shouldReceive('set')\n ->once()\n ->with(\n 'hubspot:ratelimit:config:42',\n Mockery::type('string'),\n Mockery::on(fn ($opts) => is_array($opts) && in_array('nx', $opts, true))\n );\n\n $this->config->method('getId')->willReturn(42);\n\n $badRequest = new BadRequest('Rate limit exceeded', 429);\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->willThrowException($badRequest);\n\n $this->expectException(RateLimitException::class);\n $this->expectExceptionMessage('Hubspot returned 429');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchPropagatesNonRateLimitException(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n\n $this->config->method('getId')->willReturn(42);\n\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\HubspotException('Server error', 500);\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->willThrowException($exception);\n\n $this->expectException(\\SevenShores\\Hubspot\\Exceptions\\HubspotException::class);\n $this->expectExceptionMessage('Server error');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchCircuitBreakerRetryAfterComputedFromStoredTimestamp(): void\n {\n $remaining = 45;\n $futureTimestamp = (string) (time() + $remaining);\n\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn($futureTimestamp);\n\n $this->config->method('getId')->willReturn(1);\n\n try {\n $this->client->search('deals', []);\n $this->fail('Expected RateLimitException');\n } catch (RateLimitException $e) {\n $this->assertGreaterThanOrEqual(1, $e->getRetryAfter());\n $this->assertLessThanOrEqual($remaining, $e->getRetryAfter());\n } finally {\n Mockery::close();\n }\n }\n\n // -------------------------------------------------------------------------\n // isHubspotRateLimit() tests (private method — tested via reflection)\n // -------------------------------------------------------------------------\n\n private function callIsHubspotRateLimit(\\Throwable $e): bool\n {\n $method = new \\ReflectionMethod($this->client, 'isHubspotRateLimit');\n $method->setAccessible(true);\n\n return $method->invoke($this->client, $e);\n }\n\n public function testIsHubspotRateLimitReturnsTrueForBadRequest429(): void\n {\n $e = new BadRequest('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForDealApiException429(): void\n {\n $e = new DealApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForContactApiException429(): void\n {\n $e = new ContactApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForCompanyApiException429(): void\n {\n $e = new CompanyApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForGuzzleRequestException429(): void\n {\n $request = new \\GuzzleHttp\\Psr7\\Request('POST', 'https://api.hubapi.com/test');\n $response = new \\GuzzleHttp\\Psr7\\Response(429);\n $e = new \\GuzzleHttp\\Exception\\RequestException('Too Many Requests', $request, $response);\n\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsFalseForBadRequestNon429(): void\n {\n $e = new BadRequest('Bad Request', 400);\n $this->assertFalse($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsFalseForGenericException(): void\n {\n $e = new \\RuntimeException('Something went wrong');\n $this->assertFalse($this->callIsHubspotRateLimit($e));\n }\n\n // -------------------------------------------------------------------------\n // parseRetryAfter() tests (private method — tested via reflection)\n // -------------------------------------------------------------------------\n\n private function callParseRetryAfter(\\Throwable $e): int\n {\n $method = new \\ReflectionMethod($this->client, 'parseRetryAfter');\n $method->setAccessible(true);\n\n return $method->invoke($this->client, $e);\n }\n\n public function testParseRetryAfterReadsRetryAfterHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => ['30']]);\n $this->assertSame(30, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReadsLowercaseRetryAfterHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['retry-after' => ['15']]);\n $this->assertSame(15, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterHandlesScalarHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => '60']);\n $this->assertSame(60, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsDailyLimitDelay(): void\n {\n $e = new BadRequest('Daily rate limit exceeded');\n $this->assertSame(600, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsTenSecondlyDelay(): void\n {\n $e = new BadRequest('Ten secondly rate limit exceeded');\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsSecondlyDelay(): void\n {\n $e = new BadRequest('Secondly rate limit exceeded');\n $this->assertSame(1, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsDefaultWhenNoHint(): void\n {\n $e = new BadRequest('Rate limit exceeded');\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterIgnoresNonNumericHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => ['not-a-number']]);\n // Falls through to message parsing; \"Too Many Requests\" has no known keyword → default 10\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n}","depth":4,"bounds":{"left":0.124667555,"top":0.17158818,"width":0.42852393,"height":0.8284118},"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Hubspot;\n\nuse GuzzleHttp\\Psr7\\Response;\nuse HubSpot\\Client\\Crm\\Associations\\Api\\BatchApi;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\BatchResponsePublicAssociationMulti;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\PublicObjectId;\nuse HubSpot\\Client\\Crm\\Companies\\ApiException as CompanyApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\ApiException as ContactApiException;\nuse HubSpot\\Client\\Crm\\Deals\\Api\\BasicApi as DealsBasicApi;\nuse HubSpot\\Client\\Crm\\Deals\\ApiException as DealApiException;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectWithAssociations as DealWithAssociations;\nuse HubSpot\\Client\\Crm\\Pipelines\\Api\\PipelinesApi;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\CollectionResponsePipeline;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Error;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Pipeline;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\PipelineStage;\nuse HubSpot\\Client\\Crm\\Properties\\Api\\CoreApi;\nuse HubSpot\\Client\\Crm\\Properties\\Model\\Property;\nuse HubSpot\\Discovery as HubSpotDiscovery;\nuse HubSpot\\Discovery\\Crm\\Deals\\Discovery as DealsDiscovery;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\HubspotTokenManager;\nuse Jiminny\\Services\\Crm\\Hubspot\\Pagination\\HubspotPaginationService;\nuse Jiminny\\Services\\SocialAccountService;\nuse League\\OAuth2\\Client\\Token\\AccessToken;\nuse Mockery;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse PHPUnit\\Framework\\TestCase;\nuse Psr\\Log\\NullLogger;\nuse SevenShores\\Hubspot\\Endpoints\\Engagements;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Factory;\nuse SevenShores\\Hubspot\\Http\\Client as HubspotClient;\nuse SevenShores\\Hubspot\\Http\\Response as HubspotResponse;\n\n/**\n * @runTestsInSeparateProcesses\n *\n * @preserveGlobalState disabled\n */\nclass ClientTest extends TestCase\n{\n private const string RESPONSE_TYPE_STAGE_FIELD = 'stage';\n private const string RESPONSE_TYPE_PIPELINE_FIELD = 'pipeline';\n private const string RESPONSE_TYPE_REGULAR_FIELD = 'regular';\n /**\n * @var Client&MockObject\n */\n private Client $client;\n private Configuration $config;\n\n /**\n * @var SocialAccountService&MockObject\n */\n private SocialAccountService $socialAccountServiceMock;\n\n /**\n * @var HubspotPaginationService&MockObject\n */\n private HubspotPaginationService $paginationServiceMock;\n\n /**\n * @var HubspotTokenManager&MockObject\n */\n private HubspotTokenManager $tokenManagerMock;\n\n /**\n * @var CoreApi&MockObject\n */\n private CoreApi $coreApiMock;\n\n /**\n * @var PipelinesApi&MockObject\n */\n private PipelinesApi $pipelinesApiMock;\n\n /**\n * @var BatchApi&MockObject\n */\n private BatchApi $associationsBatchApiMock;\n\n /**\n * @var DealsBasicApi&MockObject\n */\n private DealsBasicApi $dealsBasicApiMock;\n\n /**\n * @var Engagements&MockObject\n */\n private $engagementsMock;\n\n /**\n * @var HubspotClient&MockObject\n */\n private HubspotClient $hubspotClientMock;\n\n protected function setUp(): void\n {\n // Create mocks for dependencies\n $this->socialAccountServiceMock = $this->createMock(SocialAccountService::class);\n $this->paginationServiceMock = $this->createMock(HubspotPaginationService::class);\n $this->tokenManagerMock = $this->createMock(HubspotTokenManager::class);\n\n // Create a real Client instance with mocked dependencies\n // Create a partial mock only for the methods we need to mock\n $client = $this->createPartialMock(Client::class, ['getInstance', 'getNewInstance', 'makeRequest']);\n $client->setLogger(new NullLogger());\n\n // Inject the real dependencies using reflection\n $reflection = new \\ReflectionClass(Client::class);\n\n $accountServiceProperty = $reflection->getProperty('accountService');\n $accountServiceProperty->setAccessible(true);\n $accountServiceProperty->setValue($client, $this->socialAccountServiceMock);\n\n $paginationServiceProperty = $reflection->getProperty('paginationService');\n $paginationServiceProperty->setAccessible(true);\n $paginationServiceProperty->setValue($client, $this->paginationServiceMock);\n\n $tokenManagerProperty = $reflection->getProperty('tokenManager');\n $tokenManagerProperty->setAccessible(true);\n $tokenManagerProperty->setValue($client, $this->tokenManagerMock);\n $factoryMock = $this->createMock(Factory::class);\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $engagementsMock = $this->createMock(\\SevenShores\\Hubspot\\Endpoints\\Engagements::class);\n\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n $factoryMock->method('__call')->with('engagements')->willReturn($engagementsMock);\n\n $client->method('getInstance')->willReturn($factoryMock);\n\n $discoveryMock = $this->createMock(HubSpotDiscovery\\Discovery::class);\n $crmMock = $this->createMock(HubSpotDiscovery\\Crm\\Discovery::class);\n $associationsMock = $this->createMock(HubSpotDiscovery\\Crm\\Associations\\Discovery::class);\n $propertiesMock = $this->createMock(HubSpotDiscovery\\Crm\\Properties\\Discovery::class);\n $pipelinesMock = $this->createMock(HubSpotDiscovery\\Crm\\Pipelines\\Discovery::class);\n $coreApiMock = $this->createMock(CoreApi::class);\n $pipelinesApiMock = $this->createMock(PipelinesApi::class);\n $associationsBatchApiMock = $this->createMock(BatchApi::class);\n $dealsBasicApiMock = $this->createMock(DealsBasicApi::class);\n\n $propertiesMock->method('__call')->with('coreApi')->willReturn($coreApiMock);\n $pipelinesMock->method('__call')->with('pipelinesApi')->willReturn($pipelinesApiMock);\n $associationsMock->method('__call')->with('batchApi')->willReturn($associationsBatchApiMock);\n $dealsDiscoveryMock = $this->createMock(DealsDiscovery::class);\n $dealsDiscoveryMock->method('__call')->with('basicApi')->willReturn($dealsBasicApiMock);\n\n $returnMap = ['properties' => $propertiesMock, 'pipelines' => $pipelinesMock, 'associations' => $associationsMock, 'deals' => $dealsDiscoveryMock];\n $crmMock->method('__call')\n ->willReturnCallback(static fn (string $name) => $returnMap[$name])\n ;\n\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n\n $this->client = $client;\n $this->config = $this->createMock(Configuration::class);\n $this->client->setConfiguration($this->config);\n $this->coreApiMock = $coreApiMock;\n $this->pipelinesApiMock = $pipelinesApiMock;\n $this->associationsBatchApiMock = $associationsBatchApiMock;\n $this->dealsBasicApiMock = $dealsBasicApiMock;\n $this->engagementsMock = $engagementsMock;\n $this->hubspotClientMock = $hubspotClientMock;\n }\n\n public function testGetMinimumApiVersion(): void\n {\n $this->assertIsString($this->client->getMinimumApiVersion());\n }\n\n public function testGetInstance(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $client = new Client($socialAccountService, $paginationService, $tokenManager);\n $client->setBaseUrl('https://example.com');\n $client->setAccessToken(new AccessToken(['access_token' => 'foo']));\n $factory = $client->getInstance();\n\n $this->assertEquals('foo', $factory->getClient()->key);\n }\n\n public function testGetNewInstance(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $client = new Client($socialAccountService, $paginationService, $tokenManager);\n $client->setAccessToken(new AccessToken(['access_token' => 'foo']));\n\n $factory = $client->getNewInstance();\n $token = $factory->auth()->oAuth()->accessTokensApi()->getConfig()->getAccessToken();\n $this->assertEquals('foo', $token);\n }\n\n public function testFetchProperty(): void\n {\n $this->coreApiMock->method('getByName')->willReturn($this->generateProperty());\n\n $this->assertEquals(\n 'some_property',\n $this->client->fetchProperty('foo', 'test')['name']\n );\n }\n\n public function testFetchPropertyOptions(): void\n {\n $this->coreApiMock->method('getByName')->willReturn($this->generateProperty());\n\n $this->assertEquals(\n [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n $this->client->fetchPropertyOptions('foo', 'test')\n );\n }\n\n public function testFetchCallDispositions(): void\n {\n $this->engagementsMock\n ->method('getCallDispositions')\n ->willReturn($this->generateHubSpotResponse([\n [\n 'id' => 1,\n 'label' => 'foo',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'bar',\n 'deleted' => false,\n ],\n ]))\n ;\n\n $this->assertEquals(\n [\n [\n 'id' => 1,\n 'label' => 'foo',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'bar',\n 'deleted' => false,\n ],\n ],\n $this->client->fetchCallDispositions()\n );\n }\n\n public function testFetchDispositionFieldOptions(): void\n {\n $this->engagementsMock->method('getCallDispositions')\n ->willReturn($this->generateHubSpotResponse(\n [\n [\n 'id' => 1,\n 'label' => 'Label 1',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'Label 2',\n 'deleted' => true,\n ],\n ]\n ))\n ;\n\n $this->assertEquals(\n [\n [\n 'value' => 1,\n 'id' => 1,\n 'label' => 'Label 1',\n ],\n ],\n $this->client->fetchDispositionFieldOptions()\n );\n }\n\n public function testFetchOpportunityPipelineStages(): void\n {\n /**\n * @TODO: The class CollectionResponsePipeline will be deprecated in the next Hubspot version\n */\n $this->pipelinesApiMock\n ->method('getAll')\n ->with('deals')\n ->willReturn(new CollectionResponsePipeline([\n 'results' => [$this->generatePipeline()],\n ]))\n ;\n\n $this->assertEquals(\n [\n ['id' => 'foo', 'label' => 'bar'],\n ['id' => 'baz', 'label' => 'qux'],\n ],\n $this->client->fetchOpportunityPipelineStages()\n );\n }\n\n public function testFetchOpportunityPipelineStagesErrorResponse(): void\n {\n $this->pipelinesApiMock\n ->method('getAll')\n ->with('deals')\n ->willReturn(new Error(['message' => 'test error']))\n ;\n\n $this->assertEmpty($this->client->fetchOpportunityPipelineStages());\n }\n\n #[\\PHPUnit\\Framework\\Attributes\\DataProvider('meetingOutcomeFieldProvider')]\n public function testFetchMeetingOutcomeFieldOptions(string $fieldId, string $expectedEndpoint): void\n {\n $field = new Field(['crm_provider_id' => $fieldId]);\n\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->with('GET', $expectedEndpoint)\n ->willReturn($this->generateHubSpotResponse(\n [\n 'options' => [\n ['value' => 'option_1', 'label' => 'Option 1', 'displayOrder' => 0],\n ['value' => 'option_2', 'label' => 'Option 2', 'displayOrder' => 1],\n ['value' => 'option_3', 'label' => 'Option 3', 'displayOrder' => 2],\n ],\n ]\n ))\n ;\n\n $this->assertEquals(\n [\n ['id' => 'option_1', 'value' => 'option_1', 'label' => 'Option 1', 'display_order' => 0],\n ['id' => 'option_2', 'value' => 'option_2', 'label' => 'Option 2', 'display_order' => 1],\n ['id' => 'option_3', 'value' => 'option_3', 'label' => 'Option 3', 'display_order' => 2],\n ],\n $this->client->fetchMeetingOutcomeFieldOptions($field)\n );\n }\n\n public static function meetingOutcomeFieldProvider(): array\n {\n return [\n 'meeting outcome field' => [\n 'meetingOutcome',\n 'https://api.hubapi.com/crm/v3/properties/meeting/hs_meeting_outcome',\n ],\n 'activity type field' => [\n 'foobar',\n 'https://api.hubapi.com/crm/v3/properties/call/hs_activity_type',\n ],\n ];\n }\n\n #[\\PHPUnit\\Framework\\Attributes\\DataProvider('opportunityFieldOptionsProvider')]\n public function testFetchOpportunityFieldOptions(Field $field, string $type): void\n {\n $responses = [\n self::RESPONSE_TYPE_STAGE_FIELD => [\n ['id' => 'foo', 'label' => 'bar'],\n ['id' => 'baz', 'label' => 'qux'],\n ],\n self::RESPONSE_TYPE_PIPELINE_FIELD => [\n ['id' => '123', 'label' => 'Sales'],\n ['id' => 'default', 'label' => 'CS'],\n ],\n self::RESPONSE_TYPE_REGULAR_FIELD => [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n ];\n\n $field = $this->createMock(Field::class);\n\n if ($type === self::RESPONSE_TYPE_REGULAR_FIELD) {\n $field->method('isStageField')->willReturn(false);\n $field->method('isPipelineField')->willReturn(false);\n $propertyResponse = $this->generateProperty();\n $this->coreApiMock->method('getByName')->willReturn($propertyResponse);\n }\n\n if ($type === self::RESPONSE_TYPE_STAGE_FIELD) {\n $field->method('isStageField')->willReturn(true);\n /**\n * @TODO: The class CollectionResponsePipeline will be deprecated in the next Hubspot version\n */\n\n $pipelineStagesResponse = new CollectionResponsePipeline([\n 'results' => [$this->generatePipeline()],\n ]);\n $this->pipelinesApiMock\n ->method('getAll')\n ->willReturn($pipelineStagesResponse);\n }\n\n if ($type === self::RESPONSE_TYPE_PIPELINE_FIELD) {\n $field->method('isStageField')->willReturn(false);\n $field->method('isPipelineField')->willReturn(true);\n $pipelineResponse = $this->generateHubSpotResponse([\n 'results' => [\n ['id' => '123', 'label' => 'Sales'],\n ['id' => 'default', 'label' => 'CS'],\n ],\n ]);\n $this->client\n ->method('makeRequest')\n ->with('/crm/v3/pipelines/deals')\n ->willReturn($pipelineResponse);\n }\n\n $this->assertEquals(\n $responses[$type],\n $this->client->fetchOpportunityFieldOptions($field)\n );\n }\n\n public static function opportunityFieldOptionsProvider(): array\n {\n return [\n 'stage field' => [\n 'field' => new Field(['crm_provider_id' => 'dealstage']),\n 'type' => self::RESPONSE_TYPE_STAGE_FIELD,\n ],\n 'pipeline field' => [\n 'field' => new Field(['crm_provider_id' => 'pipeline']),\n 'type' => self::RESPONSE_TYPE_PIPELINE_FIELD,\n ],\n 'regular field' => [\n 'field' => new Field(['crm_provider_id' => 'some_property']),\n 'type' => self::RESPONSE_TYPE_REGULAR_FIELD,\n ],\n ];\n }\n\n private function generateHubSpotResponse(array $data): HubspotResponse\n {\n return new HubspotResponse(new Response(200, [], json_encode($data)));\n }\n\n private function generateProperty(): Property\n {\n return new Property([\n 'name' => 'some_property',\n 'options' => [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n ]);\n }\n\n private function generatePipeline(): Pipeline\n {\n return new Pipeline(['stages' => [\n new PipelineStage(['id' => 'foo', 'label' => 'bar']),\n new PipelineStage(['id' => 'baz', 'label' => 'qux']),\n ]]);\n }\n\n public function testFetchOpportunityPipelines(): void\n {\n $this->client\n ->method('makeRequest')\n ->with('/crm/v3/pipelines/deals')\n ->willReturn($this->generateHubSpotResponse([\n 'results' => [\n ['id' => 'id_1', 'label' => 'Option 1', 'displayOrder' => 0],\n ['id' => 'id_2', 'label' => 'Option 2', 'displayOrder' => 1],\n ['id' => 'id_3', 'label' => 'Option 3', 'displayOrder' => 2],\n ],\n ]));\n\n $this->assertEquals(\n [\n ['id' => 'id_1', 'label' => 'Option 1'],\n ['id' => 'id_2', 'label' => 'Option 2'],\n ['id' => 'id_3', 'label' => 'Option 3'],\n ],\n $this->client->fetchOpportunityPipelines()\n );\n }\n\n public function testGetPaginatedData(): void\n {\n $expectedResults = [\n ['id' => 'id_1', 'properties' => []],\n ['id' => 'id_2', 'properties' => []],\n ['id' => 'id_3', 'properties' => []],\n ];\n\n // Mock the pagination service to return a generator and modify reference parameters\n $this->paginationServiceMock\n ->expects($this->once())\n ->method('getPaginatedDataGenerator')\n ->with(\n $this->client,\n ['payload_key' => 'payload_value'],\n 'foobar',\n 0,\n $this->anything(),\n $this->anything()\n )\n ->willReturnCallback(function ($client, $payload, $type, $offset, &$total, &$lastRecordId) use ($expectedResults) {\n $total = 3;\n $lastRecordId = 'id_3';\n foreach ($expectedResults as $result) {\n yield $result;\n }\n });\n\n $this->assertEquals(\n [\n 'results' => $expectedResults,\n 'total' => 3,\n 'last_record' => 'id_3',\n ],\n $this->client->getPaginatedData(['payload_key' => 'payload_value'], 'foobar')\n );\n }\n\n public function testGetAssociationsData(): void\n {\n $ids = ['1', '2', '3'];\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $responseResults = [];\n foreach ($ids as $id) {\n $from = new PublicObjectId();\n $from->setId($id);\n\n $to1 = new PublicObjectId();\n $to1->setId('contact_' . $id . '_1');\n $to2 = new PublicObjectId();\n $to2->setId('contact_' . $id . '_2');\n\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to1, $to2]);\n\n $responseResults[] = $result;\n }\n\n $batchResponse = new BatchResponsePublicAssociationMulti();\n $batchResponse->setResults($responseResults);\n\n $this->associationsBatchApiMock->expects($this->once())\n ->method('read')\n ->with(\n $this->equalTo($fromObject),\n $this->equalTo($toObject),\n $this->callback(function (BatchInputPublicObjectId $batchInput) use ($ids) {\n $inputIds = array_map(\n fn ($input) => $input->getId(),\n $batchInput->getInputs()\n );\n\n return $inputIds === $ids;\n })\n )\n ->willReturn($batchResponse);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $expectedResult = [\n '1' => ['contact_1_1', 'contact_1_2'],\n '2' => ['contact_2_1', 'contact_2_2'],\n '3' => ['contact_3_1', 'contact_3_2'],\n ];\n\n $this->assertEquals($expectedResult, $result);\n }\n\n public function testGetAssociationsDataHandlesException(): void\n {\n $ids = ['1', '2', '3'];\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $exception = new \\Exception('API Error');\n\n $this->associationsBatchApiMock->expects($this->once())\n ->method('read')\n ->with(\n $this->equalTo($fromObject),\n $this->equalTo($toObject),\n $this->callback(function ($batchInput) use ($ids) {\n return $batchInput instanceof \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId\n && count($batchInput->getInputs()) === count($ids);\n })\n )\n ->willThrowException($exception);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with(\n '[Hubspot] Failed to fetch associations',\n [\n 'from_object' => $fromObject,\n 'to_object' => $toObject,\n 'reason' => 'API Error',\n ]\n );\n\n $this->client->setLogger($loggerMock);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $this->assertEmpty($result);\n $this->assertIsArray($result);\n }\n\n public function testGetAssociationsDataWithLargeDataSet(): void\n {\n $ids = array_map(fn ($i) => (string) $i, range(1, 2500)); // More than 1000 items\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $firstBatchResponse = new BatchResponsePublicAssociationMulti();\n $firstBatchResults = array_map(function ($id) {\n $from = new PublicObjectId();\n $from->setId($id);\n $to = new PublicObjectId();\n $to->setId('contact_' . $id);\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to]);\n\n return $result;\n }, array_slice($ids, 0, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));\n $firstBatchResponse->setResults($firstBatchResults);\n\n $secondBatchResponse = new BatchResponsePublicAssociationMulti();\n $secondBatchResults = array_map(function ($id) {\n $from = new PublicObjectId();\n $from->setId($id);\n $to = new PublicObjectId();\n $to->setId('contact_' . $id);\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to]);\n\n return $result;\n }, array_slice($ids, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));\n $secondBatchResponse->setResults($secondBatchResults);\n\n $this->associationsBatchApiMock->expects($this->exactly(3))\n ->method('read')\n ->willReturnOnConsecutiveCalls($firstBatchResponse, $secondBatchResponse);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $this->assertCount(2500, $result);\n $this->assertArrayHasKey('1', $result);\n $this->assertArrayHasKey('2500', $result);\n $this->assertEquals(['contact_1'], $result['1']);\n $this->assertEquals(['contact_2500'], $result['2500']);\n }\n\n public function testGetContactByEmailSuccess(): void\n {\n $email = 'test@example.com';\n $fields = ['firstname', 'lastname', 'email'];\n\n $contactMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $contactMock->method('getId')->willReturn('12345');\n $contactMock->method('getProperties')->willReturn([\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n 'email' => 'test@example.com',\n ]);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, 'firstname,lastname,email', null, false, 'email')\n ->willReturn($contactMock);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new NullLogger());\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([\n 'id' => '12345',\n 'properties' => [\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n 'email' => 'test@example.com',\n ],\n ], $result);\n }\n\n public function testGetContactByEmailWithEmptyFields(): void\n {\n $email = 'test@example.com';\n $fields = [];\n\n $contactMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $contactMock->method('getId')->willReturn('12345');\n $contactMock->method('getProperties')->willReturn(['email' => 'test@example.com']);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, '', null, false, 'email')\n ->willReturn($contactMock);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new NullLogger());\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([\n 'id' => '12345',\n 'properties' => ['email' => 'test@example.com'],\n ], $result);\n }\n\n public function testGetContactByEmailApiException(): void\n {\n $email = 'nonexistent@example.com';\n $fields = ['firstname', 'lastname'];\n\n $exception = new \\HubSpot\\Client\\Crm\\Contacts\\ApiException('Contact not found', 404);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, 'firstname,lastname', null, false, 'email')\n ->willThrowException($exception);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('info')\n ->with(\n '[Hubspot] Failed to fetch contact',\n [\n 'email' => $email,\n 'reason' => 'Contact not found',\n ]\n );\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger($loggerMock);\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([], $result);\n }\n\n public function testGetOpportunityById(): void\n {\n $opportunityId = '12345';\n $expectedProperties = [\n 'dealname' => 'Test Opportunity',\n 'amount' => '1000.00',\n 'closedate' => '2024-12-31T23:59:59.999Z',\n 'dealstage' => 'presentationscheduled',\n 'pipeline' => 'default',\n ];\n\n $mockHubspotOpportunity = $this->createMock(DealWithAssociations::class);\n $mockHubspotOpportunity->method('getProperties')->willReturn((object) $expectedProperties);\n $mockHubspotOpportunity->method('getId')->willReturn($opportunityId);\n $now = new \\DateTimeImmutable();\n $mockHubspotOpportunity->method('getCreatedAt')->willReturn($now);\n $mockHubspotOpportunity->method('getUpdatedAt')->willReturn($now);\n $mockHubspotOpportunity->method('getArchived')->willReturn(false);\n\n $this->dealsBasicApiMock\n ->expects($this->once())\n ->method('getById')\n ->willReturn($mockHubspotOpportunity);\n\n // Assuming Client::getOpportunityById processes the SimplePublicObject and returns an associative array.\n // The structure might be like: ['id' => ..., 'properties' => [...], 'createdAt' => ..., ...]\n // Adjust assertions below based on the actual return structure of your method.\n $result = $this->client->getOpportunityById($opportunityId, ['test', 'test']);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertEquals($opportunityId, $result['id']);\n }\n\n public function testGetContactById(): void\n {\n $crmId = 'contact-123';\n $fields = ['firstname', 'lastname'];\n $expectedProperties = ['firstname' => 'John', 'lastname' => 'Doe'];\n\n $mockContact = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $mockContact->method('getId')->willReturn($crmId);\n $mockContact->method('getProperties')->willReturn((object) $expectedProperties);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($crmId, 'firstname,lastname')\n ->willReturn($mockContact);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new \\Psr\\Log\\NullLogger());\n\n $result = $client->getContactById($crmId, $fields);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertArrayHasKey('properties', $result);\n $this->assertEquals($crmId, $result['id']);\n $this->assertEquals((object) $expectedProperties, $result['properties']);\n }\n\n public function testGetAccountById(): void\n {\n $crmId = 'account-123';\n $fields = ['name', 'industry'];\n $expectedProperties = ['name' => 'Acme Corp', 'industry' => 'Technology'];\n\n $mockCompany = $this->createMock(\\HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectWithAssociations::class);\n $mockCompany->method('getId')->willReturn($crmId);\n $mockCompany->method('getProperties')->willReturn((object) $expectedProperties);\n\n $companiesApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Companies\\Api\\BasicApi::class);\n $companiesApiMock->expects($this->once())\n ->method('getById')\n ->with($crmId, 'name,industry')\n ->willReturn($mockCompany);\n\n $companiesDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Companies\\Discovery::class);\n $companiesDiscoveryMock->method('__call')->with('basicApi')->willReturn($companiesApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($companiesDiscoveryMock) {\n if ($name === 'companies') {\n return $companiesDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new \\Psr\\Log\\NullLogger());\n\n $result = $client->getAccountById($crmId, $fields);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertArrayHasKey('properties', $result);\n $this->assertEquals($crmId, $result['id']);\n $this->assertEquals((object) $expectedProperties, $result['properties']);\n }\n\n public function testEnsureValidTokenWithNoTokenUpdate(): void\n {\n $socialAccountMock = $this->createMock(SocialAccount::class);\n\n // Set up OAuth account\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, $socialAccountMock);\n\n $originalToken = 'original_token';\n $accessTokenProperty = $reflection->getProperty('accessToken');\n $accessTokenProperty->setAccessible(true);\n $accessTokenProperty->setValue($this->client, $originalToken);\n\n // Mock token manager to return null (no refresh needed)\n $this->tokenManagerMock\n ->expects($this->once())\n ->method('ensureValidToken')\n ->with($socialAccountMock)\n ->willReturn(null);\n\n // Call ensureValidToken\n $this->client->ensureValidToken();\n\n // Verify access token was not changed\n $this->assertEquals($originalToken, $accessTokenProperty->getValue($this->client));\n }\n\n\n\n\n\n\n public function testGetPaginatedDataGeneratorDelegatesToPaginationService(): void\n {\n $payload = ['filters' => []];\n $type = 'contacts';\n $offset = 0;\n $total = 0;\n $lastRecordId = null;\n\n $expectedResults = [\n ['id' => 'id_1', 'properties' => []],\n ['id' => 'id_2', 'properties' => []],\n ];\n\n // Mock the pagination service to return a generator\n $this->paginationServiceMock\n ->expects($this->once())\n ->method('getPaginatedDataGenerator')\n ->with(\n $this->client,\n $payload,\n $type,\n $offset,\n $this->anything(),\n $this->anything()\n )\n ->willReturnCallback(function () use ($expectedResults) {\n foreach ($expectedResults as $result) {\n yield $result;\n }\n });\n\n // Execute the pagination\n $results = [];\n foreach ($this->client->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastRecordId) as $result) {\n $results[] = $result;\n }\n\n $this->assertCount(2, $results);\n $this->assertEquals('id_1', $results[0]['id']);\n $this->assertEquals('id_2', $results[1]['id']);\n }\n\n public function testEnsureValidTokenDelegatesToTokenManager(): void\n {\n $socialAccountMock = $this->createMock(SocialAccount::class);\n\n // Set up OAuth account\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, $socialAccountMock);\n\n // Mock token manager to return new token\n $this->tokenManagerMock\n ->expects($this->once())\n ->method('ensureValidToken')\n ->with($socialAccountMock)\n ->willReturn('new_access_token');\n\n // Call ensureValidToken\n $this->client->ensureValidToken();\n\n // Verify access token was updated\n $accessTokenProperty = $reflection->getProperty('accessToken');\n $accessTokenProperty->setAccessible(true);\n $this->assertEquals('new_access_token', $accessTokenProperty->getValue($this->client));\n }\n\n public function testGetOwnersArchivedWithValidResponse(): void\n {\n $responseData = [\n 'results' => [\n [\n 'id' => '123',\n 'email' => 'owner1@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'John',\n 'lastName' => 'Doe',\n 'userId' => 456,\n 'userIdIncludingInactive' => 789,\n 'createdAt' => '2023-01-01T12:00:00Z',\n 'updatedAt' => '2023-01-02T12:00:00Z',\n 'archived' => true,\n ],\n ],\n ];\n\n // Create a mock response object\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn($responseData);\n\n // Set up the client to return our test data\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=true'\n )\n ->willReturn($response);\n\n // Call the method\n $result = $this->client->getOwnersArchived(true);\n\n // Assert the results\n $this->assertCount(1, $result);\n $this->assertEquals('123', $result[0]->getId());\n $this->assertEquals('owner1@example.com', $result[0]->getEmail());\n $this->assertEquals('John Doe', $result[0]->getFullName());\n $this->assertTrue($result[0]->isArchived());\n }\n\n public function testGetOwnersArchivedWithEmptyResponse(): void\n {\n // Create a mock response object with empty results\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn(['results' => []]);\n\n // Set up the client to return empty results\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=false'\n )\n ->willReturn($response);\n\n // Call the method\n $result = $this->client->getOwnersArchived(false);\n\n // Assert the results\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithInvalidResponse(): void\n {\n // Create a mock response that will throw an exception when toArray is called\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willThrowException(new \\InvalidArgumentException('Invalid JSON'));\n\n // Set up the client to return the problematic response\n $this->client->method('makeRequest')\n ->willReturn($response);\n\n // Mock the logger to expect an error message\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with($this->stringContains('Failed to fetch owners'));\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n // Call the method and expect an empty array on error\n $result = $this->client->getOwnersArchived(true);\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithHttpError(): void\n {\n // Set up the client to throw an exception\n $this->client->method('makeRequest')\n ->willThrowException(new \\Exception('HTTP Error'));\n\n // Mock the logger to expect an error message\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with($this->stringContains('Failed to fetch owners'));\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n // Call the method and expect an empty array on error\n $result = $this->client->getOwnersArchived(false);\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithOwnerCreationException(): void\n {\n $responseData = [\n 'results' => [\n [\n 'id' => '123',\n 'email' => 'valid@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'John',\n 'lastName' => 'Doe',\n 'userId' => 456,\n 'userIdIncludingInactive' => 789,\n 'createdAt' => '2023-01-01T12:00:00Z',\n 'updatedAt' => '2023-01-02T12:00:00Z',\n 'archived' => false,\n ],\n [\n 'id' => '456',\n 'email' => 'invalid@example.com',\n 'type' => 'PERSON',\n 'createdAt' => 'invalid-date-format',\n ],\n [\n 'id' => '789',\n 'email' => 'another@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'Jane',\n 'lastName' => 'Smith',\n 'userId' => 999,\n 'userIdIncludingInactive' => 888,\n 'createdAt' => '2023-01-03T12:00:00Z',\n 'updatedAt' => '2023-01-04T12:00:00Z',\n 'archived' => false,\n ],\n ],\n ];\n\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn($responseData);\n\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=false'\n )\n ->willReturn($response);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with(\n '[HubSpot] Failed to process owner data',\n $this->callback(function ($context) {\n return isset($context['result']) &&\n isset($context['error']) &&\n $context['result']['id'] === '456' &&\n $context['result']['email'] === 'invalid@example.com' &&\n str_contains($context['error'], 'invalid-date-format');\n })\n );\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n $result = $this->client->getOwnersArchived(false);\n\n $this->assertIsArray($result);\n $this->assertCount(2, $result);\n $this->assertEquals('123', $result[0]->getId());\n $this->assertEquals('valid@example.com', $result[0]->getEmail());\n $this->assertEquals('789', $result[1]->getId());\n $this->assertEquals('another@example.com', $result[1]->getEmail());\n }\n\n public function testMakeRequestWithGetMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'GET',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n [],\n null,\n true\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'GET');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithGetMethodAndQueryString(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'GET',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n [],\n 'limit=10&offset=0',\n true\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'GET', [], 'limit=10&offset=0');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPostMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'POST',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'POST', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPutMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'firstname' => 'Jane',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345', 'updated' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'PUT',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'PUT', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPatchMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'email' => 'newemail@example.com',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345', 'patched' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'PATCH',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'PATCH', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithDeleteMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['deleted' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'DELETE',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => []]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'DELETE');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithEmptyPayload(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'ok']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'POST',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n ['json' => []]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'POST', []);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestPrependsBaseUrl(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n $this->anything(),\n 'https://api.hubapi.com/test/endpoint',\n $this->anything()\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $client->makeRequest('/test/endpoint', 'GET');\n }\n\n public function testGetOpportunitiesByIds(): void\n {\n $crmIds = ['deal1', 'deal2', 'deal3'];\n $fields = ['dealname', 'amount'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getOpportunitiesByIds'));\n }\n\n public function testGetCompaniesByIds(): void\n {\n $crmIds = ['company1', 'company2'];\n $fields = ['name', 'industry'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getCompaniesByIds'));\n }\n\n public function testGetContactsByIds(): void\n {\n $crmIds = ['contact1', 'contact2'];\n $fields = ['firstname', 'lastname'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getContactsByIds'));\n }\n\n public function testBatchReadObjectsEmptyIds(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $result = $method->invokeArgs($this->client, ['deals', [], ['dealname']]);\n\n $this->assertEquals([], $result);\n }\n\n public function testBatchReadObjectsExceedsBatchSize(): void\n {\n $crmIds = array_fill(0, 101, 'deal_id'); // 101 IDs, exceeds limit of 100\n\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $this->expectException(\\InvalidArgumentException::class);\n $this->expectExceptionMessage('Batch size cannot exceed 100 deals');\n\n $method->invokeArgs($this->client, ['deals', $crmIds, ['dealname']]);\n }\n\n public function testBatchReadObjectsUnsupportedObjectType(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $this->expectException(\\Jiminny\\Exceptions\\CrmException::class);\n $this->expectExceptionMessage('Failed to batch fetch unsupported');\n\n $method->invokeArgs($this->client, ['unsupported', ['id1'], ['field1']]);\n }\n\n public function testIsUnauthorizedExceptionWithBadRequest401(): void\n {\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\BadRequest('Unauthorized', 401);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithBadRequestNon401(): void\n {\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\BadRequest('Bad Request', 400);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertFalse($result);\n }\n\n public function testIsUnauthorizedExceptionWithGuzzleRequestException(): void\n {\n $response = new Response(401, [], 'Unauthorized');\n $exception = new \\GuzzleHttp\\Exception\\RequestException('Unauthorized', new \\GuzzleHttp\\Psr7\\Request('GET', 'test'), $response);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithGuzzleClientException(): void\n {\n $exception = new \\GuzzleHttp\\Exception\\ClientException('Unauthorized', new \\GuzzleHttp\\Psr7\\Request('GET', 'test'), new Response(401));\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithStringMatching(): void\n {\n $exception = new \\Exception('HTTP 401 Unauthorized error occurred');\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithNonUnauthorized(): void\n {\n $exception = new \\Exception('Some other error');\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertFalse($result);\n }\n\n public function testEnsureValidTokenWithNullOauthAccount(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, null);\n\n // Should not throw exception and not call token manager\n $this->tokenManagerMock->expects($this->never())->method('ensureValidToken');\n\n $this->client->ensureValidToken();\n\n $this->assertTrue(true); // Test passes if no exception is thrown\n }\n\n public function testGetConfig(): void\n {\n $result = $this->client->getConfig();\n\n $this->assertSame($this->config, $result);\n }\n\n public function testGetOwners(): void\n {\n // Test that the method exists and can be called\n $this->assertTrue(method_exists($this->client, 'getOwners'));\n\n // Since getOwners has complex HubSpot API dependencies,\n // we'll just verify the method exists for now\n $this->assertIsCallable([$this->client, 'getOwners']);\n }\n\n public function testCreateMeeting(): void\n {\n $payload = [\n 'properties' => [\n 'hs_meeting_title' => 'Test Meeting',\n 'hs_meeting_start_time' => '2024-01-01T10:00:00Z',\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['id' => 'meeting123']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with('/crm/v3/objects/meetings', 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->createMeeting($payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testUpdateMeeting(): void\n {\n $meetingId = 'meeting123';\n $payload = [\n 'properties' => [\n 'hs_meeting_title' => 'Updated Meeting',\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['id' => $meetingId, 'updated' => true]);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v3/objects/meetings/{$meetingId}\", 'PATCH', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->updateMeeting($meetingId, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testAddAssociations(): void\n {\n $objectType = 'deals';\n $associationType = 'contacts';\n $payload = [\n 'inputs' => [\n ['from' => ['id' => 'deal1'], 'to' => ['id' => 'contact1']],\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['status' => 'success']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v4/associations/{$objectType}/{$associationType}/batch/create\", 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->addAssociations($objectType, $associationType, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testRemoveAssociations(): void\n {\n $objectType = 'deals';\n $associationType = 'contacts';\n $payload = [\n 'inputs' => [\n ['from' => ['id' => 'deal1'], 'to' => ['id' => 'contact1']],\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['status' => 'success']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v4/associations/{$objectType}/{$associationType}/batch/archive\", 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->removeAssociations($objectType, $associationType, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n // -------------------------------------------------------------------------\n // search() / executeRequest() tests\n // -------------------------------------------------------------------------\n\n public function testSearchReturnsDecodedArrayOnSuccess(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n\n $this->config->method('getId')->willReturn(42);\n\n $payload = ['filters' => []];\n $responseData = ['results' => [['id' => '1']], 'total' => 1, 'paging' => []];\n $hubspotResponse = new HubspotResponse(new Response(200, [], json_encode($responseData)));\n\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->with('POST', Client::BASE_URL . '/crm/v3/objects/contacts/search', ['json' => $payload])\n ->willReturn($hubspotResponse);\n\n $result = $this->client->search('contacts', $payload);\n\n $this->assertSame($responseData, $result);\n\n Mockery::close();\n }\n\n public function testSearchThrowsRateLimitExceptionWhenCircuitBreakerActive(): void\n {\n $futureTimestamp = (string) (time() + 30);\n\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn($futureTimestamp);\n\n $this->config->method('getId')->willReturn(42);\n\n $this->hubspotClientMock->expects($this->never())->method('request');\n\n $this->expectException(RateLimitException::class);\n $this->expectExceptionMessage('Hubspot rate limit (cached circuit-breaker)');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchThrowsRateLimitExceptionAndSetsNxOnFresh429(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n // NX + TTL option array — exact TTL depends on parseRetryAfter, verified separately\n $redisMock->shouldReceive('set')\n ->once()\n ->with(\n 'hubspot:ratelimit:config:42',\n Mockery::type('string'),\n Mockery::on(fn ($opts) => is_array($opts) && in_array('nx', $opts, true))\n );\n\n $this->config->method('getId')->willReturn(42);\n\n $badRequest = new BadRequest('Rate limit exceeded', 429);\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->willThrowException($badRequest);\n\n $this->expectException(RateLimitException::class);\n $this->expectExceptionMessage('Hubspot returned 429');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchPropagatesNonRateLimitException(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n\n $this->config->method('getId')->willReturn(42);\n\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\HubspotException('Server error', 500);\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->willThrowException($exception);\n\n $this->expectException(\\SevenShores\\Hubspot\\Exceptions\\HubspotException::class);\n $this->expectExceptionMessage('Server error');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchCircuitBreakerRetryAfterComputedFromStoredTimestamp(): void\n {\n $remaining = 45;\n $futureTimestamp = (string) (time() + $remaining);\n\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn($futureTimestamp);\n\n $this->config->method('getId')->willReturn(1);\n\n try {\n $this->client->search('deals', []);\n $this->fail('Expected RateLimitException');\n } catch (RateLimitException $e) {\n $this->assertGreaterThanOrEqual(1, $e->getRetryAfter());\n $this->assertLessThanOrEqual($remaining, $e->getRetryAfter());\n } finally {\n Mockery::close();\n }\n }\n\n // -------------------------------------------------------------------------\n // isHubspotRateLimit() tests (private method — tested via reflection)\n // -------------------------------------------------------------------------\n\n private function callIsHubspotRateLimit(\\Throwable $e): bool\n {\n $method = new \\ReflectionMethod($this->client, 'isHubspotRateLimit');\n $method->setAccessible(true);\n\n return $method->invoke($this->client, $e);\n }\n\n public function testIsHubspotRateLimitReturnsTrueForBadRequest429(): void\n {\n $e = new BadRequest('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForDealApiException429(): void\n {\n $e = new DealApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForContactApiException429(): void\n {\n $e = new ContactApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForCompanyApiException429(): void\n {\n $e = new CompanyApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForGuzzleRequestException429(): void\n {\n $request = new \\GuzzleHttp\\Psr7\\Request('POST', 'https://api.hubapi.com/test');\n $response = new \\GuzzleHttp\\Psr7\\Response(429);\n $e = new \\GuzzleHttp\\Exception\\RequestException('Too Many Requests', $request, $response);\n\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsFalseForBadRequestNon429(): void\n {\n $e = new BadRequest('Bad Request', 400);\n $this->assertFalse($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsFalseForGenericException(): void\n {\n $e = new \\RuntimeException('Something went wrong');\n $this->assertFalse($this->callIsHubspotRateLimit($e));\n }\n\n // -------------------------------------------------------------------------\n // parseRetryAfter() tests (private method — tested via reflection)\n // -------------------------------------------------------------------------\n\n private function callParseRetryAfter(\\Throwable $e): int\n {\n $method = new \\ReflectionMethod($this->client, 'parseRetryAfter');\n $method->setAccessible(true);\n\n return $method->invoke($this->client, $e);\n }\n\n public function testParseRetryAfterReadsRetryAfterHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => ['30']]);\n $this->assertSame(30, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReadsLowercaseRetryAfterHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['retry-after' => ['15']]);\n $this->assertSame(15, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterHandlesScalarHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => '60']);\n $this->assertSame(60, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsDailyLimitDelay(): void\n {\n $e = new BadRequest('Daily rate limit exceeded');\n $this->assertSame(600, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsTenSecondlyDelay(): void\n {\n $e = new BadRequest('Ten secondly rate limit exceeded');\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsSecondlyDelay(): void\n {\n $e = new BadRequest('Secondly rate limit exceeded');\n $this->assertSame(1, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsDefaultWhenNoHint(): void\n {\n $e = new BadRequest('Rate limit exceeded');\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterIgnoresNonNumericHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => ['not-a-number']]);\n // Falls through to message parsing; \"Too Many Requests\" has no known keyword → default 10\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.96276593,"top":0.07581804,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.9740692,"top":0.074221864,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.98138297,"top":0.074221864,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
4682894321548166980
|
4446428687123393012
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
ClientTest
Run 'ClientTest'
Debug 'ClientTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
144
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Hubspot;
use GuzzleHttp\Psr7\Response;
use HubSpot\Client\Crm\Associations\Api\BatchApi;
use HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId;
use HubSpot\Client\Crm\Associations\Model\BatchResponsePublicAssociationMulti;
use HubSpot\Client\Crm\Associations\Model\PublicObjectId;
use HubSpot\Client\Crm\Companies\ApiException as CompanyApiException;
use HubSpot\Client\Crm\Contacts\ApiException as ContactApiException;
use HubSpot\Client\Crm\Deals\Api\BasicApi as DealsBasicApi;
use HubSpot\Client\Crm\Deals\ApiException as DealApiException;
use HubSpot\Client\Crm\Deals\Model\SimplePublicObjectWithAssociations as DealWithAssociations;
use HubSpot\Client\Crm\Pipelines\Api\PipelinesApi;
use HubSpot\Client\Crm\Pipelines\Model\CollectionResponsePipeline;
use HubSpot\Client\Crm\Pipelines\Model\Error;
use HubSpot\Client\Crm\Pipelines\Model\Pipeline;
use HubSpot\Client\Crm\Pipelines\Model\PipelineStage;
use HubSpot\Client\Crm\Properties\Api\CoreApi;
use HubSpot\Client\Crm\Properties\Model\Property;
use HubSpot\Discovery as HubSpotDiscovery;
use HubSpot\Discovery\Crm\Deals\Discovery as DealsDiscovery;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\SocialAccount;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\HubspotTokenManager;
use Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService;
use Jiminny\Services\SocialAccountService;
use League\OAuth2\Client\Token\AccessToken;
use Mockery;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use SevenShores\Hubspot\Endpoints\Engagements;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Factory;
use SevenShores\Hubspot\Http\Client as HubspotClient;
use SevenShores\Hubspot\Http\Response as HubspotResponse;
/**
* @runTestsInSeparateProcesses
*
* @preserveGlobalState disabled
*/
class ClientTest extends TestCase
{
private const string RESPONSE_TYPE_STAGE_FIELD = 'stage';
private const string RESPONSE_TYPE_PIPELINE_FIELD = 'pipeline';
private const string RESPONSE_TYPE_REGULAR_FIELD = 'regular';
/**
* @var Client&MockObject
*/
private Client $client;
private Configuration $config;
/**
* @var SocialAccountService&MockObject
*/
private SocialAccountService $socialAccountServiceMock;
/**
* @var HubspotPaginationService&MockObject
*/
private HubspotPaginationService $paginationServiceMock;
/**
* @var HubspotTokenManager&MockObject
*/
private HubspotTokenManager $tokenManagerMock;
/**
* @var CoreApi&MockObject
*/
private CoreApi $coreApiMock;
/**
* @var PipelinesApi&MockObject
*/
private PipelinesApi $pipelinesApiMock;
/**
* @var BatchApi&MockObject
*/
private BatchApi $associationsBatchApiMock;
/**
* @var DealsBasicApi&MockObject
*/
private DealsBasicApi $dealsBasicApiMock;
/**
* @var Engagements&MockObject
*/
private $engagementsMock;
/**
* @var HubspotClient&MockObject
*/
private HubspotClient $hubspotClientMock;
protected function setUp(): void
{
// Create mocks for dependencies
$this->socialAccountServiceMock = $this->createMock(SocialAccountService::class);
$this->paginationServiceMock = $this->createMock(HubspotPaginationService::class);
$this->tokenManagerMock = $this->createMock(HubspotTokenManager::class);
// Create a real Client instance with mocked dependencies
// Create a partial mock only for the methods we need to mock
$client = $this->createPartialMock(Client::class, ['getInstance', 'getNewInstance', 'makeRequest']);
$client->setLogger(new NullLogger());
// Inject the real dependencies using reflection
$reflection = new \ReflectionClass(Client::class);
$accountServiceProperty = $reflection->getProperty('accountService');
$accountServiceProperty->setAccessible(true);
$accountServiceProperty->setValue($client, $this->socialAccountServiceMock);
$paginationServiceProperty = $reflection->getProperty('paginationService');
$paginationServiceProperty->setAccessible(true);
$paginationServiceProperty->setValue($client, $this->paginationServiceMock);
$tokenManagerProperty = $reflection->getProperty('tokenManager');
$tokenManagerProperty->setAccessible(true);
$tokenManagerProperty->setValue($client, $this->tokenManagerMock);
$factoryMock = $this->createMock(Factory::class);
$hubspotClientMock = $this->createMock(HubspotClient::class);
$engagementsMock = $this->createMock(\SevenShores\Hubspot\Endpoints\Engagements::class);
$factoryMock->method('getClient')->willReturn($hubspotClientMock);
$factoryMock->method('__call')->with('engagements')->willReturn($engagementsMock);
$client->method('getInstance')->willReturn($factoryMock);
$discoveryMock = $this->createMock(HubSpotDiscovery\Discovery::class);
$crmMock = $this->createMock(HubSpotDiscovery\Crm\Discovery::class);
$associationsMock = $this->createMock(HubSpotDiscovery\Crm\Associations\Discovery::class);
$propertiesMock = $this->createMock(HubSpotDiscovery\Crm\Properties\Discovery::class);
$pipelinesMock = $this->createMock(HubSpotDiscovery\Crm\Pipelines\Discovery::class);
$coreApiMock = $this->createMock(CoreApi::class);
$pipelinesApiMock = $this->createMock(PipelinesApi::class);
$associationsBatchApiMock = $this->createMock(BatchApi::class);
$dealsBasicApiMock = $this->createMock(DealsBasicApi::class);
$propertiesMock->method('__call')->with('coreApi')->willReturn($coreApiMock);
$pipelinesMock->method('__call')->with('pipelinesApi')->willReturn($pipelinesApiMock);
$associationsMock->method('__call')->with('batchApi')->willReturn($associationsBatchApiMock);
$dealsDiscoveryMock = $this->createMock(DealsDiscovery::class);
$dealsDiscoveryMock->method('__call')->with('basicApi')->willReturn($dealsBasicApiMock);
$returnMap = ['properties' => $propertiesMock, 'pipelines' => $pipelinesMock, 'associations' => $associationsMock, 'deals' => $dealsDiscoveryMock];
$crmMock->method('__call')
->willReturnCallback(static fn (string $name) => $returnMap[$name])
;
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client->method('getNewInstance')->willReturn($discoveryMock);
$this->client = $client;
$this->config = $this->createMock(Configuration::class);
$this->client->setConfiguration($this->config);
$this->coreApiMock = $coreApiMock;
$this->pipelinesApiMock = $pipelinesApiMock;
$this->associationsBatchApiMock = $associationsBatchApiMock;
$this->dealsBasicApiMock = $dealsBasicApiMock;
$this->engagementsMock = $engagementsMock;
$this->hubspotClientMock = $hubspotClientMock;
}
public function testGetMinimumApiVersion(): void
{
$this->assertIsString($this->client->getMinimumApiVersion());
}
public function testGetInstance(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManager = $this->createMock(HubspotTokenManager::class);
$client = new Client($socialAccountService, $paginationService, $tokenManager);
$client->setBaseUrl('[URL_WITH_CREDENTIALS] The class CollectionResponsePipeline will be deprecated in the next Hubspot version
*/
$this->pipelinesApiMock
->method('getAll')
->with('deals')
->willReturn(new CollectionResponsePipeline([
'results' => [$this->generatePipeline()],
]))
;
$this->assertEquals(
[
['id' => 'foo', 'label' => 'bar'],
['id' => 'baz', 'label' => 'qux'],
],
$this->client->fetchOpportunityPipelineStages()
);
}
public function testFetchOpportunityPipelineStagesErrorResponse(): void
{
$this->pipelinesApiMock
->method('getAll')
->with('deals')
->willReturn(new Error(['message' => 'test error']))
;
$this->assertEmpty($this->client->fetchOpportunityPipelineStages());
}
#[\PHPUnit\Framework\Attributes\DataProvider('meetingOutcomeFieldProvider')]
public function testFetchMeetingOutcomeFieldOptions(string $fieldId, string $expectedEndpoint): void
{
$field = new Field(['crm_provider_id' => $fieldId]);
$this->hubspotClientMock
->expects($this->once())
->method('request')
->with('GET', $expectedEndpoint)
->willReturn($this->generateHubSpotResponse(
[
'options' => [
['value' => 'option_1', 'label' => 'Option 1', 'displayOrder' => 0],
['value' => 'option_2', 'label' => 'Option 2', 'displayOrder' => 1],
['value' => 'option_3', 'label' => 'Option 3', 'displayOrder' => 2],
],
]
))
;
$this->assertEquals(
[
['id' => 'option_1', 'value' => 'option_1', 'label' => 'Option 1', 'display_order' => 0],
['id' => 'option_2', 'value' => 'option_2', 'label' => 'Option 2', 'display_order' => 1],
['id' => 'option_3', 'value' => 'option_3', 'label' => 'Option 3', 'display_order' => 2],
],
$this->client->fetchMeetingOutcomeFieldOptions($field)
);
}
public static function meetingOutcomeFieldProvider(): array
{
return [
'meeting outcome field' => [
'meetingOutcome',
'[URL_WITH_CREDENTIALS] The class CollectionResponsePipeline will be deprecated in the next Hubspot version
*/
$pipelineStagesResponse = new CollectionResponsePipeline([
'results' => [$this->generatePipeline()],
]);
$this->pipelinesApiMock
->method('getAll')
->willReturn($pipelineStagesResponse);
}
if ($type === self::RESPONSE_TYPE_PIPELINE_FIELD) {
$field->method('isStageField')->willReturn(false);
$field->method('isPipelineField')->willReturn(true);
$pipelineResponse = $this->generateHubSpotResponse([
'results' => [
['id' => '123', 'label' => 'Sales'],
['id' => 'default', 'label' => 'CS'],
],
]);
$this->client
->method('makeRequest')
->with('/crm/v3/pipelines/deals')
->willReturn($pipelineResponse);
}
$this->assertEquals(
$responses[$type],
$this->client->fetchOpportunityFieldOptions($field)
);
}
public static function opportunityFieldOptionsProvider(): array
{
return [
'stage field' => [
'field' => new Field(['crm_provider_id' => 'dealstage']),
'type' => self::RESPONSE_TYPE_STAGE_FIELD,
],
'pipeline field' => [
'field' => new Field(['crm_provider_id' => 'pipeline']),
'type' => self::RESPONSE_TYPE_PIPELINE_FIELD,
],
'regular field' => [
'field' => new Field(['crm_provider_id' => 'some_property']),
'type' => self::RESPONSE_TYPE_REGULAR_FIELD,
],
];
}
private function generateHubSpotResponse(array $data): HubspotResponse
{
return new HubspotResponse(new Response(200, [], json_encode($data)));
}
private function generateProperty(): Property
{
return new Property([
'name' => 'some_property',
'options' => [
[
'label' => 'label_1',
'value' => 'value_1',
],
[
'label' => 'label_2',
'value' => 'value_2',
],
],
]);
}
private function generatePipeline(): Pipeline
{
return new Pipeline(['stages' => [
new PipelineStage(['id' => 'foo', 'label' => 'bar']),
new PipelineStage(['id' => 'baz', 'label' => 'qux']),
]]);
}
public function testFetchOpportunityPipelines(): void
{
$this->client
->method('makeRequest')
->with('/crm/v3/pipelines/deals')
->willReturn($this->generateHubSpotResponse([
'results' => [
['id' => 'id_1', 'label' => 'Option 1', 'displayOrder' => 0],
['id' => 'id_2', 'label' => 'Option 2', 'displayOrder' => 1],
['id' => 'id_3', 'label' => 'Option 3', 'displayOrder' => 2],
],
]));
$this->assertEquals(
[
['id' => 'id_1', 'label' => 'Option 1'],
['id' => 'id_2', 'label' => 'Option 2'],
['id' => 'id_3', 'label' => 'Option 3'],
],
$this->client->fetchOpportunityPipelines()
);
}
public function testGetPaginatedData(): void
{
$expectedResults = [
['id' => 'id_1', 'properties' => []],
['id' => 'id_2', 'properties' => []],
['id' => 'id_3', 'properties' => []],
];
// Mock the pagination service to return a generator and modify reference parameters
$this->paginationServiceMock
->expects($this->once())
->method('getPaginatedDataGenerator')
->with(
$this->client,
['payload_key' => 'payload_value'],
'foobar',
0,
$this->anything(),
$this->anything()
)
->willReturnCallback(function ($client, $payload, $type, $offset, &$total, &$lastRecordId) use ($expectedResults) {
$total = 3;
$lastRecordId = 'id_3';
foreach ($expectedResults as $result) {
yield $result;
}
});
$this->assertEquals(
[
'results' => $expectedResults,
'total' => 3,
'last_record' => 'id_3',
],
$this->client->getPaginatedData(['payload_key' => 'payload_value'], 'foobar')
);
}
public function testGetAssociationsData(): void
{
$ids = ['1', '2', '3'];
$fromObject = 'deals';
$toObject = 'contacts';
$responseResults = [];
foreach ($ids as $id) {
$from = new PublicObjectId();
$from->setId($id);
$to1 = new PublicObjectId();
$to1->setId('contact_' . $id . '_1');
$to2 = new PublicObjectId();
$to2->setId('contact_' . $id . '_2');
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to1, $to2]);
$responseResults[] = $result;
}
$batchResponse = new BatchResponsePublicAssociationMulti();
$batchResponse->setResults($responseResults);
$this->associationsBatchApiMock->expects($this->once())
->method('read')
->with(
$this->equalTo($fromObject),
$this->equalTo($toObject),
$this->callback(function (BatchInputPublicObjectId $batchInput) use ($ids) {
$inputIds = array_map(
fn ($input) => $input->getId(),
$batchInput->getInputs()
);
return $inputIds === $ids;
})
)
->willReturn($batchResponse);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$expectedResult = [
'1' => ['contact_1_1', 'contact_1_2'],
'2' => ['contact_2_1', 'contact_2_2'],
'3' => ['contact_3_1', 'contact_3_2'],
];
$this->assertEquals($expectedResult, $result);
}
public function testGetAssociationsDataHandlesException(): void
{
$ids = ['1', '2', '3'];
$fromObject = 'deals';
$toObject = 'contacts';
$exception = new \Exception('API Error');
$this->associationsBatchApiMock->expects($this->once())
->method('read')
->with(
$this->equalTo($fromObject),
$this->equalTo($toObject),
$this->callback(function ($batchInput) use ($ids) {
return $batchInput instanceof \HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId
&& count($batchInput->getInputs()) === count($ids);
})
)
->willThrowException($exception);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with(
'[Hubspot] Failed to fetch associations',
[
'from_object' => $fromObject,
'to_object' => $toObject,
'reason' => 'API Error',
]
);
$this->client->setLogger($loggerMock);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$this->assertEmpty($result);
$this->assertIsArray($result);
}
public function testGetAssociationsDataWithLargeDataSet(): void
{
$ids = array_map(fn ($i) => (string) $i, range(1, 2500)); // More than 1000 items
$fromObject = 'deals';
$toObject = 'contacts';
$firstBatchResponse = new BatchResponsePublicAssociationMulti();
$firstBatchResults = array_map(function ($id) {
$from = new PublicObjectId();
$from->setId($id);
$to = new PublicObjectId();
$to->setId('contact_' . $id);
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to]);
return $result;
}, array_slice($ids, 0, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));
$firstBatchResponse->setResults($firstBatchResults);
$secondBatchResponse = new BatchResponsePublicAssociationMulti();
$secondBatchResults = array_map(function ($id) {
$from = new PublicObjectId();
$from->setId($id);
$to = new PublicObjectId();
$to->setId('contact_' . $id);
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to]);
return $result;
}, array_slice($ids, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));
$secondBatchResponse->setResults($secondBatchResults);
$this->associationsBatchApiMock->expects($this->exactly(3))
->method('read')
->willReturnOnConsecutiveCalls($firstBatchResponse, $secondBatchResponse);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$this->assertCount(2500, $result);
$this->assertArrayHasKey('1', $result);
$this->assertArrayHasKey('2500', $result);
$this->assertEquals(['contact_1'], $result['1']);
$this->assertEquals(['contact_2500'], $result['2500']);
}
public function testGetContactByEmailSuccess(): void
{
$email = '[EMAIL]';
$fields = ['firstname', 'lastname', 'email'];
$contactMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$contactMock->method('getId')->willReturn('12345');
$contactMock->method('getProperties')->willReturn([
'firstname' => 'John',
'lastname' => 'Doe',
'email' => '[EMAIL]',
]);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, 'firstname,lastname,email', null, false, 'email')
->willReturn($contactMock);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new NullLogger());
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([
'id' => '12345',
'properties' => [
'firstname' => 'John',
'lastname' => 'Doe',
'email' => '[EMAIL]',
],
], $result);
}
public function testGetContactByEmailWithEmptyFields(): void
{
$email = '[EMAIL]';
$fields = [];
$contactMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$contactMock->method('getId')->willReturn('12345');
$contactMock->method('getProperties')->willReturn(['email' => '[EMAIL]']);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, '', null, false, 'email')
->willReturn($contactMock);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new NullLogger());
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([
'id' => '12345',
'properties' => ['email' => '[EMAIL]'],
], $result);
}
public function testGetContactByEmailApiException(): void
{
$email = '[EMAIL]';
$fields = ['firstname', 'lastname'];
$exception = new \HubSpot\Client\Crm\Contacts\ApiException('Contact not found', 404);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, 'firstname,lastname', null, false, 'email')
->willThrowException($exception);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('info')
->with(
'[Hubspot] Failed to fetch contact',
[
'email' => $email,
'reason' => 'Contact not found',
]
);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger($loggerMock);
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([], $result);
}
public function testGetOpportunityById(): void
{
$opportunityId = '12345';
$expectedProperties = [
'dealname' => 'Test Opportunity',
'amount' => '1000.00',
'closedate' => '2024-12-31T23:59:59.999Z',
'dealstage' => 'presentationscheduled',
'pipeline' => 'default',
];
$mockHubspotOpportunity = $this->createMock(DealWithAssociations::class);
$mockHubspotOpportunity->method('getProperties')->willReturn((object) $expectedProperties);
$mockHubspotOpportunity->method('getId')->willReturn($opportunityId);
$now = new \DateTimeImmutable();
$mockHubspotOpportunity->method('getCreatedAt')->willReturn($now);
$mockHubspotOpportunity->method('getUpdatedAt')->willReturn($now);
$mockHubspotOpportunity->method('getArchived')->willReturn(false);
$this->dealsBasicApiMock
->expects($this->once())
->method('getById')
->willReturn($mockHubspotOpportunity);
// Assuming Client::getOpportunityById processes the SimplePublicObject and returns an associative array.
// The structure might be like: ['id' => ..., 'properties' => [...], 'createdAt' => ..., ...]
// Adjust assertions below based on the actual return structure of your method.
$result = $this->client->getOpportunityById($opportunityId, ['test', 'test']);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertEquals($opportunityId, $result['id']);
}
public function testGetContactById(): void
{
$crmId = 'contact-123';
$fields = ['firstname', 'lastname'];
$expectedProperties = ['firstname' => 'John', 'lastname' => 'Doe'];
$mockContact = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$mockContact->method('getId')->willReturn($crmId);
$mockContact->method('getProperties')->willReturn((object) $expectedProperties);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($crmId, 'firstname,lastname')
->willReturn($mockContact);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new \Psr\Log\NullLogger());
$result = $client->getContactById($crmId, $fields);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('properties', $result);
$this->assertEquals($crmId, $result['id']);
$this->assertEquals((object) $expectedProperties, $result['properties']);
}
public function testGetAccountById(): void
{
$crmId = 'account-123';
$fields = ['name', 'industry'];
$expectedProperties = ['name' => 'Acme Corp', 'industry' => 'Technology'];
$mockCompany = $this->createMock(\HubSpot\Client\Crm\Companies\Model\SimplePublicObjectWithAssociations::class);
$mockCompany->method('getId')->willReturn($crmId);
$mockCompany->method('getProperties')->willReturn((object) $expectedProperties);
$companiesApiMock = $this->createMock(\HubSpot\Client\Crm\Companies\Api\BasicApi::class);
$companiesApiMock->expects($this->once())
->method('getById')
->with($crmId, 'name,industry')
->willReturn($mockCompany);
$companiesDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Companies\Discovery::class);
$companiesDiscoveryMock->method('__call')->with('basicApi')->willReturn($companiesApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($companiesDiscoveryMock) {
if ($name === 'companies') {
return $companiesDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new \Psr\Log\NullLogger());
$result = $client->getAccountById($crmId, $fields);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('properties', $result);
$this->assertEquals($crmId, $result['id']);
$this->assertEquals((object) $expectedProperties, $result['properties']);
}
public function testEnsureValidTokenWithNoTokenUpdate(): void
{
$socialAccountMock = $this->createMock(SocialAccount::class);
// Set up OAuth account
$reflection = new \ReflectionClass($this->client);
$oauthAccountProperty = $reflection->getProperty('oauthAccount');
$oauthAccountProperty->setAccessible(true);
$oauthAccountProperty->setValue($this->client, $socialAccountMock);
$originalToken = 'original_token';
$accessTokenProperty = $reflection->getProperty('accessToken');
$accessTokenProperty->setAccessible(true);
$accessTokenProperty->setValue($this->client, $originalToken);
// Mock token manager to return null (no refresh needed)
$this->tokenManagerMock
->expects($this->once())
->method('ensureValidToken')
->with($socialAccountMock)
->willReturn(null);
// Call ensureValidToken
$this->client->ensureValidToken();
// Verify access token was not changed
$this->assertEquals($originalToken, $accessTokenProperty->getValue($this->client));
}
public function testGetPaginatedDataGeneratorDelegatesToPaginationService(): void
{
$payload = ['filters' => []];
$type = 'contacts';
$offset = 0;
$total = 0;
$lastRecordId = null;
$expectedResults = [
['id' => 'id_1', 'properties' => []],
['id' => 'id_2', 'properties' => []],
];
// Mock the pagination service to return a generator
$this->paginationServiceMock
->expects($this->once())
->method('getPaginatedDataGenerator')
->with(
$this->client,
$payload,
$type,
$offset,
$this->anything(),
$this->anything()
)
->willReturnCallback(function () use ($expectedResults) {
foreach ($expectedResults as $result) {
yield $result;
}
});
// Execute the pagination
$results = [];
foreach ($this->client->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastRecordId) as $result) {
$results[] = $result;
}
$this->assertCount(2, $results);
$this->assertEquals('id_1', $results[0]['id']);
$this->assertEquals('id_2', $results[1]['id']);
}
public function testEnsureValidTokenDelegatesToTokenManager(): void
{
$socialAccountMock = $this->createMock(SocialAccount::class);
// Set up OAuth account
$reflection = new \ReflectionClass($this->client);
$oauthAccountProperty = $reflection->getProperty('oauthAccount');
$oauthAccountProperty->setAccessible(true);
$oauthAccountProperty->setValue($this->client, $socialAccountMock);
// Mock token manager to return new token
$this->tokenManagerMock
->expects($this->once())
->method('ensureValidToken')
->with($socialAccountMock)
->willReturn('new_access_token');
// Call ensureValidToken
$this->client->ensureValidToken();
// Verify access token was updated
$accessTokenProperty = $reflection->getProperty('accessToken');
$accessTokenProperty->setAccessible(true);
$this->assertEquals('new_access_token', $accessTokenProperty->getValue($this->client));
}
public function testGetOwnersArchivedWithValidResponse(): void
{
$responseData = [
'results' => [
[
'id' => '123',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'John',
'lastName' => 'Doe',
'userId' => 456,
'userIdIncludingInactive' => 789,
'createdAt' => '2023-01-01T12:00:00Z',
'updatedAt' => '2023-01-02T12:00:00Z',
'archived' => true,
],
],
];
// Create a mock response object
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn($responseData);
// Set up the client to return our test data
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=true'
)
->willReturn($response);
// Call the method
$result = $this->client->getOwnersArchived(true);
// Assert the results
$this->assertCount(1, $result);
$this->assertEquals('123', $result[0]->getId());
$this->assertEquals('[EMAIL]', $result[0]->getEmail());
$this->assertEquals('John Doe', $result[0]->getFullName());
$this->assertTrue($result[0]->isArchived());
}
public function testGetOwnersArchivedWithEmptyResponse(): void
{
// Create a mock response object with empty results
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn(['results' => []]);
// Set up the client to return empty results
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=false'
)
->willReturn($response);
// Call the method
$result = $this->client->getOwnersArchived(false);
// Assert the results
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithInvalidResponse(): void
{
// Create a mock response that will throw an exception when toArray is called
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willThrowException(new \InvalidArgumentException('Invalid JSON'));
// Set up the client to return the problematic response
$this->client->method('makeRequest')
->willReturn($response);
// Mock the logger to expect an error message
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with($this->stringContains('Failed to fetch owners'));
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
// Call the method and expect an empty array on error
$result = $this->client->getOwnersArchived(true);
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithHttpError(): void
{
// Set up the client to throw an exception
$this->client->method('makeRequest')
->willThrowException(new \Exception('HTTP Error'));
// Mock the logger to expect an error message
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with($this->stringContains('Failed to fetch owners'));
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
// Call the method and expect an empty array on error
$result = $this->client->getOwnersArchived(false);
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithOwnerCreationException(): void
{
$responseData = [
'results' => [
[
'id' => '123',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'John',
'lastName' => 'Doe',
'userId' => 456,
'userIdIncludingInactive' => 789,
'createdAt' => '2023-01-01T12:00:00Z',
'updatedAt' => '2023-01-02T12:00:00Z',
'archived' => false,
],
[
'id' => '456',
'email' => '[EMAIL]',
'type' => 'PERSON',
'createdAt' => 'invalid-date-format',
],
[
'id' => '789',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'Jane',
'lastName' => 'Smith',
'userId' => 999,
'userIdIncludingInactive' => 888,
'createdAt' => '2023-01-03T12:00:00Z',
'updatedAt' => '2023-01-04T12:00:00Z',
'archived' => false,
],
],
];
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn($responseData);
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=false'
)
->willReturn($response);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with(
'[HubSpot] Failed to process owner data',
$this->callback(function ($context) {
return isset($context['result']) &&
isset($context['error']) &&
$context['result']['id'] === '456' &&
$context['result']['email'] === '[EMAIL]' &&
str_contains($context['error'], 'invalid-date-format');
})
);
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
$result = $this->client->getOwnersArchived(false);
$this->assertIsArray($result);
$this->assertCount(2, $result);
$this->assertEquals('123', $result[0]->getId());
$this->assertEquals('[EMAIL]', $result[0]->getEmail());
$this->assertEquals('789', $result[1]->getId());
$this->assertEquals('[EMAIL]', $result[1]->getEmail());
}
public function testMakeRequestWithGetMethod(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManager = $this->createMock(HubspotTokenManager::class);
$psrResponse = new Response(200, [], json_encode(['status' => 'success']));
$expectedResponse = new HubspotResponse($psrResponse);
$hubspotClientMock = $this->createMock(HubspotClient::class);
$hubspotClientMock->expects($this->once())
->method('request')
->with(
'GET',
'https://api.hubapi.com/crm/v3/objects/contacts',
[],
null,
true
)
->willReturn($expectedResponse);
$factoryMock = $this->createMock(Factory::class);
$factoryMock->method('getClient')->willReturn($hubspotClientMock);
$client = $this->getMockBuilder(Client::class)
->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])
->onlyMethods(['getInstance'])
->getMock();
$client->method('getInstance')->willReturn($factoryMock);
$client->setAccessToken(new AccessToken(['access_token' => 'test_token']));
$result = $client->makeRequest('/crm/v3/objects/contacts', 'GET');
$this->assertSame($expectedResponse, $result);
}
public function testMakeRequestWithGetMethodAndQueryString(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManag...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20561
|
893
|
5
|
2026-05-11T15:51:44.741528+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778514704741_m2.jpg...
|
PhpStorm
|
faVsco.js – ClientTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
ClientTest
Run 'ClientTest'
Debug 'ClientTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
144
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Hubspot;
use GuzzleHttp\Psr7\Response;
use HubSpot\Client\Crm\Associations\Api\BatchApi;
use HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId;
use HubSpot\Client\Crm\Associations\Model\BatchResponsePublicAssociationMulti;
use HubSpot\Client\Crm\Associations\Model\PublicObjectId;
use HubSpot\Client\Crm\Companies\ApiException as CompanyApiException;
use HubSpot\Client\Crm\Contacts\ApiException as ContactApiException;
use HubSpot\Client\Crm\Deals\Api\BasicApi as DealsBasicApi;
use HubSpot\Client\Crm\Deals\ApiException as DealApiException;
use HubSpot\Client\Crm\Deals\Model\SimplePublicObjectWithAssociations as DealWithAssociations;
use HubSpot\Client\Crm\Pipelines\Api\PipelinesApi;
use HubSpot\Client\Crm\Pipelines\Model\CollectionResponsePipeline;
use HubSpot\Client\Crm\Pipelines\Model\Error;
use HubSpot\Client\Crm\Pipelines\Model\Pipeline;
use HubSpot\Client\Crm\Pipelines\Model\PipelineStage;
use HubSpot\Client\Crm\Properties\Api\CoreApi;
use HubSpot\Client\Crm\Properties\Model\Property;
use HubSpot\Discovery as HubSpotDiscovery;
use HubSpot\Discovery\Crm\Deals\Discovery as DealsDiscovery;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\SocialAccount;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\HubspotTokenManager;
use Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService;
use Jiminny\Services\SocialAccountService;
use League\OAuth2\Client\Token\AccessToken;
use Mockery;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use SevenShores\Hubspot\Endpoints\Engagements;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Factory;
use SevenShores\Hubspot\Http\Client as HubspotClient;
use SevenShores\Hubspot\Http\Response as HubspotResponse;
/**
* @runTestsInSeparateProcesses
*
* @preserveGlobalState disabled
*/
class ClientTest extends TestCase
{
private const string RESPONSE_TYPE_STAGE_FIELD = 'stage';
private const string RESPONSE_TYPE_PIPELINE_FIELD = 'pipeline';
private const string RESPONSE_TYPE_REGULAR_FIELD = 'regular';
/**
* @var Client&MockObject
*/
private Client $client;
private Configuration $config;
/**
* @var SocialAccountService&MockObject
*/
private SocialAccountService $socialAccountServiceMock;
/**
* @var HubspotPaginationService&MockObject
*/
private HubspotPaginationService $paginationServiceMock;
/**
* @var HubspotTokenManager&MockObject
*/
private HubspotTokenManager $tokenManagerMock;
/**
* @var CoreApi&MockObject
*/
private CoreApi $coreApiMock;
/**
* @var PipelinesApi&MockObject
*/
private PipelinesApi $pipelinesApiMock;
/**
* @var BatchApi&MockObject
*/
private BatchApi $associationsBatchApiMock;
/**
* @var DealsBasicApi&MockObject
*/
private DealsBasicApi $dealsBasicApiMock;
/**
* @var Engagements&MockObject
*/
private $engagementsMock;
/**
* @var HubspotClient&MockObject
*/
private HubspotClient $hubspotClientMock;
protected function setUp(): void
{
// Create mocks for dependencies
$this->socialAccountServiceMock = $this->createMock(SocialAccountService::class);
$this->paginationServiceMock = $this->createMock(HubspotPaginationService::class);
$this->tokenManagerMock = $this->createMock(HubspotTokenManager::class);
// Create a real Client instance with mocked dependencies
// Create a partial mock only for the methods we need to mock
$client = $this->createPartialMock(Client::class, ['getInstance', 'getNewInstance', 'makeRequest']);
$client->setLogger(new NullLogger());
// Inject the real dependencies using reflection
$reflection = new \ReflectionClass(Client::class);
$accountServiceProperty = $reflection->getProperty('accountService');
$accountServiceProperty->setAccessible(true);
$accountServiceProperty->setValue($client, $this->socialAccountServiceMock);
$paginationServiceProperty = $reflection->getProperty('paginationService');
$paginationServiceProperty->setAccessible(true);
$paginationServiceProperty->setValue($client, $this->paginationServiceMock);
$tokenManagerProperty = $reflection->getProperty('tokenManager');
$tokenManagerProperty->setAccessible(true);
$tokenManagerProperty->setValue($client, $this->tokenManagerMock);
$factoryMock = $this->createMock(Factory::class);
$hubspotClientMock = $this->createMock(HubspotClient::class);
$engagementsMock = $this->createMock(\SevenShores\Hubspot\Endpoints\Engagements::class);
$factoryMock->method('getClient')->willReturn($hubspotClientMock);
$factoryMock->method('__call')->with('engagements')->willReturn($engagementsMock);
$client->method('getInstance')->willReturn($factoryMock);
$discoveryMock = $this->createMock(HubSpotDiscovery\Discovery::class);
$crmMock = $this->createMock(HubSpotDiscovery\Crm\Discovery::class);
$associationsMock = $this->createMock(HubSpotDiscovery\Crm\Associations\Discovery::class);
$propertiesMock = $this->createMock(HubSpotDiscovery\Crm\Properties\Discovery::class);
$pipelinesMock = $this->createMock(HubSpotDiscovery\Crm\Pipelines\Discovery::class);
$coreApiMock = $this->createMock(CoreApi::class);
$pipelinesApiMock = $this->createMock(PipelinesApi::class);
$associationsBatchApiMock = $this->createMock(BatchApi::class);
$dealsBasicApiMock = $this->createMock(DealsBasicApi::class);
$propertiesMock->method('__call')->with('coreApi')->willReturn($coreApiMock);
$pipelinesMock->method('__call')->with('pipelinesApi')->willReturn($pipelinesApiMock);
$associationsMock->method('__call')->with('batchApi')->willReturn($associationsBatchApiMock);
$dealsDiscoveryMock = $this->createMock(DealsDiscovery::class);
$dealsDiscoveryMock->method('__call')->with('basicApi')->willReturn($dealsBasicApiMock);
$returnMap = ['properties' => $propertiesMock, 'pipelines' => $pipelinesMock, 'associations' => $associationsMock, 'deals' => $dealsDiscoveryMock];
$crmMock->method('__call')
->willReturnCallback(static fn (string $name) => $returnMap[$name])
;
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client->method('getNewInstance')->willReturn($discoveryMock);
$this->client = $client;
$this->config = $this->createMock(Configuration::class);
$this->client->setConfiguration($this->config);
$this->coreApiMock = $coreApiMock;
$this->pipelinesApiMock = $pipelinesApiMock;
$this->associationsBatchApiMock = $associationsBatchApiMock;
$this->dealsBasicApiMock = $dealsBasicApiMock;
$this->engagementsMock = $engagementsMock;
$this->hubspotClientMock = $hubspotClientMock;
}
public function testGetMinimumApiVersion(): void
{
$this->assertIsString($this->client->getMinimumApiVersion());
}
public function testGetInstance(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManager = $this->createMock(HubspotTokenManager::class);
$client = new Client($socialAccountService, $paginationService, $tokenManager);
$client->setBaseUrl('[URL_WITH_CREDENTIALS] The class CollectionResponsePipeline will be deprecated in the next Hubspot version
*/
$this->pipelinesApiMock
->method('getAll')
->with('deals')
->willReturn(new CollectionResponsePipeline([
'results' => [$this->generatePipeline()],
]))
;
$this->assertEquals(
[
['id' => 'foo', 'label' => 'bar'],
['id' => 'baz', 'label' => 'qux'],
],
$this->client->fetchOpportunityPipelineStages()
);
}
public function testFetchOpportunityPipelineStagesErrorResponse(): void
{
$this->pipelinesApiMock
->method('getAll')
->with('deals')
->willReturn(new Error(['message' => 'test error']))
;
$this->assertEmpty($this->client->fetchOpportunityPipelineStages());
}
#[\PHPUnit\Framework\Attributes\DataProvider('meetingOutcomeFieldProvider')]
public function testFetchMeetingOutcomeFieldOptions(string $fieldId, string $expectedEndpoint): void
{
$field = new Field(['crm_provider_id' => $fieldId]);
$this->hubspotClientMock
->expects($this->once())
->method('request')
->with('GET', $expectedEndpoint)
->willReturn($this->generateHubSpotResponse(
[
'options' => [
['value' => 'option_1', 'label' => 'Option 1', 'displayOrder' => 0],
['value' => 'option_2', 'label' => 'Option 2', 'displayOrder' => 1],
['value' => 'option_3', 'label' => 'Option 3', 'displayOrder' => 2],
],
]
))
;
$this->assertEquals(
[
['id' => 'option_1', 'value' => 'option_1', 'label' => 'Option 1', 'display_order' => 0],
['id' => 'option_2', 'value' => 'option_2', 'label' => 'Option 2', 'display_order' => 1],
['id' => 'option_3', 'value' => 'option_3', 'label' => 'Option 3', 'display_order' => 2],
],
$this->client->fetchMeetingOutcomeFieldOptions($field)
);
}
public static function meetingOutcomeFieldProvider(): array
{
return [
'meeting outcome field' => [
'meetingOutcome',
'[URL_WITH_CREDENTIALS] The class CollectionResponsePipeline will be deprecated in the next Hubspot version
*/
$pipelineStagesResponse = new CollectionResponsePipeline([
'results' => [$this->generatePipeline()],
]);
$this->pipelinesApiMock
->method('getAll')
->willReturn($pipelineStagesResponse);
}
if ($type === self::RESPONSE_TYPE_PIPELINE_FIELD) {
$field->method('isStageField')->willReturn(false);
$field->method('isPipelineField')->willReturn(true);
$pipelineResponse = $this->generateHubSpotResponse([
'results' => [
['id' => '123', 'label' => 'Sales'],
['id' => 'default', 'label' => 'CS'],
],
]);
$this->client
->method('makeRequest')
->with('/crm/v3/pipelines/deals')
->willReturn($pipelineResponse);
}
$this->assertEquals(
$responses[$type],
$this->client->fetchOpportunityFieldOptions($field)
);
}
public static function opportunityFieldOptionsProvider(): array
{
return [
'stage field' => [
'field' => new Field(['crm_provider_id' => 'dealstage']),
'type' => self::RESPONSE_TYPE_STAGE_FIELD,
],
'pipeline field' => [
'field' => new Field(['crm_provider_id' => 'pipeline']),
'type' => self::RESPONSE_TYPE_PIPELINE_FIELD,
],
'regular field' => [
'field' => new Field(['crm_provider_id' => 'some_property']),
'type' => self::RESPONSE_TYPE_REGULAR_FIELD,
],
];
}
private function generateHubSpotResponse(array $data): HubspotResponse
{
return new HubspotResponse(new Response(200, [], json_encode($data)));
}
private function generateProperty(): Property
{
return new Property([
'name' => 'some_property',
'options' => [
[
'label' => 'label_1',
'value' => 'value_1',
],
[
'label' => 'label_2',
'value' => 'value_2',
],
],
]);
}
private function generatePipeline(): Pipeline
{
return new Pipeline(['stages' => [
new PipelineStage(['id' => 'foo', 'label' => 'bar']),
new PipelineStage(['id' => 'baz', 'label' => 'qux']),
]]);
}
public function testFetchOpportunityPipelines(): void
{
$this->client
->method('makeRequest')
->with('/crm/v3/pipelines/deals')
->willReturn($this->generateHubSpotResponse([
'results' => [
['id' => 'id_1', 'label' => 'Option 1', 'displayOrder' => 0],
['id' => 'id_2', 'label' => 'Option 2', 'displayOrder' => 1],
['id' => 'id_3', 'label' => 'Option 3', 'displayOrder' => 2],
],
]));
$this->assertEquals(
[
['id' => 'id_1', 'label' => 'Option 1'],
['id' => 'id_2', 'label' => 'Option 2'],
['id' => 'id_3', 'label' => 'Option 3'],
],
$this->client->fetchOpportunityPipelines()
);
}
public function testGetPaginatedData(): void
{
$expectedResults = [
['id' => 'id_1', 'properties' => []],
['id' => 'id_2', 'properties' => []],
['id' => 'id_3', 'properties' => []],
];
// Mock the pagination service to return a generator and modify reference parameters
$this->paginationServiceMock
->expects($this->once())
->method('getPaginatedDataGenerator')
->with(
$this->client,
['payload_key' => 'payload_value'],
'foobar',
0,
$this->anything(),
$this->anything()
)
->willReturnCallback(function ($client, $payload, $type, $offset, &$total, &$lastRecordId) use ($expectedResults) {
$total = 3;
$lastRecordId = 'id_3';
foreach ($expectedResults as $result) {
yield $result;
}
});
$this->assertEquals(
[
'results' => $expectedResults,
'total' => 3,
'last_record' => 'id_3',
],
$this->client->getPaginatedData(['payload_key' => 'payload_value'], 'foobar')
);
}
public function testGetAssociationsData(): void
{
$ids = ['1', '2', '3'];
$fromObject = 'deals';
$toObject = 'contacts';
$responseResults = [];
foreach ($ids as $id) {
$from = new PublicObjectId();
$from->setId($id);
$to1 = new PublicObjectId();
$to1->setId('contact_' . $id . '_1');
$to2 = new PublicObjectId();
$to2->setId('contact_' . $id . '_2');
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to1, $to2]);
$responseResults[] = $result;
}
$batchResponse = new BatchResponsePublicAssociationMulti();
$batchResponse->setResults($responseResults);
$this->associationsBatchApiMock->expects($this->once())
->method('read')
->with(
$this->equalTo($fromObject),
$this->equalTo($toObject),
$this->callback(function (BatchInputPublicObjectId $batchInput) use ($ids) {
$inputIds = array_map(
fn ($input) => $input->getId(),
$batchInput->getInputs()
);
return $inputIds === $ids;
})
)
->willReturn($batchResponse);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$expectedResult = [
'1' => ['contact_1_1', 'contact_1_2'],
'2' => ['contact_2_1', 'contact_2_2'],
'3' => ['contact_3_1', 'contact_3_2'],
];
$this->assertEquals($expectedResult, $result);
}
public function testGetAssociationsDataHandlesException(): void
{
$ids = ['1', '2', '3'];
$fromObject = 'deals';
$toObject = 'contacts';
$exception = new \Exception('API Error');
$this->associationsBatchApiMock->expects($this->once())
->method('read')
->with(
$this->equalTo($fromObject),
$this->equalTo($toObject),
$this->callback(function ($batchInput) use ($ids) {
return $batchInput instanceof \HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId
&& count($batchInput->getInputs()) === count($ids);
})
)
->willThrowException($exception);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with(
'[Hubspot] Failed to fetch associations',
[
'from_object' => $fromObject,
'to_object' => $toObject,
'reason' => 'API Error',
]
);
$this->client->setLogger($loggerMock);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$this->assertEmpty($result);
$this->assertIsArray($result);
}
public function testGetAssociationsDataWithLargeDataSet(): void
{
$ids = array_map(fn ($i) => (string) $i, range(1, 2500)); // More than 1000 items
$fromObject = 'deals';
$toObject = 'contacts';
$firstBatchResponse = new BatchResponsePublicAssociationMulti();
$firstBatchResults = array_map(function ($id) {
$from = new PublicObjectId();
$from->setId($id);
$to = new PublicObjectId();
$to->setId('contact_' . $id);
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to]);
return $result;
}, array_slice($ids, 0, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));
$firstBatchResponse->setResults($firstBatchResults);
$secondBatchResponse = new BatchResponsePublicAssociationMulti();
$secondBatchResults = array_map(function ($id) {
$from = new PublicObjectId();
$from->setId($id);
$to = new PublicObjectId();
$to->setId('contact_' . $id);
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to]);
return $result;
}, array_slice($ids, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));
$secondBatchResponse->setResults($secondBatchResults);
$this->associationsBatchApiMock->expects($this->exactly(3))
->method('read')
->willReturnOnConsecutiveCalls($firstBatchResponse, $secondBatchResponse);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$this->assertCount(2500, $result);
$this->assertArrayHasKey('1', $result);
$this->assertArrayHasKey('2500', $result);
$this->assertEquals(['contact_1'], $result['1']);
$this->assertEquals(['contact_2500'], $result['2500']);
}
public function testGetContactByEmailSuccess(): void
{
$email = '[EMAIL]';
$fields = ['firstname', 'lastname', 'email'];
$contactMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$contactMock->method('getId')->willReturn('12345');
$contactMock->method('getProperties')->willReturn([
'firstname' => 'John',
'lastname' => 'Doe',
'email' => '[EMAIL]',
]);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, 'firstname,lastname,email', null, false, 'email')
->willReturn($contactMock);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new NullLogger());
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([
'id' => '12345',
'properties' => [
'firstname' => 'John',
'lastname' => 'Doe',
'email' => '[EMAIL]',
],
], $result);
}
public function testGetContactByEmailWithEmptyFields(): void
{
$email = '[EMAIL]';
$fields = [];
$contactMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$contactMock->method('getId')->willReturn('12345');
$contactMock->method('getProperties')->willReturn(['email' => '[EMAIL]']);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, '', null, false, 'email')
->willReturn($contactMock);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new NullLogger());
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([
'id' => '12345',
'properties' => ['email' => '[EMAIL]'],
], $result);
}
public function testGetContactByEmailApiException(): void
{
$email = '[EMAIL]';
$fields = ['firstname', 'lastname'];
$exception = new \HubSpot\Client\Crm\Contacts\ApiException('Contact not found', 404);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, 'firstname,lastname', null, false, 'email')
->willThrowException($exception);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('info')
->with(
'[Hubspot] Failed to fetch contact',
[
'email' => $email,
'reason' => 'Contact not found',
]
);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger($loggerMock);
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([], $result);
}
public function testGetOpportunityById(): void
{
$opportunityId = '12345';
$expectedProperties = [
'dealname' => 'Test Opportunity',
'amount' => '1000.00',
'closedate' => '2024-12-31T23:59:59.999Z',
'dealstage' => 'presentationscheduled',
'pipeline' => 'default',
];
$mockHubspotOpportunity = $this->createMock(DealWithAssociations::class);
$mockHubspotOpportunity->method('getProperties')->willReturn((object) $expectedProperties);
$mockHubspotOpportunity->method('getId')->willReturn($opportunityId);
$now = new \DateTimeImmutable();
$mockHubspotOpportunity->method('getCreatedAt')->willReturn($now);
$mockHubspotOpportunity->method('getUpdatedAt')->willReturn($now);
$mockHubspotOpportunity->method('getArchived')->willReturn(false);
$this->dealsBasicApiMock
->expects($this->once())
->method('getById')
->willReturn($mockHubspotOpportunity);
// Assuming Client::getOpportunityById processes the SimplePublicObject and returns an associative array.
// The structure might be like: ['id' => ..., 'properties' => [...], 'createdAt' => ..., ...]
// Adjust assertions below based on the actual return structure of your method.
$result = $this->client->getOpportunityById($opportunityId, ['test', 'test']);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertEquals($opportunityId, $result['id']);
}
public function testGetContactById(): void
{
$crmId = 'contact-123';
$fields = ['firstname', 'lastname'];
$expectedProperties = ['firstname' => 'John', 'lastname' => 'Doe'];
$mockContact = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$mockContact->method('getId')->willReturn($crmId);
$mockContact->method('getProperties')->willReturn((object) $expectedProperties);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($crmId, 'firstname,lastname')
->willReturn($mockContact);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new \Psr\Log\NullLogger());
$result = $client->getContactById($crmId, $fields);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('properties', $result);
$this->assertEquals($crmId, $result['id']);
$this->assertEquals((object) $expectedProperties, $result['properties']);
}
public function testGetAccountById(): void
{
$crmId = 'account-123';
$fields = ['name', 'industry'];
$expectedProperties = ['name' => 'Acme Corp', 'industry' => 'Technology'];
$mockCompany = $this->createMock(\HubSpot\Client\Crm\Companies\Model\SimplePublicObjectWithAssociations::class);
$mockCompany->method('getId')->willReturn($crmId);
$mockCompany->method('getProperties')->willReturn((object) $expectedProperties);
$companiesApiMock = $this->createMock(\HubSpot\Client\Crm\Companies\Api\BasicApi::class);
$companiesApiMock->expects($this->once())
->method('getById')
->with($crmId, 'name,industry')
->willReturn($mockCompany);
$companiesDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Companies\Discovery::class);
$companiesDiscoveryMock->method('__call')->with('basicApi')->willReturn($companiesApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($companiesDiscoveryMock) {
if ($name === 'companies') {
return $companiesDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new \Psr\Log\NullLogger());
$result = $client->getAccountById($crmId, $fields);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('properties', $result);
$this->assertEquals($crmId, $result['id']);
$this->assertEquals((object) $expectedProperties, $result['properties']);
}
public function testEnsureValidTokenWithNoTokenUpdate(): void
{
$socialAccountMock = $this->createMock(SocialAccount::class);
// Set up OAuth account
$reflection = new \ReflectionClass($this->client);
$oauthAccountProperty = $reflection->getProperty('oauthAccount');
$oauthAccountProperty->setAccessible(true);
$oauthAccountProperty->setValue($this->client, $socialAccountMock);
$originalToken = 'original_token';
$accessTokenProperty = $reflection->getProperty('accessToken');
$accessTokenProperty->setAccessible(true);
$accessTokenProperty->setValue($this->client, $originalToken);
// Mock token manager to return null (no refresh needed)
$this->tokenManagerMock
->expects($this->once())
->method('ensureValidToken')
->with($socialAccountMock)
->willReturn(null);
// Call ensureValidToken
$this->client->ensureValidToken();
// Verify access token was not changed
$this->assertEquals($originalToken, $accessTokenProperty->getValue($this->client));
}
public function testGetPaginatedDataGeneratorDelegatesToPaginationService(): void
{
$payload = ['filters' => []];
$type = 'contacts';
$offset = 0;
$total = 0;
$lastRecordId = null;
$expectedResults = [
['id' => 'id_1', 'properties' => []],
['id' => 'id_2', 'properties' => []],
];
// Mock the pagination service to return a generator
$this->paginationServiceMock
->expects($this->once())
->method('getPaginatedDataGenerator')
->with(
$this->client,
$payload,
$type,
$offset,
$this->anything(),
$this->anything()
)
->willReturnCallback(function () use ($expectedResults) {
foreach ($expectedResults as $result) {
yield $result;
}
});
// Execute the pagination
$results = [];
foreach ($this->client->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastRecordId) as $result) {
$results[] = $result;
}
$this->assertCount(2, $results);
$this->assertEquals('id_1', $results[0]['id']);
$this->assertEquals('id_2', $results[1]['id']);
}
public function testEnsureValidTokenDelegatesToTokenManager(): void
{
$socialAccountMock = $this->createMock(SocialAccount::class);
// Set up OAuth account
$reflection = new \ReflectionClass($this->client);
$oauthAccountProperty = $reflection->getProperty('oauthAccount');
$oauthAccountProperty->setAccessible(true);
$oauthAccountProperty->setValue($this->client, $socialAccountMock);
// Mock token manager to return new token
$this->tokenManagerMock
->expects($this->once())
->method('ensureValidToken')
->with($socialAccountMock)
->willReturn('new_access_token');
// Call ensureValidToken
$this->client->ensureValidToken();
// Verify access token was updated
$accessTokenProperty = $reflection->getProperty('accessToken');
$accessTokenProperty->setAccessible(true);
$this->assertEquals('new_access_token', $accessTokenProperty->getValue($this->client));
}
public function testGetOwnersArchivedWithValidResponse(): void
{
$responseData = [
'results' => [
[
'id' => '123',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'John',
'lastName' => 'Doe',
'userId' => 456,
'userIdIncludingInactive' => 789,
'createdAt' => '2023-01-01T12:00:00Z',
'updatedAt' => '2023-01-02T12:00:00Z',
'archived' => true,
],
],
];
// Create a mock response object
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn($responseData);
// Set up the client to return our test data
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=true'
)
->willReturn($response);
// Call the method
$result = $this->client->getOwnersArchived(true);
// Assert the results
$this->assertCount(1, $result);
$this->assertEquals('123', $result[0]->getId());
$this->assertEquals('[EMAIL]', $result[0]->getEmail());
$this->assertEquals('John Doe', $result[0]->getFullName());
$this->assertTrue($result[0]->isArchived());
}
public function testGetOwnersArchivedWithEmptyResponse(): void
{
// Create a mock response object with empty results
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn(['results' => []]);
// Set up the client to return empty results
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=false'
)
->willReturn($response);
// Call the method
$result = $this->client->getOwnersArchived(false);
// Assert the results
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithInvalidResponse(): void
{
// Create a mock response that will throw an exception when toArray is called
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willThrowException(new \InvalidArgumentException('Invalid JSON'));
// Set up the client to return the problematic response
$this->client->method('makeRequest')
->willReturn($response);
// Mock the logger to expect an error message
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with($this->stringContains('Failed to fetch owners'));
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
// Call the method and expect an empty array on error
$result = $this->client->getOwnersArchived(true);
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithHttpError(): void
{
// Set up the client to throw an exception
$this->client->method('makeRequest')
->willThrowException(new \Exception('HTTP Error'));
// Mock the logger to expect an error message
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with($this->stringContains('Failed to fetch owners'));
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
// Call the method and expect an empty array on error
$result = $this->client->getOwnersArchived(false);
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithOwnerCreationException(): void
{
$responseData = [
'results' => [
[
'id' => '123',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'John',
'lastName' => 'Doe',
'userId' => 456,
'userIdIncludingInactive' => 789,
'createdAt' => '2023-01-01T12:00:00Z',
'updatedAt' => '2023-01-02T12:00:00Z',
'archived' => false,
],
[
'id' => '456',
'email' => '[EMAIL]',
'type' => 'PERSON',
'createdAt' => 'invalid-date-format',
],
[
'id' => '789',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'Jane',
'lastName' => 'Smith',
'userId' => 999,
'userIdIncludingInactive' => 888,
'createdAt' => '2023-01-03T12:00:00Z',
'updatedAt' => '2023-01-04T12:00:00Z',
'archived' => false,
],
],
];
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn($responseData);
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=false'
)
->willReturn($response);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with(
'[HubSpot] Failed to process owner data',
$this->callback(function ($context) {
return isset($context['result']) &&
isset($context['error']) &&
$context['result']['id'] === '456' &&
$context['result']['email'] === '[EMAIL]' &&
str_contains($context['error'], 'invalid-date-format');
})
);
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
$result = $this->client->getOwnersArchived(false);
$this->assertIsArray($result);
$this->assertCount(2, $result);
$this->assertEquals('123', $result[0]->getId());
$this->assertEquals('[EMAIL]', $result[0]->getEmail());
$this->assertEquals('789', $result[1]->getId());
$this->assertEquals('[EMAIL]', $result[1]->getEmail());
}
public function testMakeRequestWithGetMethod(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManager = $this->createMock(HubspotTokenManager::class);
$psrResponse = new Response(200, [], json_encode(['status' => 'success']));
$expectedResponse = new HubspotResponse($psrResponse);
$hubspotClientMock = $this->createMock(HubspotClient::class);
$hubspotClientMock->expects($this->once())
->method('request')
->with(
'GET',
'https://api.hubapi.com/crm/v3/objects/contacts',
[],
null,
true
)
->willReturn($expectedResponse);
$factoryMock = $this->createMock(Factory::class);
$factoryMock->method('getClient')->willReturn($hubspotClientMock);
$client = $this->getMockBuilder(Client::class)
->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])
->onlyMethods(['getInstance'])
->getMock();
$client->method('getInstance')->willReturn($factoryMock);
$client->setAccessToken(new AccessToken(['access_token' => 'test_token']));
$result = $client->makeRequest('/crm/v3/objects/contacts', 'GET');
$this->assertSame($expectedResponse, $result);
}
public function testMakeRequestWithGetMethodAndQueryString(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManag...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8597075,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ClientTest","depth":6,"bounds":{"left":0.875,"top":0.019952115,"width":0.04055851,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ClientTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ClientTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.50265956,"top":0.17478053,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"144","depth":4,"bounds":{"left":0.5142952,"top":0.17478053,"width":0.011968086,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":4,"bounds":{"left":0.52825797,"top":0.17478053,"width":0.008976064,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.53889626,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.5462101,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Hubspot;\n\nuse GuzzleHttp\\Psr7\\Response;\nuse HubSpot\\Client\\Crm\\Associations\\Api\\BatchApi;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\BatchResponsePublicAssociationMulti;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\PublicObjectId;\nuse HubSpot\\Client\\Crm\\Companies\\ApiException as CompanyApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\ApiException as ContactApiException;\nuse HubSpot\\Client\\Crm\\Deals\\Api\\BasicApi as DealsBasicApi;\nuse HubSpot\\Client\\Crm\\Deals\\ApiException as DealApiException;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectWithAssociations as DealWithAssociations;\nuse HubSpot\\Client\\Crm\\Pipelines\\Api\\PipelinesApi;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\CollectionResponsePipeline;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Error;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Pipeline;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\PipelineStage;\nuse HubSpot\\Client\\Crm\\Properties\\Api\\CoreApi;\nuse HubSpot\\Client\\Crm\\Properties\\Model\\Property;\nuse HubSpot\\Discovery as HubSpotDiscovery;\nuse HubSpot\\Discovery\\Crm\\Deals\\Discovery as DealsDiscovery;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\HubspotTokenManager;\nuse Jiminny\\Services\\Crm\\Hubspot\\Pagination\\HubspotPaginationService;\nuse Jiminny\\Services\\SocialAccountService;\nuse League\\OAuth2\\Client\\Token\\AccessToken;\nuse Mockery;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse PHPUnit\\Framework\\TestCase;\nuse Psr\\Log\\NullLogger;\nuse SevenShores\\Hubspot\\Endpoints\\Engagements;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Factory;\nuse SevenShores\\Hubspot\\Http\\Client as HubspotClient;\nuse SevenShores\\Hubspot\\Http\\Response as HubspotResponse;\n\n/**\n * @runTestsInSeparateProcesses\n *\n * @preserveGlobalState disabled\n */\nclass ClientTest extends TestCase\n{\n private const string RESPONSE_TYPE_STAGE_FIELD = 'stage';\n private const string RESPONSE_TYPE_PIPELINE_FIELD = 'pipeline';\n private const string RESPONSE_TYPE_REGULAR_FIELD = 'regular';\n /**\n * @var Client&MockObject\n */\n private Client $client;\n private Configuration $config;\n\n /**\n * @var SocialAccountService&MockObject\n */\n private SocialAccountService $socialAccountServiceMock;\n\n /**\n * @var HubspotPaginationService&MockObject\n */\n private HubspotPaginationService $paginationServiceMock;\n\n /**\n * @var HubspotTokenManager&MockObject\n */\n private HubspotTokenManager $tokenManagerMock;\n\n /**\n * @var CoreApi&MockObject\n */\n private CoreApi $coreApiMock;\n\n /**\n * @var PipelinesApi&MockObject\n */\n private PipelinesApi $pipelinesApiMock;\n\n /**\n * @var BatchApi&MockObject\n */\n private BatchApi $associationsBatchApiMock;\n\n /**\n * @var DealsBasicApi&MockObject\n */\n private DealsBasicApi $dealsBasicApiMock;\n\n /**\n * @var Engagements&MockObject\n */\n private $engagementsMock;\n\n /**\n * @var HubspotClient&MockObject\n */\n private HubspotClient $hubspotClientMock;\n\n protected function setUp(): void\n {\n // Create mocks for dependencies\n $this->socialAccountServiceMock = $this->createMock(SocialAccountService::class);\n $this->paginationServiceMock = $this->createMock(HubspotPaginationService::class);\n $this->tokenManagerMock = $this->createMock(HubspotTokenManager::class);\n\n // Create a real Client instance with mocked dependencies\n // Create a partial mock only for the methods we need to mock\n $client = $this->createPartialMock(Client::class, ['getInstance', 'getNewInstance', 'makeRequest']);\n $client->setLogger(new NullLogger());\n\n // Inject the real dependencies using reflection\n $reflection = new \\ReflectionClass(Client::class);\n\n $accountServiceProperty = $reflection->getProperty('accountService');\n $accountServiceProperty->setAccessible(true);\n $accountServiceProperty->setValue($client, $this->socialAccountServiceMock);\n\n $paginationServiceProperty = $reflection->getProperty('paginationService');\n $paginationServiceProperty->setAccessible(true);\n $paginationServiceProperty->setValue($client, $this->paginationServiceMock);\n\n $tokenManagerProperty = $reflection->getProperty('tokenManager');\n $tokenManagerProperty->setAccessible(true);\n $tokenManagerProperty->setValue($client, $this->tokenManagerMock);\n $factoryMock = $this->createMock(Factory::class);\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $engagementsMock = $this->createMock(\\SevenShores\\Hubspot\\Endpoints\\Engagements::class);\n\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n $factoryMock->method('__call')->with('engagements')->willReturn($engagementsMock);\n\n $client->method('getInstance')->willReturn($factoryMock);\n\n $discoveryMock = $this->createMock(HubSpotDiscovery\\Discovery::class);\n $crmMock = $this->createMock(HubSpotDiscovery\\Crm\\Discovery::class);\n $associationsMock = $this->createMock(HubSpotDiscovery\\Crm\\Associations\\Discovery::class);\n $propertiesMock = $this->createMock(HubSpotDiscovery\\Crm\\Properties\\Discovery::class);\n $pipelinesMock = $this->createMock(HubSpotDiscovery\\Crm\\Pipelines\\Discovery::class);\n $coreApiMock = $this->createMock(CoreApi::class);\n $pipelinesApiMock = $this->createMock(PipelinesApi::class);\n $associationsBatchApiMock = $this->createMock(BatchApi::class);\n $dealsBasicApiMock = $this->createMock(DealsBasicApi::class);\n\n $propertiesMock->method('__call')->with('coreApi')->willReturn($coreApiMock);\n $pipelinesMock->method('__call')->with('pipelinesApi')->willReturn($pipelinesApiMock);\n $associationsMock->method('__call')->with('batchApi')->willReturn($associationsBatchApiMock);\n $dealsDiscoveryMock = $this->createMock(DealsDiscovery::class);\n $dealsDiscoveryMock->method('__call')->with('basicApi')->willReturn($dealsBasicApiMock);\n\n $returnMap = ['properties' => $propertiesMock, 'pipelines' => $pipelinesMock, 'associations' => $associationsMock, 'deals' => $dealsDiscoveryMock];\n $crmMock->method('__call')\n ->willReturnCallback(static fn (string $name) => $returnMap[$name])\n ;\n\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n\n $this->client = $client;\n $this->config = $this->createMock(Configuration::class);\n $this->client->setConfiguration($this->config);\n $this->coreApiMock = $coreApiMock;\n $this->pipelinesApiMock = $pipelinesApiMock;\n $this->associationsBatchApiMock = $associationsBatchApiMock;\n $this->dealsBasicApiMock = $dealsBasicApiMock;\n $this->engagementsMock = $engagementsMock;\n $this->hubspotClientMock = $hubspotClientMock;\n }\n\n public function testGetMinimumApiVersion(): void\n {\n $this->assertIsString($this->client->getMinimumApiVersion());\n }\n\n public function testGetInstance(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $client = new Client($socialAccountService, $paginationService, $tokenManager);\n $client->setBaseUrl('https://example.com');\n $client->setAccessToken(new AccessToken(['access_token' => 'foo']));\n $factory = $client->getInstance();\n\n $this->assertEquals('foo', $factory->getClient()->key);\n }\n\n public function testGetNewInstance(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $client = new Client($socialAccountService, $paginationService, $tokenManager);\n $client->setAccessToken(new AccessToken(['access_token' => 'foo']));\n\n $factory = $client->getNewInstance();\n $token = $factory->auth()->oAuth()->accessTokensApi()->getConfig()->getAccessToken();\n $this->assertEquals('foo', $token);\n }\n\n public function testFetchProperty(): void\n {\n $this->coreApiMock->method('getByName')->willReturn($this->generateProperty());\n\n $this->assertEquals(\n 'some_property',\n $this->client->fetchProperty('foo', 'test')['name']\n );\n }\n\n public function testFetchPropertyOptions(): void\n {\n $this->coreApiMock->method('getByName')->willReturn($this->generateProperty());\n\n $this->assertEquals(\n [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n $this->client->fetchPropertyOptions('foo', 'test')\n );\n }\n\n public function testFetchCallDispositions(): void\n {\n $this->engagementsMock\n ->method('getCallDispositions')\n ->willReturn($this->generateHubSpotResponse([\n [\n 'id' => 1,\n 'label' => 'foo',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'bar',\n 'deleted' => false,\n ],\n ]))\n ;\n\n $this->assertEquals(\n [\n [\n 'id' => 1,\n 'label' => 'foo',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'bar',\n 'deleted' => false,\n ],\n ],\n $this->client->fetchCallDispositions()\n );\n }\n\n public function testFetchDispositionFieldOptions(): void\n {\n $this->engagementsMock->method('getCallDispositions')\n ->willReturn($this->generateHubSpotResponse(\n [\n [\n 'id' => 1,\n 'label' => 'Label 1',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'Label 2',\n 'deleted' => true,\n ],\n ]\n ))\n ;\n\n $this->assertEquals(\n [\n [\n 'value' => 1,\n 'id' => 1,\n 'label' => 'Label 1',\n ],\n ],\n $this->client->fetchDispositionFieldOptions()\n );\n }\n\n public function testFetchOpportunityPipelineStages(): void\n {\n /**\n * @TODO: The class CollectionResponsePipeline will be deprecated in the next Hubspot version\n */\n $this->pipelinesApiMock\n ->method('getAll')\n ->with('deals')\n ->willReturn(new CollectionResponsePipeline([\n 'results' => [$this->generatePipeline()],\n ]))\n ;\n\n $this->assertEquals(\n [\n ['id' => 'foo', 'label' => 'bar'],\n ['id' => 'baz', 'label' => 'qux'],\n ],\n $this->client->fetchOpportunityPipelineStages()\n );\n }\n\n public function testFetchOpportunityPipelineStagesErrorResponse(): void\n {\n $this->pipelinesApiMock\n ->method('getAll')\n ->with('deals')\n ->willReturn(new Error(['message' => 'test error']))\n ;\n\n $this->assertEmpty($this->client->fetchOpportunityPipelineStages());\n }\n\n #[\\PHPUnit\\Framework\\Attributes\\DataProvider('meetingOutcomeFieldProvider')]\n public function testFetchMeetingOutcomeFieldOptions(string $fieldId, string $expectedEndpoint): void\n {\n $field = new Field(['crm_provider_id' => $fieldId]);\n\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->with('GET', $expectedEndpoint)\n ->willReturn($this->generateHubSpotResponse(\n [\n 'options' => [\n ['value' => 'option_1', 'label' => 'Option 1', 'displayOrder' => 0],\n ['value' => 'option_2', 'label' => 'Option 2', 'displayOrder' => 1],\n ['value' => 'option_3', 'label' => 'Option 3', 'displayOrder' => 2],\n ],\n ]\n ))\n ;\n\n $this->assertEquals(\n [\n ['id' => 'option_1', 'value' => 'option_1', 'label' => 'Option 1', 'display_order' => 0],\n ['id' => 'option_2', 'value' => 'option_2', 'label' => 'Option 2', 'display_order' => 1],\n ['id' => 'option_3', 'value' => 'option_3', 'label' => 'Option 3', 'display_order' => 2],\n ],\n $this->client->fetchMeetingOutcomeFieldOptions($field)\n );\n }\n\n public static function meetingOutcomeFieldProvider(): array\n {\n return [\n 'meeting outcome field' => [\n 'meetingOutcome',\n 'https://api.hubapi.com/crm/v3/properties/meeting/hs_meeting_outcome',\n ],\n 'activity type field' => [\n 'foobar',\n 'https://api.hubapi.com/crm/v3/properties/call/hs_activity_type',\n ],\n ];\n }\n\n #[\\PHPUnit\\Framework\\Attributes\\DataProvider('opportunityFieldOptionsProvider')]\n public function testFetchOpportunityFieldOptions(Field $field, string $type): void\n {\n $responses = [\n self::RESPONSE_TYPE_STAGE_FIELD => [\n ['id' => 'foo', 'label' => 'bar'],\n ['id' => 'baz', 'label' => 'qux'],\n ],\n self::RESPONSE_TYPE_PIPELINE_FIELD => [\n ['id' => '123', 'label' => 'Sales'],\n ['id' => 'default', 'label' => 'CS'],\n ],\n self::RESPONSE_TYPE_REGULAR_FIELD => [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n ];\n\n $field = $this->createMock(Field::class);\n\n if ($type === self::RESPONSE_TYPE_REGULAR_FIELD) {\n $field->method('isStageField')->willReturn(false);\n $field->method('isPipelineField')->willReturn(false);\n $propertyResponse = $this->generateProperty();\n $this->coreApiMock->method('getByName')->willReturn($propertyResponse);\n }\n\n if ($type === self::RESPONSE_TYPE_STAGE_FIELD) {\n $field->method('isStageField')->willReturn(true);\n /**\n * @TODO: The class CollectionResponsePipeline will be deprecated in the next Hubspot version\n */\n\n $pipelineStagesResponse = new CollectionResponsePipeline([\n 'results' => [$this->generatePipeline()],\n ]);\n $this->pipelinesApiMock\n ->method('getAll')\n ->willReturn($pipelineStagesResponse);\n }\n\n if ($type === self::RESPONSE_TYPE_PIPELINE_FIELD) {\n $field->method('isStageField')->willReturn(false);\n $field->method('isPipelineField')->willReturn(true);\n $pipelineResponse = $this->generateHubSpotResponse([\n 'results' => [\n ['id' => '123', 'label' => 'Sales'],\n ['id' => 'default', 'label' => 'CS'],\n ],\n ]);\n $this->client\n ->method('makeRequest')\n ->with('/crm/v3/pipelines/deals')\n ->willReturn($pipelineResponse);\n }\n\n $this->assertEquals(\n $responses[$type],\n $this->client->fetchOpportunityFieldOptions($field)\n );\n }\n\n public static function opportunityFieldOptionsProvider(): array\n {\n return [\n 'stage field' => [\n 'field' => new Field(['crm_provider_id' => 'dealstage']),\n 'type' => self::RESPONSE_TYPE_STAGE_FIELD,\n ],\n 'pipeline field' => [\n 'field' => new Field(['crm_provider_id' => 'pipeline']),\n 'type' => self::RESPONSE_TYPE_PIPELINE_FIELD,\n ],\n 'regular field' => [\n 'field' => new Field(['crm_provider_id' => 'some_property']),\n 'type' => self::RESPONSE_TYPE_REGULAR_FIELD,\n ],\n ];\n }\n\n private function generateHubSpotResponse(array $data): HubspotResponse\n {\n return new HubspotResponse(new Response(200, [], json_encode($data)));\n }\n\n private function generateProperty(): Property\n {\n return new Property([\n 'name' => 'some_property',\n 'options' => [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n ]);\n }\n\n private function generatePipeline(): Pipeline\n {\n return new Pipeline(['stages' => [\n new PipelineStage(['id' => 'foo', 'label' => 'bar']),\n new PipelineStage(['id' => 'baz', 'label' => 'qux']),\n ]]);\n }\n\n public function testFetchOpportunityPipelines(): void\n {\n $this->client\n ->method('makeRequest')\n ->with('/crm/v3/pipelines/deals')\n ->willReturn($this->generateHubSpotResponse([\n 'results' => [\n ['id' => 'id_1', 'label' => 'Option 1', 'displayOrder' => 0],\n ['id' => 'id_2', 'label' => 'Option 2', 'displayOrder' => 1],\n ['id' => 'id_3', 'label' => 'Option 3', 'displayOrder' => 2],\n ],\n ]));\n\n $this->assertEquals(\n [\n ['id' => 'id_1', 'label' => 'Option 1'],\n ['id' => 'id_2', 'label' => 'Option 2'],\n ['id' => 'id_3', 'label' => 'Option 3'],\n ],\n $this->client->fetchOpportunityPipelines()\n );\n }\n\n public function testGetPaginatedData(): void\n {\n $expectedResults = [\n ['id' => 'id_1', 'properties' => []],\n ['id' => 'id_2', 'properties' => []],\n ['id' => 'id_3', 'properties' => []],\n ];\n\n // Mock the pagination service to return a generator and modify reference parameters\n $this->paginationServiceMock\n ->expects($this->once())\n ->method('getPaginatedDataGenerator')\n ->with(\n $this->client,\n ['payload_key' => 'payload_value'],\n 'foobar',\n 0,\n $this->anything(),\n $this->anything()\n )\n ->willReturnCallback(function ($client, $payload, $type, $offset, &$total, &$lastRecordId) use ($expectedResults) {\n $total = 3;\n $lastRecordId = 'id_3';\n foreach ($expectedResults as $result) {\n yield $result;\n }\n });\n\n $this->assertEquals(\n [\n 'results' => $expectedResults,\n 'total' => 3,\n 'last_record' => 'id_3',\n ],\n $this->client->getPaginatedData(['payload_key' => 'payload_value'], 'foobar')\n );\n }\n\n public function testGetAssociationsData(): void\n {\n $ids = ['1', '2', '3'];\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $responseResults = [];\n foreach ($ids as $id) {\n $from = new PublicObjectId();\n $from->setId($id);\n\n $to1 = new PublicObjectId();\n $to1->setId('contact_' . $id . '_1');\n $to2 = new PublicObjectId();\n $to2->setId('contact_' . $id . '_2');\n\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to1, $to2]);\n\n $responseResults[] = $result;\n }\n\n $batchResponse = new BatchResponsePublicAssociationMulti();\n $batchResponse->setResults($responseResults);\n\n $this->associationsBatchApiMock->expects($this->once())\n ->method('read')\n ->with(\n $this->equalTo($fromObject),\n $this->equalTo($toObject),\n $this->callback(function (BatchInputPublicObjectId $batchInput) use ($ids) {\n $inputIds = array_map(\n fn ($input) => $input->getId(),\n $batchInput->getInputs()\n );\n\n return $inputIds === $ids;\n })\n )\n ->willReturn($batchResponse);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $expectedResult = [\n '1' => ['contact_1_1', 'contact_1_2'],\n '2' => ['contact_2_1', 'contact_2_2'],\n '3' => ['contact_3_1', 'contact_3_2'],\n ];\n\n $this->assertEquals($expectedResult, $result);\n }\n\n public function testGetAssociationsDataHandlesException(): void\n {\n $ids = ['1', '2', '3'];\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $exception = new \\Exception('API Error');\n\n $this->associationsBatchApiMock->expects($this->once())\n ->method('read')\n ->with(\n $this->equalTo($fromObject),\n $this->equalTo($toObject),\n $this->callback(function ($batchInput) use ($ids) {\n return $batchInput instanceof \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId\n && count($batchInput->getInputs()) === count($ids);\n })\n )\n ->willThrowException($exception);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with(\n '[Hubspot] Failed to fetch associations',\n [\n 'from_object' => $fromObject,\n 'to_object' => $toObject,\n 'reason' => 'API Error',\n ]\n );\n\n $this->client->setLogger($loggerMock);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $this->assertEmpty($result);\n $this->assertIsArray($result);\n }\n\n public function testGetAssociationsDataWithLargeDataSet(): void\n {\n $ids = array_map(fn ($i) => (string) $i, range(1, 2500)); // More than 1000 items\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $firstBatchResponse = new BatchResponsePublicAssociationMulti();\n $firstBatchResults = array_map(function ($id) {\n $from = new PublicObjectId();\n $from->setId($id);\n $to = new PublicObjectId();\n $to->setId('contact_' . $id);\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to]);\n\n return $result;\n }, array_slice($ids, 0, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));\n $firstBatchResponse->setResults($firstBatchResults);\n\n $secondBatchResponse = new BatchResponsePublicAssociationMulti();\n $secondBatchResults = array_map(function ($id) {\n $from = new PublicObjectId();\n $from->setId($id);\n $to = new PublicObjectId();\n $to->setId('contact_' . $id);\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to]);\n\n return $result;\n }, array_slice($ids, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));\n $secondBatchResponse->setResults($secondBatchResults);\n\n $this->associationsBatchApiMock->expects($this->exactly(3))\n ->method('read')\n ->willReturnOnConsecutiveCalls($firstBatchResponse, $secondBatchResponse);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $this->assertCount(2500, $result);\n $this->assertArrayHasKey('1', $result);\n $this->assertArrayHasKey('2500', $result);\n $this->assertEquals(['contact_1'], $result['1']);\n $this->assertEquals(['contact_2500'], $result['2500']);\n }\n\n public function testGetContactByEmailSuccess(): void\n {\n $email = 'test@example.com';\n $fields = ['firstname', 'lastname', 'email'];\n\n $contactMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $contactMock->method('getId')->willReturn('12345');\n $contactMock->method('getProperties')->willReturn([\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n 'email' => 'test@example.com',\n ]);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, 'firstname,lastname,email', null, false, 'email')\n ->willReturn($contactMock);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new NullLogger());\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([\n 'id' => '12345',\n 'properties' => [\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n 'email' => 'test@example.com',\n ],\n ], $result);\n }\n\n public function testGetContactByEmailWithEmptyFields(): void\n {\n $email = 'test@example.com';\n $fields = [];\n\n $contactMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $contactMock->method('getId')->willReturn('12345');\n $contactMock->method('getProperties')->willReturn(['email' => 'test@example.com']);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, '', null, false, 'email')\n ->willReturn($contactMock);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new NullLogger());\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([\n 'id' => '12345',\n 'properties' => ['email' => 'test@example.com'],\n ], $result);\n }\n\n public function testGetContactByEmailApiException(): void\n {\n $email = 'nonexistent@example.com';\n $fields = ['firstname', 'lastname'];\n\n $exception = new \\HubSpot\\Client\\Crm\\Contacts\\ApiException('Contact not found', 404);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, 'firstname,lastname', null, false, 'email')\n ->willThrowException($exception);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('info')\n ->with(\n '[Hubspot] Failed to fetch contact',\n [\n 'email' => $email,\n 'reason' => 'Contact not found',\n ]\n );\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger($loggerMock);\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([], $result);\n }\n\n public function testGetOpportunityById(): void\n {\n $opportunityId = '12345';\n $expectedProperties = [\n 'dealname' => 'Test Opportunity',\n 'amount' => '1000.00',\n 'closedate' => '2024-12-31T23:59:59.999Z',\n 'dealstage' => 'presentationscheduled',\n 'pipeline' => 'default',\n ];\n\n $mockHubspotOpportunity = $this->createMock(DealWithAssociations::class);\n $mockHubspotOpportunity->method('getProperties')->willReturn((object) $expectedProperties);\n $mockHubspotOpportunity->method('getId')->willReturn($opportunityId);\n $now = new \\DateTimeImmutable();\n $mockHubspotOpportunity->method('getCreatedAt')->willReturn($now);\n $mockHubspotOpportunity->method('getUpdatedAt')->willReturn($now);\n $mockHubspotOpportunity->method('getArchived')->willReturn(false);\n\n $this->dealsBasicApiMock\n ->expects($this->once())\n ->method('getById')\n ->willReturn($mockHubspotOpportunity);\n\n // Assuming Client::getOpportunityById processes the SimplePublicObject and returns an associative array.\n // The structure might be like: ['id' => ..., 'properties' => [...], 'createdAt' => ..., ...]\n // Adjust assertions below based on the actual return structure of your method.\n $result = $this->client->getOpportunityById($opportunityId, ['test', 'test']);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertEquals($opportunityId, $result['id']);\n }\n\n public function testGetContactById(): void\n {\n $crmId = 'contact-123';\n $fields = ['firstname', 'lastname'];\n $expectedProperties = ['firstname' => 'John', 'lastname' => 'Doe'];\n\n $mockContact = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $mockContact->method('getId')->willReturn($crmId);\n $mockContact->method('getProperties')->willReturn((object) $expectedProperties);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($crmId, 'firstname,lastname')\n ->willReturn($mockContact);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new \\Psr\\Log\\NullLogger());\n\n $result = $client->getContactById($crmId, $fields);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertArrayHasKey('properties', $result);\n $this->assertEquals($crmId, $result['id']);\n $this->assertEquals((object) $expectedProperties, $result['properties']);\n }\n\n public function testGetAccountById(): void\n {\n $crmId = 'account-123';\n $fields = ['name', 'industry'];\n $expectedProperties = ['name' => 'Acme Corp', 'industry' => 'Technology'];\n\n $mockCompany = $this->createMock(\\HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectWithAssociations::class);\n $mockCompany->method('getId')->willReturn($crmId);\n $mockCompany->method('getProperties')->willReturn((object) $expectedProperties);\n\n $companiesApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Companies\\Api\\BasicApi::class);\n $companiesApiMock->expects($this->once())\n ->method('getById')\n ->with($crmId, 'name,industry')\n ->willReturn($mockCompany);\n\n $companiesDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Companies\\Discovery::class);\n $companiesDiscoveryMock->method('__call')->with('basicApi')->willReturn($companiesApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($companiesDiscoveryMock) {\n if ($name === 'companies') {\n return $companiesDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new \\Psr\\Log\\NullLogger());\n\n $result = $client->getAccountById($crmId, $fields);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertArrayHasKey('properties', $result);\n $this->assertEquals($crmId, $result['id']);\n $this->assertEquals((object) $expectedProperties, $result['properties']);\n }\n\n public function testEnsureValidTokenWithNoTokenUpdate(): void\n {\n $socialAccountMock = $this->createMock(SocialAccount::class);\n\n // Set up OAuth account\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, $socialAccountMock);\n\n $originalToken = 'original_token';\n $accessTokenProperty = $reflection->getProperty('accessToken');\n $accessTokenProperty->setAccessible(true);\n $accessTokenProperty->setValue($this->client, $originalToken);\n\n // Mock token manager to return null (no refresh needed)\n $this->tokenManagerMock\n ->expects($this->once())\n ->method('ensureValidToken')\n ->with($socialAccountMock)\n ->willReturn(null);\n\n // Call ensureValidToken\n $this->client->ensureValidToken();\n\n // Verify access token was not changed\n $this->assertEquals($originalToken, $accessTokenProperty->getValue($this->client));\n }\n\n\n\n\n\n\n public function testGetPaginatedDataGeneratorDelegatesToPaginationService(): void\n {\n $payload = ['filters' => []];\n $type = 'contacts';\n $offset = 0;\n $total = 0;\n $lastRecordId = null;\n\n $expectedResults = [\n ['id' => 'id_1', 'properties' => []],\n ['id' => 'id_2', 'properties' => []],\n ];\n\n // Mock the pagination service to return a generator\n $this->paginationServiceMock\n ->expects($this->once())\n ->method('getPaginatedDataGenerator')\n ->with(\n $this->client,\n $payload,\n $type,\n $offset,\n $this->anything(),\n $this->anything()\n )\n ->willReturnCallback(function () use ($expectedResults) {\n foreach ($expectedResults as $result) {\n yield $result;\n }\n });\n\n // Execute the pagination\n $results = [];\n foreach ($this->client->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastRecordId) as $result) {\n $results[] = $result;\n }\n\n $this->assertCount(2, $results);\n $this->assertEquals('id_1', $results[0]['id']);\n $this->assertEquals('id_2', $results[1]['id']);\n }\n\n public function testEnsureValidTokenDelegatesToTokenManager(): void\n {\n $socialAccountMock = $this->createMock(SocialAccount::class);\n\n // Set up OAuth account\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, $socialAccountMock);\n\n // Mock token manager to return new token\n $this->tokenManagerMock\n ->expects($this->once())\n ->method('ensureValidToken')\n ->with($socialAccountMock)\n ->willReturn('new_access_token');\n\n // Call ensureValidToken\n $this->client->ensureValidToken();\n\n // Verify access token was updated\n $accessTokenProperty = $reflection->getProperty('accessToken');\n $accessTokenProperty->setAccessible(true);\n $this->assertEquals('new_access_token', $accessTokenProperty->getValue($this->client));\n }\n\n public function testGetOwnersArchivedWithValidResponse(): void\n {\n $responseData = [\n 'results' => [\n [\n 'id' => '123',\n 'email' => 'owner1@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'John',\n 'lastName' => 'Doe',\n 'userId' => 456,\n 'userIdIncludingInactive' => 789,\n 'createdAt' => '2023-01-01T12:00:00Z',\n 'updatedAt' => '2023-01-02T12:00:00Z',\n 'archived' => true,\n ],\n ],\n ];\n\n // Create a mock response object\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn($responseData);\n\n // Set up the client to return our test data\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=true'\n )\n ->willReturn($response);\n\n // Call the method\n $result = $this->client->getOwnersArchived(true);\n\n // Assert the results\n $this->assertCount(1, $result);\n $this->assertEquals('123', $result[0]->getId());\n $this->assertEquals('owner1@example.com', $result[0]->getEmail());\n $this->assertEquals('John Doe', $result[0]->getFullName());\n $this->assertTrue($result[0]->isArchived());\n }\n\n public function testGetOwnersArchivedWithEmptyResponse(): void\n {\n // Create a mock response object with empty results\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn(['results' => []]);\n\n // Set up the client to return empty results\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=false'\n )\n ->willReturn($response);\n\n // Call the method\n $result = $this->client->getOwnersArchived(false);\n\n // Assert the results\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithInvalidResponse(): void\n {\n // Create a mock response that will throw an exception when toArray is called\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willThrowException(new \\InvalidArgumentException('Invalid JSON'));\n\n // Set up the client to return the problematic response\n $this->client->method('makeRequest')\n ->willReturn($response);\n\n // Mock the logger to expect an error message\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with($this->stringContains('Failed to fetch owners'));\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n // Call the method and expect an empty array on error\n $result = $this->client->getOwnersArchived(true);\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithHttpError(): void\n {\n // Set up the client to throw an exception\n $this->client->method('makeRequest')\n ->willThrowException(new \\Exception('HTTP Error'));\n\n // Mock the logger to expect an error message\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with($this->stringContains('Failed to fetch owners'));\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n // Call the method and expect an empty array on error\n $result = $this->client->getOwnersArchived(false);\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithOwnerCreationException(): void\n {\n $responseData = [\n 'results' => [\n [\n 'id' => '123',\n 'email' => 'valid@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'John',\n 'lastName' => 'Doe',\n 'userId' => 456,\n 'userIdIncludingInactive' => 789,\n 'createdAt' => '2023-01-01T12:00:00Z',\n 'updatedAt' => '2023-01-02T12:00:00Z',\n 'archived' => false,\n ],\n [\n 'id' => '456',\n 'email' => 'invalid@example.com',\n 'type' => 'PERSON',\n 'createdAt' => 'invalid-date-format',\n ],\n [\n 'id' => '789',\n 'email' => 'another@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'Jane',\n 'lastName' => 'Smith',\n 'userId' => 999,\n 'userIdIncludingInactive' => 888,\n 'createdAt' => '2023-01-03T12:00:00Z',\n 'updatedAt' => '2023-01-04T12:00:00Z',\n 'archived' => false,\n ],\n ],\n ];\n\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn($responseData);\n\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=false'\n )\n ->willReturn($response);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with(\n '[HubSpot] Failed to process owner data',\n $this->callback(function ($context) {\n return isset($context['result']) &&\n isset($context['error']) &&\n $context['result']['id'] === '456' &&\n $context['result']['email'] === 'invalid@example.com' &&\n str_contains($context['error'], 'invalid-date-format');\n })\n );\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n $result = $this->client->getOwnersArchived(false);\n\n $this->assertIsArray($result);\n $this->assertCount(2, $result);\n $this->assertEquals('123', $result[0]->getId());\n $this->assertEquals('valid@example.com', $result[0]->getEmail());\n $this->assertEquals('789', $result[1]->getId());\n $this->assertEquals('another@example.com', $result[1]->getEmail());\n }\n\n public function testMakeRequestWithGetMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'GET',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n [],\n null,\n true\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'GET');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithGetMethodAndQueryString(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'GET',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n [],\n 'limit=10&offset=0',\n true\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'GET', [], 'limit=10&offset=0');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPostMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'POST',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'POST', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPutMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'firstname' => 'Jane',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345', 'updated' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'PUT',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'PUT', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPatchMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'email' => 'newemail@example.com',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345', 'patched' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'PATCH',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'PATCH', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithDeleteMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['deleted' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'DELETE',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => []]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'DELETE');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithEmptyPayload(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'ok']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'POST',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n ['json' => []]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'POST', []);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestPrependsBaseUrl(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n $this->anything(),\n 'https://api.hubapi.com/test/endpoint',\n $this->anything()\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $client->makeRequest('/test/endpoint', 'GET');\n }\n\n public function testGetOpportunitiesByIds(): void\n {\n $crmIds = ['deal1', 'deal2', 'deal3'];\n $fields = ['dealname', 'amount'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getOpportunitiesByIds'));\n }\n\n public function testGetCompaniesByIds(): void\n {\n $crmIds = ['company1', 'company2'];\n $fields = ['name', 'industry'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getCompaniesByIds'));\n }\n\n public function testGetContactsByIds(): void\n {\n $crmIds = ['contact1', 'contact2'];\n $fields = ['firstname', 'lastname'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getContactsByIds'));\n }\n\n public function testBatchReadObjectsEmptyIds(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $result = $method->invokeArgs($this->client, ['deals', [], ['dealname']]);\n\n $this->assertEquals([], $result);\n }\n\n public function testBatchReadObjectsExceedsBatchSize(): void\n {\n $crmIds = array_fill(0, 101, 'deal_id'); // 101 IDs, exceeds limit of 100\n\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $this->expectException(\\InvalidArgumentException::class);\n $this->expectExceptionMessage('Batch size cannot exceed 100 deals');\n\n $method->invokeArgs($this->client, ['deals', $crmIds, ['dealname']]);\n }\n\n public function testBatchReadObjectsUnsupportedObjectType(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $this->expectException(\\Jiminny\\Exceptions\\CrmException::class);\n $this->expectExceptionMessage('Failed to batch fetch unsupported');\n\n $method->invokeArgs($this->client, ['unsupported', ['id1'], ['field1']]);\n }\n\n public function testIsUnauthorizedExceptionWithBadRequest401(): void\n {\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\BadRequest('Unauthorized', 401);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithBadRequestNon401(): void\n {\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\BadRequest('Bad Request', 400);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertFalse($result);\n }\n\n public function testIsUnauthorizedExceptionWithGuzzleRequestException(): void\n {\n $response = new Response(401, [], 'Unauthorized');\n $exception = new \\GuzzleHttp\\Exception\\RequestException('Unauthorized', new \\GuzzleHttp\\Psr7\\Request('GET', 'test'), $response);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithGuzzleClientException(): void\n {\n $exception = new \\GuzzleHttp\\Exception\\ClientException('Unauthorized', new \\GuzzleHttp\\Psr7\\Request('GET', 'test'), new Response(401));\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithStringMatching(): void\n {\n $exception = new \\Exception('HTTP 401 Unauthorized error occurred');\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithNonUnauthorized(): void\n {\n $exception = new \\Exception('Some other error');\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertFalse($result);\n }\n\n public function testEnsureValidTokenWithNullOauthAccount(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, null);\n\n // Should not throw exception and not call token manager\n $this->tokenManagerMock->expects($this->never())->method('ensureValidToken');\n\n $this->client->ensureValidToken();\n\n $this->assertTrue(true); // Test passes if no exception is thrown\n }\n\n public function testGetConfig(): void\n {\n $result = $this->client->getConfig();\n\n $this->assertSame($this->config, $result);\n }\n\n public function testGetOwners(): void\n {\n // Test that the method exists and can be called\n $this->assertTrue(method_exists($this->client, 'getOwners'));\n\n // Since getOwners has complex HubSpot API dependencies,\n // we'll just verify the method exists for now\n $this->assertIsCallable([$this->client, 'getOwners']);\n }\n\n public function testCreateMeeting(): void\n {\n $payload = [\n 'properties' => [\n 'hs_meeting_title' => 'Test Meeting',\n 'hs_meeting_start_time' => '2024-01-01T10:00:00Z',\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['id' => 'meeting123']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with('/crm/v3/objects/meetings', 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->createMeeting($payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testUpdateMeeting(): void\n {\n $meetingId = 'meeting123';\n $payload = [\n 'properties' => [\n 'hs_meeting_title' => 'Updated Meeting',\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['id' => $meetingId, 'updated' => true]);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v3/objects/meetings/{$meetingId}\", 'PATCH', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->updateMeeting($meetingId, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testAddAssociations(): void\n {\n $objectType = 'deals';\n $associationType = 'contacts';\n $payload = [\n 'inputs' => [\n ['from' => ['id' => 'deal1'], 'to' => ['id' => 'contact1']],\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['status' => 'success']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v4/associations/{$objectType}/{$associationType}/batch/create\", 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->addAssociations($objectType, $associationType, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testRemoveAssociations(): void\n {\n $objectType = 'deals';\n $associationType = 'contacts';\n $payload = [\n 'inputs' => [\n ['from' => ['id' => 'deal1'], 'to' => ['id' => 'contact1']],\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['status' => 'success']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v4/associations/{$objectType}/{$associationType}/batch/archive\", 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->removeAssociations($objectType, $associationType, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n // -------------------------------------------------------------------------\n // search() / executeRequest() tests\n // -------------------------------------------------------------------------\n\n public function testSearchReturnsDecodedArrayOnSuccess(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n\n $this->config->method('getId')->willReturn(42);\n\n $payload = ['filters' => []];\n $responseData = ['results' => [['id' => '1']], 'total' => 1, 'paging' => []];\n $hubspotResponse = new HubspotResponse(new Response(200, [], json_encode($responseData)));\n\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->with('POST', Client::BASE_URL . '/crm/v3/objects/contacts/search', ['json' => $payload])\n ->willReturn($hubspotResponse);\n\n $result = $this->client->search('contacts', $payload);\n\n $this->assertSame($responseData, $result);\n\n Mockery::close();\n }\n\n public function testSearchThrowsRateLimitExceptionWhenCircuitBreakerActive(): void\n {\n $futureTimestamp = (string) (time() + 30);\n\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn($futureTimestamp);\n\n $this->config->method('getId')->willReturn(42);\n\n $this->hubspotClientMock->expects($this->never())->method('request');\n\n $this->expectException(RateLimitException::class);\n $this->expectExceptionMessage('Hubspot rate limit (cached circuit-breaker)');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchThrowsRateLimitExceptionAndSetsNxOnFresh429(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n // NX + TTL option array — exact TTL depends on parseRetryAfter, verified separately\n $redisMock->shouldReceive('set')\n ->once()\n ->with(\n 'hubspot:ratelimit:config:42',\n Mockery::type('string'),\n Mockery::on(fn ($opts) => is_array($opts) && in_array('nx', $opts, true))\n );\n\n $this->config->method('getId')->willReturn(42);\n\n $badRequest = new BadRequest('Rate limit exceeded', 429);\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->willThrowException($badRequest);\n\n $this->expectException(RateLimitException::class);\n $this->expectExceptionMessage('Hubspot returned 429');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchPropagatesNonRateLimitException(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n\n $this->config->method('getId')->willReturn(42);\n\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\HubspotException('Server error', 500);\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->willThrowException($exception);\n\n $this->expectException(\\SevenShores\\Hubspot\\Exceptions\\HubspotException::class);\n $this->expectExceptionMessage('Server error');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchCircuitBreakerRetryAfterComputedFromStoredTimestamp(): void\n {\n $remaining = 45;\n $futureTimestamp = (string) (time() + $remaining);\n\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn($futureTimestamp);\n\n $this->config->method('getId')->willReturn(1);\n\n try {\n $this->client->search('deals', []);\n $this->fail('Expected RateLimitException');\n } catch (RateLimitException $e) {\n $this->assertGreaterThanOrEqual(1, $e->getRetryAfter());\n $this->assertLessThanOrEqual($remaining, $e->getRetryAfter());\n } finally {\n Mockery::close();\n }\n }\n\n // -------------------------------------------------------------------------\n // isHubspotRateLimit() tests (private method — tested via reflection)\n // -------------------------------------------------------------------------\n\n private function callIsHubspotRateLimit(\\Throwable $e): bool\n {\n $method = new \\ReflectionMethod($this->client, 'isHubspotRateLimit');\n $method->setAccessible(true);\n\n return $method->invoke($this->client, $e);\n }\n\n public function testIsHubspotRateLimitReturnsTrueForBadRequest429(): void\n {\n $e = new BadRequest('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForDealApiException429(): void\n {\n $e = new DealApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForContactApiException429(): void\n {\n $e = new ContactApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForCompanyApiException429(): void\n {\n $e = new CompanyApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForGuzzleRequestException429(): void\n {\n $request = new \\GuzzleHttp\\Psr7\\Request('POST', 'https://api.hubapi.com/test');\n $response = new \\GuzzleHttp\\Psr7\\Response(429);\n $e = new \\GuzzleHttp\\Exception\\RequestException('Too Many Requests', $request, $response);\n\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsFalseForBadRequestNon429(): void\n {\n $e = new BadRequest('Bad Request', 400);\n $this->assertFalse($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsFalseForGenericException(): void\n {\n $e = new \\RuntimeException('Something went wrong');\n $this->assertFalse($this->callIsHubspotRateLimit($e));\n }\n\n // -------------------------------------------------------------------------\n // parseRetryAfter() tests (private method — tested via reflection)\n // -------------------------------------------------------------------------\n\n private function callParseRetryAfter(\\Throwable $e): int\n {\n $method = new \\ReflectionMethod($this->client, 'parseRetryAfter');\n $method->setAccessible(true);\n\n return $method->invoke($this->client, $e);\n }\n\n public function testParseRetryAfterReadsRetryAfterHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => ['30']]);\n $this->assertSame(30, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReadsLowercaseRetryAfterHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['retry-after' => ['15']]);\n $this->assertSame(15, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterHandlesScalarHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => '60']);\n $this->assertSame(60, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsDailyLimitDelay(): void\n {\n $e = new BadRequest('Daily rate limit exceeded');\n $this->assertSame(600, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsTenSecondlyDelay(): void\n {\n $e = new BadRequest('Ten secondly rate limit exceeded');\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsSecondlyDelay(): void\n {\n $e = new BadRequest('Secondly rate limit exceeded');\n $this->assertSame(1, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsDefaultWhenNoHint(): void\n {\n $e = new BadRequest('Rate limit exceeded');\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterIgnoresNonNumericHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => ['not-a-number']]);\n // Falls through to message parsing; \"Too Many Requests\" has no known keyword → default 10\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n}","depth":4,"bounds":{"left":0.124667555,"top":0.17158818,"width":0.42852393,"height":0.8284118},"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Hubspot;\n\nuse GuzzleHttp\\Psr7\\Response;\nuse HubSpot\\Client\\Crm\\Associations\\Api\\BatchApi;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\BatchResponsePublicAssociationMulti;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\PublicObjectId;\nuse HubSpot\\Client\\Crm\\Companies\\ApiException as CompanyApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\ApiException as ContactApiException;\nuse HubSpot\\Client\\Crm\\Deals\\Api\\BasicApi as DealsBasicApi;\nuse HubSpot\\Client\\Crm\\Deals\\ApiException as DealApiException;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectWithAssociations as DealWithAssociations;\nuse HubSpot\\Client\\Crm\\Pipelines\\Api\\PipelinesApi;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\CollectionResponsePipeline;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Error;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Pipeline;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\PipelineStage;\nuse HubSpot\\Client\\Crm\\Properties\\Api\\CoreApi;\nuse HubSpot\\Client\\Crm\\Properties\\Model\\Property;\nuse HubSpot\\Discovery as HubSpotDiscovery;\nuse HubSpot\\Discovery\\Crm\\Deals\\Discovery as DealsDiscovery;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\HubspotTokenManager;\nuse Jiminny\\Services\\Crm\\Hubspot\\Pagination\\HubspotPaginationService;\nuse Jiminny\\Services\\SocialAccountService;\nuse League\\OAuth2\\Client\\Token\\AccessToken;\nuse Mockery;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse PHPUnit\\Framework\\TestCase;\nuse Psr\\Log\\NullLogger;\nuse SevenShores\\Hubspot\\Endpoints\\Engagements;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Factory;\nuse SevenShores\\Hubspot\\Http\\Client as HubspotClient;\nuse SevenShores\\Hubspot\\Http\\Response as HubspotResponse;\n\n/**\n * @runTestsInSeparateProcesses\n *\n * @preserveGlobalState disabled\n */\nclass ClientTest extends TestCase\n{\n private const string RESPONSE_TYPE_STAGE_FIELD = 'stage';\n private const string RESPONSE_TYPE_PIPELINE_FIELD = 'pipeline';\n private const string RESPONSE_TYPE_REGULAR_FIELD = 'regular';\n /**\n * @var Client&MockObject\n */\n private Client $client;\n private Configuration $config;\n\n /**\n * @var SocialAccountService&MockObject\n */\n private SocialAccountService $socialAccountServiceMock;\n\n /**\n * @var HubspotPaginationService&MockObject\n */\n private HubspotPaginationService $paginationServiceMock;\n\n /**\n * @var HubspotTokenManager&MockObject\n */\n private HubspotTokenManager $tokenManagerMock;\n\n /**\n * @var CoreApi&MockObject\n */\n private CoreApi $coreApiMock;\n\n /**\n * @var PipelinesApi&MockObject\n */\n private PipelinesApi $pipelinesApiMock;\n\n /**\n * @var BatchApi&MockObject\n */\n private BatchApi $associationsBatchApiMock;\n\n /**\n * @var DealsBasicApi&MockObject\n */\n private DealsBasicApi $dealsBasicApiMock;\n\n /**\n * @var Engagements&MockObject\n */\n private $engagementsMock;\n\n /**\n * @var HubspotClient&MockObject\n */\n private HubspotClient $hubspotClientMock;\n\n protected function setUp(): void\n {\n // Create mocks for dependencies\n $this->socialAccountServiceMock = $this->createMock(SocialAccountService::class);\n $this->paginationServiceMock = $this->createMock(HubspotPaginationService::class);\n $this->tokenManagerMock = $this->createMock(HubspotTokenManager::class);\n\n // Create a real Client instance with mocked dependencies\n // Create a partial mock only for the methods we need to mock\n $client = $this->createPartialMock(Client::class, ['getInstance', 'getNewInstance', 'makeRequest']);\n $client->setLogger(new NullLogger());\n\n // Inject the real dependencies using reflection\n $reflection = new \\ReflectionClass(Client::class);\n\n $accountServiceProperty = $reflection->getProperty('accountService');\n $accountServiceProperty->setAccessible(true);\n $accountServiceProperty->setValue($client, $this->socialAccountServiceMock);\n\n $paginationServiceProperty = $reflection->getProperty('paginationService');\n $paginationServiceProperty->setAccessible(true);\n $paginationServiceProperty->setValue($client, $this->paginationServiceMock);\n\n $tokenManagerProperty = $reflection->getProperty('tokenManager');\n $tokenManagerProperty->setAccessible(true);\n $tokenManagerProperty->setValue($client, $this->tokenManagerMock);\n $factoryMock = $this->createMock(Factory::class);\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $engagementsMock = $this->createMock(\\SevenShores\\Hubspot\\Endpoints\\Engagements::class);\n\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n $factoryMock->method('__call')->with('engagements')->willReturn($engagementsMock);\n\n $client->method('getInstance')->willReturn($factoryMock);\n\n $discoveryMock = $this->createMock(HubSpotDiscovery\\Discovery::class);\n $crmMock = $this->createMock(HubSpotDiscovery\\Crm\\Discovery::class);\n $associationsMock = $this->createMock(HubSpotDiscovery\\Crm\\Associations\\Discovery::class);\n $propertiesMock = $this->createMock(HubSpotDiscovery\\Crm\\Properties\\Discovery::class);\n $pipelinesMock = $this->createMock(HubSpotDiscovery\\Crm\\Pipelines\\Discovery::class);\n $coreApiMock = $this->createMock(CoreApi::class);\n $pipelinesApiMock = $this->createMock(PipelinesApi::class);\n $associationsBatchApiMock = $this->createMock(BatchApi::class);\n $dealsBasicApiMock = $this->createMock(DealsBasicApi::class);\n\n $propertiesMock->method('__call')->with('coreApi')->willReturn($coreApiMock);\n $pipelinesMock->method('__call')->with('pipelinesApi')->willReturn($pipelinesApiMock);\n $associationsMock->method('__call')->with('batchApi')->willReturn($associationsBatchApiMock);\n $dealsDiscoveryMock = $this->createMock(DealsDiscovery::class);\n $dealsDiscoveryMock->method('__call')->with('basicApi')->willReturn($dealsBasicApiMock);\n\n $returnMap = ['properties' => $propertiesMock, 'pipelines' => $pipelinesMock, 'associations' => $associationsMock, 'deals' => $dealsDiscoveryMock];\n $crmMock->method('__call')\n ->willReturnCallback(static fn (string $name) => $returnMap[$name])\n ;\n\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n\n $this->client = $client;\n $this->config = $this->createMock(Configuration::class);\n $this->client->setConfiguration($this->config);\n $this->coreApiMock = $coreApiMock;\n $this->pipelinesApiMock = $pipelinesApiMock;\n $this->associationsBatchApiMock = $associationsBatchApiMock;\n $this->dealsBasicApiMock = $dealsBasicApiMock;\n $this->engagementsMock = $engagementsMock;\n $this->hubspotClientMock = $hubspotClientMock;\n }\n\n public function testGetMinimumApiVersion(): void\n {\n $this->assertIsString($this->client->getMinimumApiVersion());\n }\n\n public function testGetInstance(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $client = new Client($socialAccountService, $paginationService, $tokenManager);\n $client->setBaseUrl('https://example.com');\n $client->setAccessToken(new AccessToken(['access_token' => 'foo']));\n $factory = $client->getInstance();\n\n $this->assertEquals('foo', $factory->getClient()->key);\n }\n\n public function testGetNewInstance(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $client = new Client($socialAccountService, $paginationService, $tokenManager);\n $client->setAccessToken(new AccessToken(['access_token' => 'foo']));\n\n $factory = $client->getNewInstance();\n $token = $factory->auth()->oAuth()->accessTokensApi()->getConfig()->getAccessToken();\n $this->assertEquals('foo', $token);\n }\n\n public function testFetchProperty(): void\n {\n $this->coreApiMock->method('getByName')->willReturn($this->generateProperty());\n\n $this->assertEquals(\n 'some_property',\n $this->client->fetchProperty('foo', 'test')['name']\n );\n }\n\n public function testFetchPropertyOptions(): void\n {\n $this->coreApiMock->method('getByName')->willReturn($this->generateProperty());\n\n $this->assertEquals(\n [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n $this->client->fetchPropertyOptions('foo', 'test')\n );\n }\n\n public function testFetchCallDispositions(): void\n {\n $this->engagementsMock\n ->method('getCallDispositions')\n ->willReturn($this->generateHubSpotResponse([\n [\n 'id' => 1,\n 'label' => 'foo',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'bar',\n 'deleted' => false,\n ],\n ]))\n ;\n\n $this->assertEquals(\n [\n [\n 'id' => 1,\n 'label' => 'foo',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'bar',\n 'deleted' => false,\n ],\n ],\n $this->client->fetchCallDispositions()\n );\n }\n\n public function testFetchDispositionFieldOptions(): void\n {\n $this->engagementsMock->method('getCallDispositions')\n ->willReturn($this->generateHubSpotResponse(\n [\n [\n 'id' => 1,\n 'label' => 'Label 1',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'Label 2',\n 'deleted' => true,\n ],\n ]\n ))\n ;\n\n $this->assertEquals(\n [\n [\n 'value' => 1,\n 'id' => 1,\n 'label' => 'Label 1',\n ],\n ],\n $this->client->fetchDispositionFieldOptions()\n );\n }\n\n public function testFetchOpportunityPipelineStages(): void\n {\n /**\n * @TODO: The class CollectionResponsePipeline will be deprecated in the next Hubspot version\n */\n $this->pipelinesApiMock\n ->method('getAll')\n ->with('deals')\n ->willReturn(new CollectionResponsePipeline([\n 'results' => [$this->generatePipeline()],\n ]))\n ;\n\n $this->assertEquals(\n [\n ['id' => 'foo', 'label' => 'bar'],\n ['id' => 'baz', 'label' => 'qux'],\n ],\n $this->client->fetchOpportunityPipelineStages()\n );\n }\n\n public function testFetchOpportunityPipelineStagesErrorResponse(): void\n {\n $this->pipelinesApiMock\n ->method('getAll')\n ->with('deals')\n ->willReturn(new Error(['message' => 'test error']))\n ;\n\n $this->assertEmpty($this->client->fetchOpportunityPipelineStages());\n }\n\n #[\\PHPUnit\\Framework\\Attributes\\DataProvider('meetingOutcomeFieldProvider')]\n public function testFetchMeetingOutcomeFieldOptions(string $fieldId, string $expectedEndpoint): void\n {\n $field = new Field(['crm_provider_id' => $fieldId]);\n\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->with('GET', $expectedEndpoint)\n ->willReturn($this->generateHubSpotResponse(\n [\n 'options' => [\n ['value' => 'option_1', 'label' => 'Option 1', 'displayOrder' => 0],\n ['value' => 'option_2', 'label' => 'Option 2', 'displayOrder' => 1],\n ['value' => 'option_3', 'label' => 'Option 3', 'displayOrder' => 2],\n ],\n ]\n ))\n ;\n\n $this->assertEquals(\n [\n ['id' => 'option_1', 'value' => 'option_1', 'label' => 'Option 1', 'display_order' => 0],\n ['id' => 'option_2', 'value' => 'option_2', 'label' => 'Option 2', 'display_order' => 1],\n ['id' => 'option_3', 'value' => 'option_3', 'label' => 'Option 3', 'display_order' => 2],\n ],\n $this->client->fetchMeetingOutcomeFieldOptions($field)\n );\n }\n\n public static function meetingOutcomeFieldProvider(): array\n {\n return [\n 'meeting outcome field' => [\n 'meetingOutcome',\n 'https://api.hubapi.com/crm/v3/properties/meeting/hs_meeting_outcome',\n ],\n 'activity type field' => [\n 'foobar',\n 'https://api.hubapi.com/crm/v3/properties/call/hs_activity_type',\n ],\n ];\n }\n\n #[\\PHPUnit\\Framework\\Attributes\\DataProvider('opportunityFieldOptionsProvider')]\n public function testFetchOpportunityFieldOptions(Field $field, string $type): void\n {\n $responses = [\n self::RESPONSE_TYPE_STAGE_FIELD => [\n ['id' => 'foo', 'label' => 'bar'],\n ['id' => 'baz', 'label' => 'qux'],\n ],\n self::RESPONSE_TYPE_PIPELINE_FIELD => [\n ['id' => '123', 'label' => 'Sales'],\n ['id' => 'default', 'label' => 'CS'],\n ],\n self::RESPONSE_TYPE_REGULAR_FIELD => [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n ];\n\n $field = $this->createMock(Field::class);\n\n if ($type === self::RESPONSE_TYPE_REGULAR_FIELD) {\n $field->method('isStageField')->willReturn(false);\n $field->method('isPipelineField')->willReturn(false);\n $propertyResponse = $this->generateProperty();\n $this->coreApiMock->method('getByName')->willReturn($propertyResponse);\n }\n\n if ($type === self::RESPONSE_TYPE_STAGE_FIELD) {\n $field->method('isStageField')->willReturn(true);\n /**\n * @TODO: The class CollectionResponsePipeline will be deprecated in the next Hubspot version\n */\n\n $pipelineStagesResponse = new CollectionResponsePipeline([\n 'results' => [$this->generatePipeline()],\n ]);\n $this->pipelinesApiMock\n ->method('getAll')\n ->willReturn($pipelineStagesResponse);\n }\n\n if ($type === self::RESPONSE_TYPE_PIPELINE_FIELD) {\n $field->method('isStageField')->willReturn(false);\n $field->method('isPipelineField')->willReturn(true);\n $pipelineResponse = $this->generateHubSpotResponse([\n 'results' => [\n ['id' => '123', 'label' => 'Sales'],\n ['id' => 'default', 'label' => 'CS'],\n ],\n ]);\n $this->client\n ->method('makeRequest')\n ->with('/crm/v3/pipelines/deals')\n ->willReturn($pipelineResponse);\n }\n\n $this->assertEquals(\n $responses[$type],\n $this->client->fetchOpportunityFieldOptions($field)\n );\n }\n\n public static function opportunityFieldOptionsProvider(): array\n {\n return [\n 'stage field' => [\n 'field' => new Field(['crm_provider_id' => 'dealstage']),\n 'type' => self::RESPONSE_TYPE_STAGE_FIELD,\n ],\n 'pipeline field' => [\n 'field' => new Field(['crm_provider_id' => 'pipeline']),\n 'type' => self::RESPONSE_TYPE_PIPELINE_FIELD,\n ],\n 'regular field' => [\n 'field' => new Field(['crm_provider_id' => 'some_property']),\n 'type' => self::RESPONSE_TYPE_REGULAR_FIELD,\n ],\n ];\n }\n\n private function generateHubSpotResponse(array $data): HubspotResponse\n {\n return new HubspotResponse(new Response(200, [], json_encode($data)));\n }\n\n private function generateProperty(): Property\n {\n return new Property([\n 'name' => 'some_property',\n 'options' => [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n ]);\n }\n\n private function generatePipeline(): Pipeline\n {\n return new Pipeline(['stages' => [\n new PipelineStage(['id' => 'foo', 'label' => 'bar']),\n new PipelineStage(['id' => 'baz', 'label' => 'qux']),\n ]]);\n }\n\n public function testFetchOpportunityPipelines(): void\n {\n $this->client\n ->method('makeRequest')\n ->with('/crm/v3/pipelines/deals')\n ->willReturn($this->generateHubSpotResponse([\n 'results' => [\n ['id' => 'id_1', 'label' => 'Option 1', 'displayOrder' => 0],\n ['id' => 'id_2', 'label' => 'Option 2', 'displayOrder' => 1],\n ['id' => 'id_3', 'label' => 'Option 3', 'displayOrder' => 2],\n ],\n ]));\n\n $this->assertEquals(\n [\n ['id' => 'id_1', 'label' => 'Option 1'],\n ['id' => 'id_2', 'label' => 'Option 2'],\n ['id' => 'id_3', 'label' => 'Option 3'],\n ],\n $this->client->fetchOpportunityPipelines()\n );\n }\n\n public function testGetPaginatedData(): void\n {\n $expectedResults = [\n ['id' => 'id_1', 'properties' => []],\n ['id' => 'id_2', 'properties' => []],\n ['id' => 'id_3', 'properties' => []],\n ];\n\n // Mock the pagination service to return a generator and modify reference parameters\n $this->paginationServiceMock\n ->expects($this->once())\n ->method('getPaginatedDataGenerator')\n ->with(\n $this->client,\n ['payload_key' => 'payload_value'],\n 'foobar',\n 0,\n $this->anything(),\n $this->anything()\n )\n ->willReturnCallback(function ($client, $payload, $type, $offset, &$total, &$lastRecordId) use ($expectedResults) {\n $total = 3;\n $lastRecordId = 'id_3';\n foreach ($expectedResults as $result) {\n yield $result;\n }\n });\n\n $this->assertEquals(\n [\n 'results' => $expectedResults,\n 'total' => 3,\n 'last_record' => 'id_3',\n ],\n $this->client->getPaginatedData(['payload_key' => 'payload_value'], 'foobar')\n );\n }\n\n public function testGetAssociationsData(): void\n {\n $ids = ['1', '2', '3'];\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $responseResults = [];\n foreach ($ids as $id) {\n $from = new PublicObjectId();\n $from->setId($id);\n\n $to1 = new PublicObjectId();\n $to1->setId('contact_' . $id . '_1');\n $to2 = new PublicObjectId();\n $to2->setId('contact_' . $id . '_2');\n\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to1, $to2]);\n\n $responseResults[] = $result;\n }\n\n $batchResponse = new BatchResponsePublicAssociationMulti();\n $batchResponse->setResults($responseResults);\n\n $this->associationsBatchApiMock->expects($this->once())\n ->method('read')\n ->with(\n $this->equalTo($fromObject),\n $this->equalTo($toObject),\n $this->callback(function (BatchInputPublicObjectId $batchInput) use ($ids) {\n $inputIds = array_map(\n fn ($input) => $input->getId(),\n $batchInput->getInputs()\n );\n\n return $inputIds === $ids;\n })\n )\n ->willReturn($batchResponse);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $expectedResult = [\n '1' => ['contact_1_1', 'contact_1_2'],\n '2' => ['contact_2_1', 'contact_2_2'],\n '3' => ['contact_3_1', 'contact_3_2'],\n ];\n\n $this->assertEquals($expectedResult, $result);\n }\n\n public function testGetAssociationsDataHandlesException(): void\n {\n $ids = ['1', '2', '3'];\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $exception = new \\Exception('API Error');\n\n $this->associationsBatchApiMock->expects($this->once())\n ->method('read')\n ->with(\n $this->equalTo($fromObject),\n $this->equalTo($toObject),\n $this->callback(function ($batchInput) use ($ids) {\n return $batchInput instanceof \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId\n && count($batchInput->getInputs()) === count($ids);\n })\n )\n ->willThrowException($exception);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with(\n '[Hubspot] Failed to fetch associations',\n [\n 'from_object' => $fromObject,\n 'to_object' => $toObject,\n 'reason' => 'API Error',\n ]\n );\n\n $this->client->setLogger($loggerMock);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $this->assertEmpty($result);\n $this->assertIsArray($result);\n }\n\n public function testGetAssociationsDataWithLargeDataSet(): void\n {\n $ids = array_map(fn ($i) => (string) $i, range(1, 2500)); // More than 1000 items\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $firstBatchResponse = new BatchResponsePublicAssociationMulti();\n $firstBatchResults = array_map(function ($id) {\n $from = new PublicObjectId();\n $from->setId($id);\n $to = new PublicObjectId();\n $to->setId('contact_' . $id);\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to]);\n\n return $result;\n }, array_slice($ids, 0, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));\n $firstBatchResponse->setResults($firstBatchResults);\n\n $secondBatchResponse = new BatchResponsePublicAssociationMulti();\n $secondBatchResults = array_map(function ($id) {\n $from = new PublicObjectId();\n $from->setId($id);\n $to = new PublicObjectId();\n $to->setId('contact_' . $id);\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to]);\n\n return $result;\n }, array_slice($ids, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));\n $secondBatchResponse->setResults($secondBatchResults);\n\n $this->associationsBatchApiMock->expects($this->exactly(3))\n ->method('read')\n ->willReturnOnConsecutiveCalls($firstBatchResponse, $secondBatchResponse);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $this->assertCount(2500, $result);\n $this->assertArrayHasKey('1', $result);\n $this->assertArrayHasKey('2500', $result);\n $this->assertEquals(['contact_1'], $result['1']);\n $this->assertEquals(['contact_2500'], $result['2500']);\n }\n\n public function testGetContactByEmailSuccess(): void\n {\n $email = 'test@example.com';\n $fields = ['firstname', 'lastname', 'email'];\n\n $contactMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $contactMock->method('getId')->willReturn('12345');\n $contactMock->method('getProperties')->willReturn([\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n 'email' => 'test@example.com',\n ]);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, 'firstname,lastname,email', null, false, 'email')\n ->willReturn($contactMock);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new NullLogger());\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([\n 'id' => '12345',\n 'properties' => [\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n 'email' => 'test@example.com',\n ],\n ], $result);\n }\n\n public function testGetContactByEmailWithEmptyFields(): void\n {\n $email = 'test@example.com';\n $fields = [];\n\n $contactMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $contactMock->method('getId')->willReturn('12345');\n $contactMock->method('getProperties')->willReturn(['email' => 'test@example.com']);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, '', null, false, 'email')\n ->willReturn($contactMock);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new NullLogger());\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([\n 'id' => '12345',\n 'properties' => ['email' => 'test@example.com'],\n ], $result);\n }\n\n public function testGetContactByEmailApiException(): void\n {\n $email = 'nonexistent@example.com';\n $fields = ['firstname', 'lastname'];\n\n $exception = new \\HubSpot\\Client\\Crm\\Contacts\\ApiException('Contact not found', 404);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, 'firstname,lastname', null, false, 'email')\n ->willThrowException($exception);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('info')\n ->with(\n '[Hubspot] Failed to fetch contact',\n [\n 'email' => $email,\n 'reason' => 'Contact not found',\n ]\n );\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger($loggerMock);\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([], $result);\n }\n\n public function testGetOpportunityById(): void\n {\n $opportunityId = '12345';\n $expectedProperties = [\n 'dealname' => 'Test Opportunity',\n 'amount' => '1000.00',\n 'closedate' => '2024-12-31T23:59:59.999Z',\n 'dealstage' => 'presentationscheduled',\n 'pipeline' => 'default',\n ];\n\n $mockHubspotOpportunity = $this->createMock(DealWithAssociations::class);\n $mockHubspotOpportunity->method('getProperties')->willReturn((object) $expectedProperties);\n $mockHubspotOpportunity->method('getId')->willReturn($opportunityId);\n $now = new \\DateTimeImmutable();\n $mockHubspotOpportunity->method('getCreatedAt')->willReturn($now);\n $mockHubspotOpportunity->method('getUpdatedAt')->willReturn($now);\n $mockHubspotOpportunity->method('getArchived')->willReturn(false);\n\n $this->dealsBasicApiMock\n ->expects($this->once())\n ->method('getById')\n ->willReturn($mockHubspotOpportunity);\n\n // Assuming Client::getOpportunityById processes the SimplePublicObject and returns an associative array.\n // The structure might be like: ['id' => ..., 'properties' => [...], 'createdAt' => ..., ...]\n // Adjust assertions below based on the actual return structure of your method.\n $result = $this->client->getOpportunityById($opportunityId, ['test', 'test']);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertEquals($opportunityId, $result['id']);\n }\n\n public function testGetContactById(): void\n {\n $crmId = 'contact-123';\n $fields = ['firstname', 'lastname'];\n $expectedProperties = ['firstname' => 'John', 'lastname' => 'Doe'];\n\n $mockContact = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $mockContact->method('getId')->willReturn($crmId);\n $mockContact->method('getProperties')->willReturn((object) $expectedProperties);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($crmId, 'firstname,lastname')\n ->willReturn($mockContact);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new \\Psr\\Log\\NullLogger());\n\n $result = $client->getContactById($crmId, $fields);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertArrayHasKey('properties', $result);\n $this->assertEquals($crmId, $result['id']);\n $this->assertEquals((object) $expectedProperties, $result['properties']);\n }\n\n public function testGetAccountById(): void\n {\n $crmId = 'account-123';\n $fields = ['name', 'industry'];\n $expectedProperties = ['name' => 'Acme Corp', 'industry' => 'Technology'];\n\n $mockCompany = $this->createMock(\\HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectWithAssociations::class);\n $mockCompany->method('getId')->willReturn($crmId);\n $mockCompany->method('getProperties')->willReturn((object) $expectedProperties);\n\n $companiesApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Companies\\Api\\BasicApi::class);\n $companiesApiMock->expects($this->once())\n ->method('getById')\n ->with($crmId, 'name,industry')\n ->willReturn($mockCompany);\n\n $companiesDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Companies\\Discovery::class);\n $companiesDiscoveryMock->method('__call')->with('basicApi')->willReturn($companiesApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($companiesDiscoveryMock) {\n if ($name === 'companies') {\n return $companiesDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new \\Psr\\Log\\NullLogger());\n\n $result = $client->getAccountById($crmId, $fields);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertArrayHasKey('properties', $result);\n $this->assertEquals($crmId, $result['id']);\n $this->assertEquals((object) $expectedProperties, $result['properties']);\n }\n\n public function testEnsureValidTokenWithNoTokenUpdate(): void\n {\n $socialAccountMock = $this->createMock(SocialAccount::class);\n\n // Set up OAuth account\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, $socialAccountMock);\n\n $originalToken = 'original_token';\n $accessTokenProperty = $reflection->getProperty('accessToken');\n $accessTokenProperty->setAccessible(true);\n $accessTokenProperty->setValue($this->client, $originalToken);\n\n // Mock token manager to return null (no refresh needed)\n $this->tokenManagerMock\n ->expects($this->once())\n ->method('ensureValidToken')\n ->with($socialAccountMock)\n ->willReturn(null);\n\n // Call ensureValidToken\n $this->client->ensureValidToken();\n\n // Verify access token was not changed\n $this->assertEquals($originalToken, $accessTokenProperty->getValue($this->client));\n }\n\n\n\n\n\n\n public function testGetPaginatedDataGeneratorDelegatesToPaginationService(): void\n {\n $payload = ['filters' => []];\n $type = 'contacts';\n $offset = 0;\n $total = 0;\n $lastRecordId = null;\n\n $expectedResults = [\n ['id' => 'id_1', 'properties' => []],\n ['id' => 'id_2', 'properties' => []],\n ];\n\n // Mock the pagination service to return a generator\n $this->paginationServiceMock\n ->expects($this->once())\n ->method('getPaginatedDataGenerator')\n ->with(\n $this->client,\n $payload,\n $type,\n $offset,\n $this->anything(),\n $this->anything()\n )\n ->willReturnCallback(function () use ($expectedResults) {\n foreach ($expectedResults as $result) {\n yield $result;\n }\n });\n\n // Execute the pagination\n $results = [];\n foreach ($this->client->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastRecordId) as $result) {\n $results[] = $result;\n }\n\n $this->assertCount(2, $results);\n $this->assertEquals('id_1', $results[0]['id']);\n $this->assertEquals('id_2', $results[1]['id']);\n }\n\n public function testEnsureValidTokenDelegatesToTokenManager(): void\n {\n $socialAccountMock = $this->createMock(SocialAccount::class);\n\n // Set up OAuth account\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, $socialAccountMock);\n\n // Mock token manager to return new token\n $this->tokenManagerMock\n ->expects($this->once())\n ->method('ensureValidToken')\n ->with($socialAccountMock)\n ->willReturn('new_access_token');\n\n // Call ensureValidToken\n $this->client->ensureValidToken();\n\n // Verify access token was updated\n $accessTokenProperty = $reflection->getProperty('accessToken');\n $accessTokenProperty->setAccessible(true);\n $this->assertEquals('new_access_token', $accessTokenProperty->getValue($this->client));\n }\n\n public function testGetOwnersArchivedWithValidResponse(): void\n {\n $responseData = [\n 'results' => [\n [\n 'id' => '123',\n 'email' => 'owner1@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'John',\n 'lastName' => 'Doe',\n 'userId' => 456,\n 'userIdIncludingInactive' => 789,\n 'createdAt' => '2023-01-01T12:00:00Z',\n 'updatedAt' => '2023-01-02T12:00:00Z',\n 'archived' => true,\n ],\n ],\n ];\n\n // Create a mock response object\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn($responseData);\n\n // Set up the client to return our test data\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=true'\n )\n ->willReturn($response);\n\n // Call the method\n $result = $this->client->getOwnersArchived(true);\n\n // Assert the results\n $this->assertCount(1, $result);\n $this->assertEquals('123', $result[0]->getId());\n $this->assertEquals('owner1@example.com', $result[0]->getEmail());\n $this->assertEquals('John Doe', $result[0]->getFullName());\n $this->assertTrue($result[0]->isArchived());\n }\n\n public function testGetOwnersArchivedWithEmptyResponse(): void\n {\n // Create a mock response object with empty results\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn(['results' => []]);\n\n // Set up the client to return empty results\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=false'\n )\n ->willReturn($response);\n\n // Call the method\n $result = $this->client->getOwnersArchived(false);\n\n // Assert the results\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithInvalidResponse(): void\n {\n // Create a mock response that will throw an exception when toArray is called\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willThrowException(new \\InvalidArgumentException('Invalid JSON'));\n\n // Set up the client to return the problematic response\n $this->client->method('makeRequest')\n ->willReturn($response);\n\n // Mock the logger to expect an error message\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with($this->stringContains('Failed to fetch owners'));\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n // Call the method and expect an empty array on error\n $result = $this->client->getOwnersArchived(true);\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithHttpError(): void\n {\n // Set up the client to throw an exception\n $this->client->method('makeRequest')\n ->willThrowException(new \\Exception('HTTP Error'));\n\n // Mock the logger to expect an error message\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with($this->stringContains('Failed to fetch owners'));\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n // Call the method and expect an empty array on error\n $result = $this->client->getOwnersArchived(false);\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithOwnerCreationException(): void\n {\n $responseData = [\n 'results' => [\n [\n 'id' => '123',\n 'email' => 'valid@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'John',\n 'lastName' => 'Doe',\n 'userId' => 456,\n 'userIdIncludingInactive' => 789,\n 'createdAt' => '2023-01-01T12:00:00Z',\n 'updatedAt' => '2023-01-02T12:00:00Z',\n 'archived' => false,\n ],\n [\n 'id' => '456',\n 'email' => 'invalid@example.com',\n 'type' => 'PERSON',\n 'createdAt' => 'invalid-date-format',\n ],\n [\n 'id' => '789',\n 'email' => 'another@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'Jane',\n 'lastName' => 'Smith',\n 'userId' => 999,\n 'userIdIncludingInactive' => 888,\n 'createdAt' => '2023-01-03T12:00:00Z',\n 'updatedAt' => '2023-01-04T12:00:00Z',\n 'archived' => false,\n ],\n ],\n ];\n\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn($responseData);\n\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=false'\n )\n ->willReturn($response);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with(\n '[HubSpot] Failed to process owner data',\n $this->callback(function ($context) {\n return isset($context['result']) &&\n isset($context['error']) &&\n $context['result']['id'] === '456' &&\n $context['result']['email'] === 'invalid@example.com' &&\n str_contains($context['error'], 'invalid-date-format');\n })\n );\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n $result = $this->client->getOwnersArchived(false);\n\n $this->assertIsArray($result);\n $this->assertCount(2, $result);\n $this->assertEquals('123', $result[0]->getId());\n $this->assertEquals('valid@example.com', $result[0]->getEmail());\n $this->assertEquals('789', $result[1]->getId());\n $this->assertEquals('another@example.com', $result[1]->getEmail());\n }\n\n public function testMakeRequestWithGetMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'GET',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n [],\n null,\n true\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'GET');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithGetMethodAndQueryString(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'GET',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n [],\n 'limit=10&offset=0',\n true\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'GET', [], 'limit=10&offset=0');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPostMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'POST',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'POST', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPutMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'firstname' => 'Jane',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345', 'updated' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'PUT',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'PUT', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPatchMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'email' => 'newemail@example.com',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345', 'patched' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'PATCH',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'PATCH', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithDeleteMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['deleted' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'DELETE',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => []]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'DELETE');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithEmptyPayload(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'ok']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'POST',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n ['json' => []]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'POST', []);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestPrependsBaseUrl(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n $this->anything(),\n 'https://api.hubapi.com/test/endpoint',\n $this->anything()\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $client->makeRequest('/test/endpoint', 'GET');\n }\n\n public function testGetOpportunitiesByIds(): void\n {\n $crmIds = ['deal1', 'deal2', 'deal3'];\n $fields = ['dealname', 'amount'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getOpportunitiesByIds'));\n }\n\n public function testGetCompaniesByIds(): void\n {\n $crmIds = ['company1', 'company2'];\n $fields = ['name', 'industry'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getCompaniesByIds'));\n }\n\n public function testGetContactsByIds(): void\n {\n $crmIds = ['contact1', 'contact2'];\n $fields = ['firstname', 'lastname'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getContactsByIds'));\n }\n\n public function testBatchReadObjectsEmptyIds(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $result = $method->invokeArgs($this->client, ['deals', [], ['dealname']]);\n\n $this->assertEquals([], $result);\n }\n\n public function testBatchReadObjectsExceedsBatchSize(): void\n {\n $crmIds = array_fill(0, 101, 'deal_id'); // 101 IDs, exceeds limit of 100\n\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $this->expectException(\\InvalidArgumentException::class);\n $this->expectExceptionMessage('Batch size cannot exceed 100 deals');\n\n $method->invokeArgs($this->client, ['deals', $crmIds, ['dealname']]);\n }\n\n public function testBatchReadObjectsUnsupportedObjectType(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $this->expectException(\\Jiminny\\Exceptions\\CrmException::class);\n $this->expectExceptionMessage('Failed to batch fetch unsupported');\n\n $method->invokeArgs($this->client, ['unsupported', ['id1'], ['field1']]);\n }\n\n public function testIsUnauthorizedExceptionWithBadRequest401(): void\n {\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\BadRequest('Unauthorized', 401);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithBadRequestNon401(): void\n {\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\BadRequest('Bad Request', 400);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertFalse($result);\n }\n\n public function testIsUnauthorizedExceptionWithGuzzleRequestException(): void\n {\n $response = new Response(401, [], 'Unauthorized');\n $exception = new \\GuzzleHttp\\Exception\\RequestException('Unauthorized', new \\GuzzleHttp\\Psr7\\Request('GET', 'test'), $response);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithGuzzleClientException(): void\n {\n $exception = new \\GuzzleHttp\\Exception\\ClientException('Unauthorized', new \\GuzzleHttp\\Psr7\\Request('GET', 'test'), new Response(401));\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithStringMatching(): void\n {\n $exception = new \\Exception('HTTP 401 Unauthorized error occurred');\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithNonUnauthorized(): void\n {\n $exception = new \\Exception('Some other error');\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertFalse($result);\n }\n\n public function testEnsureValidTokenWithNullOauthAccount(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, null);\n\n // Should not throw exception and not call token manager\n $this->tokenManagerMock->expects($this->never())->method('ensureValidToken');\n\n $this->client->ensureValidToken();\n\n $this->assertTrue(true); // Test passes if no exception is thrown\n }\n\n public function testGetConfig(): void\n {\n $result = $this->client->getConfig();\n\n $this->assertSame($this->config, $result);\n }\n\n public function testGetOwners(): void\n {\n // Test that the method exists and can be called\n $this->assertTrue(method_exists($this->client, 'getOwners'));\n\n // Since getOwners has complex HubSpot API dependencies,\n // we'll just verify the method exists for now\n $this->assertIsCallable([$this->client, 'getOwners']);\n }\n\n public function testCreateMeeting(): void\n {\n $payload = [\n 'properties' => [\n 'hs_meeting_title' => 'Test Meeting',\n 'hs_meeting_start_time' => '2024-01-01T10:00:00Z',\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['id' => 'meeting123']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with('/crm/v3/objects/meetings', 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->createMeeting($payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testUpdateMeeting(): void\n {\n $meetingId = 'meeting123';\n $payload = [\n 'properties' => [\n 'hs_meeting_title' => 'Updated Meeting',\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['id' => $meetingId, 'updated' => true]);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v3/objects/meetings/{$meetingId}\", 'PATCH', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->updateMeeting($meetingId, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testAddAssociations(): void\n {\n $objectType = 'deals';\n $associationType = 'contacts';\n $payload = [\n 'inputs' => [\n ['from' => ['id' => 'deal1'], 'to' => ['id' => 'contact1']],\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['status' => 'success']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v4/associations/{$objectType}/{$associationType}/batch/create\", 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->addAssociations($objectType, $associationType, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testRemoveAssociations(): void\n {\n $objectType = 'deals';\n $associationType = 'contacts';\n $payload = [\n 'inputs' => [\n ['from' => ['id' => 'deal1'], 'to' => ['id' => 'contact1']],\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['status' => 'success']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v4/associations/{$objectType}/{$associationType}/batch/archive\", 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->removeAssociations($objectType, $associationType, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n // -------------------------------------------------------------------------\n // search() / executeRequest() tests\n // -------------------------------------------------------------------------\n\n public function testSearchReturnsDecodedArrayOnSuccess(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n\n $this->config->method('getId')->willReturn(42);\n\n $payload = ['filters' => []];\n $responseData = ['results' => [['id' => '1']], 'total' => 1, 'paging' => []];\n $hubspotResponse = new HubspotResponse(new Response(200, [], json_encode($responseData)));\n\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->with('POST', Client::BASE_URL . '/crm/v3/objects/contacts/search', ['json' => $payload])\n ->willReturn($hubspotResponse);\n\n $result = $this->client->search('contacts', $payload);\n\n $this->assertSame($responseData, $result);\n\n Mockery::close();\n }\n\n public function testSearchThrowsRateLimitExceptionWhenCircuitBreakerActive(): void\n {\n $futureTimestamp = (string) (time() + 30);\n\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn($futureTimestamp);\n\n $this->config->method('getId')->willReturn(42);\n\n $this->hubspotClientMock->expects($this->never())->method('request');\n\n $this->expectException(RateLimitException::class);\n $this->expectExceptionMessage('Hubspot rate limit (cached circuit-breaker)');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchThrowsRateLimitExceptionAndSetsNxOnFresh429(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n // NX + TTL option array — exact TTL depends on parseRetryAfter, verified separately\n $redisMock->shouldReceive('set')\n ->once()\n ->with(\n 'hubspot:ratelimit:config:42',\n Mockery::type('string'),\n Mockery::on(fn ($opts) => is_array($opts) && in_array('nx', $opts, true))\n );\n\n $this->config->method('getId')->willReturn(42);\n\n $badRequest = new BadRequest('Rate limit exceeded', 429);\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->willThrowException($badRequest);\n\n $this->expectException(RateLimitException::class);\n $this->expectExceptionMessage('Hubspot returned 429');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchPropagatesNonRateLimitException(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n\n $this->config->method('getId')->willReturn(42);\n\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\HubspotException('Server error', 500);\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->willThrowException($exception);\n\n $this->expectException(\\SevenShores\\Hubspot\\Exceptions\\HubspotException::class);\n $this->expectExceptionMessage('Server error');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchCircuitBreakerRetryAfterComputedFromStoredTimestamp(): void\n {\n $remaining = 45;\n $futureTimestamp = (string) (time() + $remaining);\n\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn($futureTimestamp);\n\n $this->config->method('getId')->willReturn(1);\n\n try {\n $this->client->search('deals', []);\n $this->fail('Expected RateLimitException');\n } catch (RateLimitException $e) {\n $this->assertGreaterThanOrEqual(1, $e->getRetryAfter());\n $this->assertLessThanOrEqual($remaining, $e->getRetryAfter());\n } finally {\n Mockery::close();\n }\n }\n\n // -------------------------------------------------------------------------\n // isHubspotRateLimit() tests (private method — tested via reflection)\n // -------------------------------------------------------------------------\n\n private function callIsHubspotRateLimit(\\Throwable $e): bool\n {\n $method = new \\ReflectionMethod($this->client, 'isHubspotRateLimit');\n $method->setAccessible(true);\n\n return $method->invoke($this->client, $e);\n }\n\n public function testIsHubspotRateLimitReturnsTrueForBadRequest429(): void\n {\n $e = new BadRequest('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForDealApiException429(): void\n {\n $e = new DealApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForContactApiException429(): void\n {\n $e = new ContactApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForCompanyApiException429(): void\n {\n $e = new CompanyApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForGuzzleRequestException429(): void\n {\n $request = new \\GuzzleHttp\\Psr7\\Request('POST', 'https://api.hubapi.com/test');\n $response = new \\GuzzleHttp\\Psr7\\Response(429);\n $e = new \\GuzzleHttp\\Exception\\RequestException('Too Many Requests', $request, $response);\n\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsFalseForBadRequestNon429(): void\n {\n $e = new BadRequest('Bad Request', 400);\n $this->assertFalse($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsFalseForGenericException(): void\n {\n $e = new \\RuntimeException('Something went wrong');\n $this->assertFalse($this->callIsHubspotRateLimit($e));\n }\n\n // -------------------------------------------------------------------------\n // parseRetryAfter() tests (private method — tested via reflection)\n // -------------------------------------------------------------------------\n\n private function callParseRetryAfter(\\Throwable $e): int\n {\n $method = new \\ReflectionMethod($this->client, 'parseRetryAfter');\n $method->setAccessible(true);\n\n return $method->invoke($this->client, $e);\n }\n\n public function testParseRetryAfterReadsRetryAfterHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => ['30']]);\n $this->assertSame(30, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReadsLowercaseRetryAfterHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['retry-after' => ['15']]);\n $this->assertSame(15, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterHandlesScalarHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => '60']);\n $this->assertSame(60, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsDailyLimitDelay(): void\n {\n $e = new BadRequest('Daily rate limit exceeded');\n $this->assertSame(600, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsTenSecondlyDelay(): void\n {\n $e = new BadRequest('Ten secondly rate limit exceeded');\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsSecondlyDelay(): void\n {\n $e = new BadRequest('Secondly rate limit exceeded');\n $this->assertSame(1, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsDefaultWhenNoHint(): void\n {\n $e = new BadRequest('Rate limit exceeded');\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterIgnoresNonNumericHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => ['not-a-number']]);\n // Falls through to message parsing; \"Too Many Requests\" has no known keyword → default 10\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.96276593,"top":0.07581804,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.9740692,"top":0.074221864,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.98138297,"top":0.074221864,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
4682894321548166980
|
4446428687123393012
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
ClientTest
Run 'ClientTest'
Debug 'ClientTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
144
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Hubspot;
use GuzzleHttp\Psr7\Response;
use HubSpot\Client\Crm\Associations\Api\BatchApi;
use HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId;
use HubSpot\Client\Crm\Associations\Model\BatchResponsePublicAssociationMulti;
use HubSpot\Client\Crm\Associations\Model\PublicObjectId;
use HubSpot\Client\Crm\Companies\ApiException as CompanyApiException;
use HubSpot\Client\Crm\Contacts\ApiException as ContactApiException;
use HubSpot\Client\Crm\Deals\Api\BasicApi as DealsBasicApi;
use HubSpot\Client\Crm\Deals\ApiException as DealApiException;
use HubSpot\Client\Crm\Deals\Model\SimplePublicObjectWithAssociations as DealWithAssociations;
use HubSpot\Client\Crm\Pipelines\Api\PipelinesApi;
use HubSpot\Client\Crm\Pipelines\Model\CollectionResponsePipeline;
use HubSpot\Client\Crm\Pipelines\Model\Error;
use HubSpot\Client\Crm\Pipelines\Model\Pipeline;
use HubSpot\Client\Crm\Pipelines\Model\PipelineStage;
use HubSpot\Client\Crm\Properties\Api\CoreApi;
use HubSpot\Client\Crm\Properties\Model\Property;
use HubSpot\Discovery as HubSpotDiscovery;
use HubSpot\Discovery\Crm\Deals\Discovery as DealsDiscovery;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\SocialAccount;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\HubspotTokenManager;
use Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService;
use Jiminny\Services\SocialAccountService;
use League\OAuth2\Client\Token\AccessToken;
use Mockery;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use SevenShores\Hubspot\Endpoints\Engagements;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Factory;
use SevenShores\Hubspot\Http\Client as HubspotClient;
use SevenShores\Hubspot\Http\Response as HubspotResponse;
/**
* @runTestsInSeparateProcesses
*
* @preserveGlobalState disabled
*/
class ClientTest extends TestCase
{
private const string RESPONSE_TYPE_STAGE_FIELD = 'stage';
private const string RESPONSE_TYPE_PIPELINE_FIELD = 'pipeline';
private const string RESPONSE_TYPE_REGULAR_FIELD = 'regular';
/**
* @var Client&MockObject
*/
private Client $client;
private Configuration $config;
/**
* @var SocialAccountService&MockObject
*/
private SocialAccountService $socialAccountServiceMock;
/**
* @var HubspotPaginationService&MockObject
*/
private HubspotPaginationService $paginationServiceMock;
/**
* @var HubspotTokenManager&MockObject
*/
private HubspotTokenManager $tokenManagerMock;
/**
* @var CoreApi&MockObject
*/
private CoreApi $coreApiMock;
/**
* @var PipelinesApi&MockObject
*/
private PipelinesApi $pipelinesApiMock;
/**
* @var BatchApi&MockObject
*/
private BatchApi $associationsBatchApiMock;
/**
* @var DealsBasicApi&MockObject
*/
private DealsBasicApi $dealsBasicApiMock;
/**
* @var Engagements&MockObject
*/
private $engagementsMock;
/**
* @var HubspotClient&MockObject
*/
private HubspotClient $hubspotClientMock;
protected function setUp(): void
{
// Create mocks for dependencies
$this->socialAccountServiceMock = $this->createMock(SocialAccountService::class);
$this->paginationServiceMock = $this->createMock(HubspotPaginationService::class);
$this->tokenManagerMock = $this->createMock(HubspotTokenManager::class);
// Create a real Client instance with mocked dependencies
// Create a partial mock only for the methods we need to mock
$client = $this->createPartialMock(Client::class, ['getInstance', 'getNewInstance', 'makeRequest']);
$client->setLogger(new NullLogger());
// Inject the real dependencies using reflection
$reflection = new \ReflectionClass(Client::class);
$accountServiceProperty = $reflection->getProperty('accountService');
$accountServiceProperty->setAccessible(true);
$accountServiceProperty->setValue($client, $this->socialAccountServiceMock);
$paginationServiceProperty = $reflection->getProperty('paginationService');
$paginationServiceProperty->setAccessible(true);
$paginationServiceProperty->setValue($client, $this->paginationServiceMock);
$tokenManagerProperty = $reflection->getProperty('tokenManager');
$tokenManagerProperty->setAccessible(true);
$tokenManagerProperty->setValue($client, $this->tokenManagerMock);
$factoryMock = $this->createMock(Factory::class);
$hubspotClientMock = $this->createMock(HubspotClient::class);
$engagementsMock = $this->createMock(\SevenShores\Hubspot\Endpoints\Engagements::class);
$factoryMock->method('getClient')->willReturn($hubspotClientMock);
$factoryMock->method('__call')->with('engagements')->willReturn($engagementsMock);
$client->method('getInstance')->willReturn($factoryMock);
$discoveryMock = $this->createMock(HubSpotDiscovery\Discovery::class);
$crmMock = $this->createMock(HubSpotDiscovery\Crm\Discovery::class);
$associationsMock = $this->createMock(HubSpotDiscovery\Crm\Associations\Discovery::class);
$propertiesMock = $this->createMock(HubSpotDiscovery\Crm\Properties\Discovery::class);
$pipelinesMock = $this->createMock(HubSpotDiscovery\Crm\Pipelines\Discovery::class);
$coreApiMock = $this->createMock(CoreApi::class);
$pipelinesApiMock = $this->createMock(PipelinesApi::class);
$associationsBatchApiMock = $this->createMock(BatchApi::class);
$dealsBasicApiMock = $this->createMock(DealsBasicApi::class);
$propertiesMock->method('__call')->with('coreApi')->willReturn($coreApiMock);
$pipelinesMock->method('__call')->with('pipelinesApi')->willReturn($pipelinesApiMock);
$associationsMock->method('__call')->with('batchApi')->willReturn($associationsBatchApiMock);
$dealsDiscoveryMock = $this->createMock(DealsDiscovery::class);
$dealsDiscoveryMock->method('__call')->with('basicApi')->willReturn($dealsBasicApiMock);
$returnMap = ['properties' => $propertiesMock, 'pipelines' => $pipelinesMock, 'associations' => $associationsMock, 'deals' => $dealsDiscoveryMock];
$crmMock->method('__call')
->willReturnCallback(static fn (string $name) => $returnMap[$name])
;
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client->method('getNewInstance')->willReturn($discoveryMock);
$this->client = $client;
$this->config = $this->createMock(Configuration::class);
$this->client->setConfiguration($this->config);
$this->coreApiMock = $coreApiMock;
$this->pipelinesApiMock = $pipelinesApiMock;
$this->associationsBatchApiMock = $associationsBatchApiMock;
$this->dealsBasicApiMock = $dealsBasicApiMock;
$this->engagementsMock = $engagementsMock;
$this->hubspotClientMock = $hubspotClientMock;
}
public function testGetMinimumApiVersion(): void
{
$this->assertIsString($this->client->getMinimumApiVersion());
}
public function testGetInstance(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManager = $this->createMock(HubspotTokenManager::class);
$client = new Client($socialAccountService, $paginationService, $tokenManager);
$client->setBaseUrl('[URL_WITH_CREDENTIALS] The class CollectionResponsePipeline will be deprecated in the next Hubspot version
*/
$this->pipelinesApiMock
->method('getAll')
->with('deals')
->willReturn(new CollectionResponsePipeline([
'results' => [$this->generatePipeline()],
]))
;
$this->assertEquals(
[
['id' => 'foo', 'label' => 'bar'],
['id' => 'baz', 'label' => 'qux'],
],
$this->client->fetchOpportunityPipelineStages()
);
}
public function testFetchOpportunityPipelineStagesErrorResponse(): void
{
$this->pipelinesApiMock
->method('getAll')
->with('deals')
->willReturn(new Error(['message' => 'test error']))
;
$this->assertEmpty($this->client->fetchOpportunityPipelineStages());
}
#[\PHPUnit\Framework\Attributes\DataProvider('meetingOutcomeFieldProvider')]
public function testFetchMeetingOutcomeFieldOptions(string $fieldId, string $expectedEndpoint): void
{
$field = new Field(['crm_provider_id' => $fieldId]);
$this->hubspotClientMock
->expects($this->once())
->method('request')
->with('GET', $expectedEndpoint)
->willReturn($this->generateHubSpotResponse(
[
'options' => [
['value' => 'option_1', 'label' => 'Option 1', 'displayOrder' => 0],
['value' => 'option_2', 'label' => 'Option 2', 'displayOrder' => 1],
['value' => 'option_3', 'label' => 'Option 3', 'displayOrder' => 2],
],
]
))
;
$this->assertEquals(
[
['id' => 'option_1', 'value' => 'option_1', 'label' => 'Option 1', 'display_order' => 0],
['id' => 'option_2', 'value' => 'option_2', 'label' => 'Option 2', 'display_order' => 1],
['id' => 'option_3', 'value' => 'option_3', 'label' => 'Option 3', 'display_order' => 2],
],
$this->client->fetchMeetingOutcomeFieldOptions($field)
);
}
public static function meetingOutcomeFieldProvider(): array
{
return [
'meeting outcome field' => [
'meetingOutcome',
'[URL_WITH_CREDENTIALS] The class CollectionResponsePipeline will be deprecated in the next Hubspot version
*/
$pipelineStagesResponse = new CollectionResponsePipeline([
'results' => [$this->generatePipeline()],
]);
$this->pipelinesApiMock
->method('getAll')
->willReturn($pipelineStagesResponse);
}
if ($type === self::RESPONSE_TYPE_PIPELINE_FIELD) {
$field->method('isStageField')->willReturn(false);
$field->method('isPipelineField')->willReturn(true);
$pipelineResponse = $this->generateHubSpotResponse([
'results' => [
['id' => '123', 'label' => 'Sales'],
['id' => 'default', 'label' => 'CS'],
],
]);
$this->client
->method('makeRequest')
->with('/crm/v3/pipelines/deals')
->willReturn($pipelineResponse);
}
$this->assertEquals(
$responses[$type],
$this->client->fetchOpportunityFieldOptions($field)
);
}
public static function opportunityFieldOptionsProvider(): array
{
return [
'stage field' => [
'field' => new Field(['crm_provider_id' => 'dealstage']),
'type' => self::RESPONSE_TYPE_STAGE_FIELD,
],
'pipeline field' => [
'field' => new Field(['crm_provider_id' => 'pipeline']),
'type' => self::RESPONSE_TYPE_PIPELINE_FIELD,
],
'regular field' => [
'field' => new Field(['crm_provider_id' => 'some_property']),
'type' => self::RESPONSE_TYPE_REGULAR_FIELD,
],
];
}
private function generateHubSpotResponse(array $data): HubspotResponse
{
return new HubspotResponse(new Response(200, [], json_encode($data)));
}
private function generateProperty(): Property
{
return new Property([
'name' => 'some_property',
'options' => [
[
'label' => 'label_1',
'value' => 'value_1',
],
[
'label' => 'label_2',
'value' => 'value_2',
],
],
]);
}
private function generatePipeline(): Pipeline
{
return new Pipeline(['stages' => [
new PipelineStage(['id' => 'foo', 'label' => 'bar']),
new PipelineStage(['id' => 'baz', 'label' => 'qux']),
]]);
}
public function testFetchOpportunityPipelines(): void
{
$this->client
->method('makeRequest')
->with('/crm/v3/pipelines/deals')
->willReturn($this->generateHubSpotResponse([
'results' => [
['id' => 'id_1', 'label' => 'Option 1', 'displayOrder' => 0],
['id' => 'id_2', 'label' => 'Option 2', 'displayOrder' => 1],
['id' => 'id_3', 'label' => 'Option 3', 'displayOrder' => 2],
],
]));
$this->assertEquals(
[
['id' => 'id_1', 'label' => 'Option 1'],
['id' => 'id_2', 'label' => 'Option 2'],
['id' => 'id_3', 'label' => 'Option 3'],
],
$this->client->fetchOpportunityPipelines()
);
}
public function testGetPaginatedData(): void
{
$expectedResults = [
['id' => 'id_1', 'properties' => []],
['id' => 'id_2', 'properties' => []],
['id' => 'id_3', 'properties' => []],
];
// Mock the pagination service to return a generator and modify reference parameters
$this->paginationServiceMock
->expects($this->once())
->method('getPaginatedDataGenerator')
->with(
$this->client,
['payload_key' => 'payload_value'],
'foobar',
0,
$this->anything(),
$this->anything()
)
->willReturnCallback(function ($client, $payload, $type, $offset, &$total, &$lastRecordId) use ($expectedResults) {
$total = 3;
$lastRecordId = 'id_3';
foreach ($expectedResults as $result) {
yield $result;
}
});
$this->assertEquals(
[
'results' => $expectedResults,
'total' => 3,
'last_record' => 'id_3',
],
$this->client->getPaginatedData(['payload_key' => 'payload_value'], 'foobar')
);
}
public function testGetAssociationsData(): void
{
$ids = ['1', '2', '3'];
$fromObject = 'deals';
$toObject = 'contacts';
$responseResults = [];
foreach ($ids as $id) {
$from = new PublicObjectId();
$from->setId($id);
$to1 = new PublicObjectId();
$to1->setId('contact_' . $id . '_1');
$to2 = new PublicObjectId();
$to2->setId('contact_' . $id . '_2');
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to1, $to2]);
$responseResults[] = $result;
}
$batchResponse = new BatchResponsePublicAssociationMulti();
$batchResponse->setResults($responseResults);
$this->associationsBatchApiMock->expects($this->once())
->method('read')
->with(
$this->equalTo($fromObject),
$this->equalTo($toObject),
$this->callback(function (BatchInputPublicObjectId $batchInput) use ($ids) {
$inputIds = array_map(
fn ($input) => $input->getId(),
$batchInput->getInputs()
);
return $inputIds === $ids;
})
)
->willReturn($batchResponse);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$expectedResult = [
'1' => ['contact_1_1', 'contact_1_2'],
'2' => ['contact_2_1', 'contact_2_2'],
'3' => ['contact_3_1', 'contact_3_2'],
];
$this->assertEquals($expectedResult, $result);
}
public function testGetAssociationsDataHandlesException(): void
{
$ids = ['1', '2', '3'];
$fromObject = 'deals';
$toObject = 'contacts';
$exception = new \Exception('API Error');
$this->associationsBatchApiMock->expects($this->once())
->method('read')
->with(
$this->equalTo($fromObject),
$this->equalTo($toObject),
$this->callback(function ($batchInput) use ($ids) {
return $batchInput instanceof \HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId
&& count($batchInput->getInputs()) === count($ids);
})
)
->willThrowException($exception);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with(
'[Hubspot] Failed to fetch associations',
[
'from_object' => $fromObject,
'to_object' => $toObject,
'reason' => 'API Error',
]
);
$this->client->setLogger($loggerMock);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$this->assertEmpty($result);
$this->assertIsArray($result);
}
public function testGetAssociationsDataWithLargeDataSet(): void
{
$ids = array_map(fn ($i) => (string) $i, range(1, 2500)); // More than 1000 items
$fromObject = 'deals';
$toObject = 'contacts';
$firstBatchResponse = new BatchResponsePublicAssociationMulti();
$firstBatchResults = array_map(function ($id) {
$from = new PublicObjectId();
$from->setId($id);
$to = new PublicObjectId();
$to->setId('contact_' . $id);
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to]);
return $result;
}, array_slice($ids, 0, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));
$firstBatchResponse->setResults($firstBatchResults);
$secondBatchResponse = new BatchResponsePublicAssociationMulti();
$secondBatchResults = array_map(function ($id) {
$from = new PublicObjectId();
$from->setId($id);
$to = new PublicObjectId();
$to->setId('contact_' . $id);
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to]);
return $result;
}, array_slice($ids, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));
$secondBatchResponse->setResults($secondBatchResults);
$this->associationsBatchApiMock->expects($this->exactly(3))
->method('read')
->willReturnOnConsecutiveCalls($firstBatchResponse, $secondBatchResponse);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$this->assertCount(2500, $result);
$this->assertArrayHasKey('1', $result);
$this->assertArrayHasKey('2500', $result);
$this->assertEquals(['contact_1'], $result['1']);
$this->assertEquals(['contact_2500'], $result['2500']);
}
public function testGetContactByEmailSuccess(): void
{
$email = '[EMAIL]';
$fields = ['firstname', 'lastname', 'email'];
$contactMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$contactMock->method('getId')->willReturn('12345');
$contactMock->method('getProperties')->willReturn([
'firstname' => 'John',
'lastname' => 'Doe',
'email' => '[EMAIL]',
]);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, 'firstname,lastname,email', null, false, 'email')
->willReturn($contactMock);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new NullLogger());
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([
'id' => '12345',
'properties' => [
'firstname' => 'John',
'lastname' => 'Doe',
'email' => '[EMAIL]',
],
], $result);
}
public function testGetContactByEmailWithEmptyFields(): void
{
$email = '[EMAIL]';
$fields = [];
$contactMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$contactMock->method('getId')->willReturn('12345');
$contactMock->method('getProperties')->willReturn(['email' => '[EMAIL]']);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, '', null, false, 'email')
->willReturn($contactMock);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new NullLogger());
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([
'id' => '12345',
'properties' => ['email' => '[EMAIL]'],
], $result);
}
public function testGetContactByEmailApiException(): void
{
$email = '[EMAIL]';
$fields = ['firstname', 'lastname'];
$exception = new \HubSpot\Client\Crm\Contacts\ApiException('Contact not found', 404);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, 'firstname,lastname', null, false, 'email')
->willThrowException($exception);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('info')
->with(
'[Hubspot] Failed to fetch contact',
[
'email' => $email,
'reason' => 'Contact not found',
]
);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger($loggerMock);
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([], $result);
}
public function testGetOpportunityById(): void
{
$opportunityId = '12345';
$expectedProperties = [
'dealname' => 'Test Opportunity',
'amount' => '1000.00',
'closedate' => '2024-12-31T23:59:59.999Z',
'dealstage' => 'presentationscheduled',
'pipeline' => 'default',
];
$mockHubspotOpportunity = $this->createMock(DealWithAssociations::class);
$mockHubspotOpportunity->method('getProperties')->willReturn((object) $expectedProperties);
$mockHubspotOpportunity->method('getId')->willReturn($opportunityId);
$now = new \DateTimeImmutable();
$mockHubspotOpportunity->method('getCreatedAt')->willReturn($now);
$mockHubspotOpportunity->method('getUpdatedAt')->willReturn($now);
$mockHubspotOpportunity->method('getArchived')->willReturn(false);
$this->dealsBasicApiMock
->expects($this->once())
->method('getById')
->willReturn($mockHubspotOpportunity);
// Assuming Client::getOpportunityById processes the SimplePublicObject and returns an associative array.
// The structure might be like: ['id' => ..., 'properties' => [...], 'createdAt' => ..., ...]
// Adjust assertions below based on the actual return structure of your method.
$result = $this->client->getOpportunityById($opportunityId, ['test', 'test']);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertEquals($opportunityId, $result['id']);
}
public function testGetContactById(): void
{
$crmId = 'contact-123';
$fields = ['firstname', 'lastname'];
$expectedProperties = ['firstname' => 'John', 'lastname' => 'Doe'];
$mockContact = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$mockContact->method('getId')->willReturn($crmId);
$mockContact->method('getProperties')->willReturn((object) $expectedProperties);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($crmId, 'firstname,lastname')
->willReturn($mockContact);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new \Psr\Log\NullLogger());
$result = $client->getContactById($crmId, $fields);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('properties', $result);
$this->assertEquals($crmId, $result['id']);
$this->assertEquals((object) $expectedProperties, $result['properties']);
}
public function testGetAccountById(): void
{
$crmId = 'account-123';
$fields = ['name', 'industry'];
$expectedProperties = ['name' => 'Acme Corp', 'industry' => 'Technology'];
$mockCompany = $this->createMock(\HubSpot\Client\Crm\Companies\Model\SimplePublicObjectWithAssociations::class);
$mockCompany->method('getId')->willReturn($crmId);
$mockCompany->method('getProperties')->willReturn((object) $expectedProperties);
$companiesApiMock = $this->createMock(\HubSpot\Client\Crm\Companies\Api\BasicApi::class);
$companiesApiMock->expects($this->once())
->method('getById')
->with($crmId, 'name,industry')
->willReturn($mockCompany);
$companiesDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Companies\Discovery::class);
$companiesDiscoveryMock->method('__call')->with('basicApi')->willReturn($companiesApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($companiesDiscoveryMock) {
if ($name === 'companies') {
return $companiesDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new \Psr\Log\NullLogger());
$result = $client->getAccountById($crmId, $fields);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('properties', $result);
$this->assertEquals($crmId, $result['id']);
$this->assertEquals((object) $expectedProperties, $result['properties']);
}
public function testEnsureValidTokenWithNoTokenUpdate(): void
{
$socialAccountMock = $this->createMock(SocialAccount::class);
// Set up OAuth account
$reflection = new \ReflectionClass($this->client);
$oauthAccountProperty = $reflection->getProperty('oauthAccount');
$oauthAccountProperty->setAccessible(true);
$oauthAccountProperty->setValue($this->client, $socialAccountMock);
$originalToken = 'original_token';
$accessTokenProperty = $reflection->getProperty('accessToken');
$accessTokenProperty->setAccessible(true);
$accessTokenProperty->setValue($this->client, $originalToken);
// Mock token manager to return null (no refresh needed)
$this->tokenManagerMock
->expects($this->once())
->method('ensureValidToken')
->with($socialAccountMock)
->willReturn(null);
// Call ensureValidToken
$this->client->ensureValidToken();
// Verify access token was not changed
$this->assertEquals($originalToken, $accessTokenProperty->getValue($this->client));
}
public function testGetPaginatedDataGeneratorDelegatesToPaginationService(): void
{
$payload = ['filters' => []];
$type = 'contacts';
$offset = 0;
$total = 0;
$lastRecordId = null;
$expectedResults = [
['id' => 'id_1', 'properties' => []],
['id' => 'id_2', 'properties' => []],
];
// Mock the pagination service to return a generator
$this->paginationServiceMock
->expects($this->once())
->method('getPaginatedDataGenerator')
->with(
$this->client,
$payload,
$type,
$offset,
$this->anything(),
$this->anything()
)
->willReturnCallback(function () use ($expectedResults) {
foreach ($expectedResults as $result) {
yield $result;
}
});
// Execute the pagination
$results = [];
foreach ($this->client->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastRecordId) as $result) {
$results[] = $result;
}
$this->assertCount(2, $results);
$this->assertEquals('id_1', $results[0]['id']);
$this->assertEquals('id_2', $results[1]['id']);
}
public function testEnsureValidTokenDelegatesToTokenManager(): void
{
$socialAccountMock = $this->createMock(SocialAccount::class);
// Set up OAuth account
$reflection = new \ReflectionClass($this->client);
$oauthAccountProperty = $reflection->getProperty('oauthAccount');
$oauthAccountProperty->setAccessible(true);
$oauthAccountProperty->setValue($this->client, $socialAccountMock);
// Mock token manager to return new token
$this->tokenManagerMock
->expects($this->once())
->method('ensureValidToken')
->with($socialAccountMock)
->willReturn('new_access_token');
// Call ensureValidToken
$this->client->ensureValidToken();
// Verify access token was updated
$accessTokenProperty = $reflection->getProperty('accessToken');
$accessTokenProperty->setAccessible(true);
$this->assertEquals('new_access_token', $accessTokenProperty->getValue($this->client));
}
public function testGetOwnersArchivedWithValidResponse(): void
{
$responseData = [
'results' => [
[
'id' => '123',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'John',
'lastName' => 'Doe',
'userId' => 456,
'userIdIncludingInactive' => 789,
'createdAt' => '2023-01-01T12:00:00Z',
'updatedAt' => '2023-01-02T12:00:00Z',
'archived' => true,
],
],
];
// Create a mock response object
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn($responseData);
// Set up the client to return our test data
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=true'
)
->willReturn($response);
// Call the method
$result = $this->client->getOwnersArchived(true);
// Assert the results
$this->assertCount(1, $result);
$this->assertEquals('123', $result[0]->getId());
$this->assertEquals('[EMAIL]', $result[0]->getEmail());
$this->assertEquals('John Doe', $result[0]->getFullName());
$this->assertTrue($result[0]->isArchived());
}
public function testGetOwnersArchivedWithEmptyResponse(): void
{
// Create a mock response object with empty results
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn(['results' => []]);
// Set up the client to return empty results
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=false'
)
->willReturn($response);
// Call the method
$result = $this->client->getOwnersArchived(false);
// Assert the results
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithInvalidResponse(): void
{
// Create a mock response that will throw an exception when toArray is called
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willThrowException(new \InvalidArgumentException('Invalid JSON'));
// Set up the client to return the problematic response
$this->client->method('makeRequest')
->willReturn($response);
// Mock the logger to expect an error message
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with($this->stringContains('Failed to fetch owners'));
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
// Call the method and expect an empty array on error
$result = $this->client->getOwnersArchived(true);
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithHttpError(): void
{
// Set up the client to throw an exception
$this->client->method('makeRequest')
->willThrowException(new \Exception('HTTP Error'));
// Mock the logger to expect an error message
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with($this->stringContains('Failed to fetch owners'));
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
// Call the method and expect an empty array on error
$result = $this->client->getOwnersArchived(false);
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithOwnerCreationException(): void
{
$responseData = [
'results' => [
[
'id' => '123',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'John',
'lastName' => 'Doe',
'userId' => 456,
'userIdIncludingInactive' => 789,
'createdAt' => '2023-01-01T12:00:00Z',
'updatedAt' => '2023-01-02T12:00:00Z',
'archived' => false,
],
[
'id' => '456',
'email' => '[EMAIL]',
'type' => 'PERSON',
'createdAt' => 'invalid-date-format',
],
[
'id' => '789',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'Jane',
'lastName' => 'Smith',
'userId' => 999,
'userIdIncludingInactive' => 888,
'createdAt' => '2023-01-03T12:00:00Z',
'updatedAt' => '2023-01-04T12:00:00Z',
'archived' => false,
],
],
];
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn($responseData);
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=false'
)
->willReturn($response);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with(
'[HubSpot] Failed to process owner data',
$this->callback(function ($context) {
return isset($context['result']) &&
isset($context['error']) &&
$context['result']['id'] === '456' &&
$context['result']['email'] === '[EMAIL]' &&
str_contains($context['error'], 'invalid-date-format');
})
);
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
$result = $this->client->getOwnersArchived(false);
$this->assertIsArray($result);
$this->assertCount(2, $result);
$this->assertEquals('123', $result[0]->getId());
$this->assertEquals('[EMAIL]', $result[0]->getEmail());
$this->assertEquals('789', $result[1]->getId());
$this->assertEquals('[EMAIL]', $result[1]->getEmail());
}
public function testMakeRequestWithGetMethod(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManager = $this->createMock(HubspotTokenManager::class);
$psrResponse = new Response(200, [], json_encode(['status' => 'success']));
$expectedResponse = new HubspotResponse($psrResponse);
$hubspotClientMock = $this->createMock(HubspotClient::class);
$hubspotClientMock->expects($this->once())
->method('request')
->with(
'GET',
'https://api.hubapi.com/crm/v3/objects/contacts',
[],
null,
true
)
->willReturn($expectedResponse);
$factoryMock = $this->createMock(Factory::class);
$factoryMock->method('getClient')->willReturn($hubspotClientMock);
$client = $this->getMockBuilder(Client::class)
->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])
->onlyMethods(['getInstance'])
->getMock();
$client->method('getInstance')->willReturn($factoryMock);
$client->setAccessToken(new AccessToken(['access_token' => 'test_token']));
$result = $client->makeRequest('/crm/v3/objects/contacts', 'GET');
$this->assertSame($expectedResponse, $result);
}
public function testMakeRequestWithGetMethodAndQueryString(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManag...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20566
|
893
|
8
|
2026-05-11T15:52:27.316919+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778514747316_m2.jpg...
|
PhpStorm
|
faVsco.js – ClientTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
ClientTest
Run 'ClientTest'
Debug 'ClientTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
144
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Hubspot;
use GuzzleHttp\Psr7\Response;
use HubSpot\Client\Crm\Associations\Api\BatchApi;
use HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId;
use HubSpot\Client\Crm\Associations\Model\BatchResponsePublicAssociationMulti;
use HubSpot\Client\Crm\Associations\Model\PublicObjectId;
use HubSpot\Client\Crm\Companies\ApiException as CompanyApiException;
use HubSpot\Client\Crm\Contacts\ApiException as ContactApiException;
use HubSpot\Client\Crm\Deals\Api\BasicApi as DealsBasicApi;
use HubSpot\Client\Crm\Deals\ApiException as DealApiException;
use HubSpot\Client\Crm\Deals\Model\SimplePublicObjectWithAssociations as DealWithAssociations;
use HubSpot\Client\Crm\Pipelines\Api\PipelinesApi;
use HubSpot\Client\Crm\Pipelines\Model\CollectionResponsePipeline;
use HubSpot\Client\Crm\Pipelines\Model\Error;
use HubSpot\Client\Crm\Pipelines\Model\Pipeline;
use HubSpot\Client\Crm\Pipelines\Model\PipelineStage;
use HubSpot\Client\Crm\Properties\Api\CoreApi;
use HubSpot\Client\Crm\Properties\Model\Property;
use HubSpot\Discovery as HubSpotDiscovery;
use HubSpot\Discovery\Crm\Deals\Discovery as DealsDiscovery;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\SocialAccount;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\HubspotTokenManager;
use Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService;
use Jiminny\Services\SocialAccountService;
use League\OAuth2\Client\Token\AccessToken;
use Mockery;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use SevenShores\Hubspot\Endpoints\Engagements;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Factory;
use SevenShores\Hubspot\Http\Client as HubspotClient;
use SevenShores\Hubspot\Http\Response as HubspotResponse;
/**
* @runTestsInSeparateProcesses
*
* @preserveGlobalState disabled
*/
class ClientTest extends TestCase
{
private const string RESPONSE_TYPE_STAGE_FIELD = 'stage';
private const string RESPONSE_TYPE_PIPELINE_FIELD = 'pipeline';
private const string RESPONSE_TYPE_REGULAR_FIELD = 'regular';
/**
* @var Client&MockObject
*/
private Client $client;
private Configuration $config;
/**
* @var SocialAccountService&MockObject
*/
private SocialAccountService $socialAccountServiceMock;
/**
* @var HubspotPaginationService&MockObject
*/
private HubspotPaginationService $paginationServiceMock;
/**
* @var HubspotTokenManager&MockObject
*/
private HubspotTokenManager $tokenManagerMock;
/**
* @var CoreApi&MockObject
*/
private CoreApi $coreApiMock;
/**
* @var PipelinesApi&MockObject
*/
private PipelinesApi $pipelinesApiMock;
/**
* @var BatchApi&MockObject
*/
private BatchApi $associationsBatchApiMock;
/**
* @var DealsBasicApi&MockObject
*/
private DealsBasicApi $dealsBasicApiMock;
/**
* @var Engagements&MockObject
*/
private $engagementsMock;
/**
* @var HubspotClient&MockObject
*/
private HubspotClient $hubspotClientMock;
protected function setUp(): void
{
// Create mocks for dependencies
$this->socialAccountServiceMock = $this->createMock(SocialAccountService::class);
$this->paginationServiceMock = $this->createMock(HubspotPaginationService::class);
$this->tokenManagerMock = $this->createMock(HubspotTokenManager::class);
// Create a real Client instance with mocked dependencies
// Create a partial mock only for the methods we need to mock
$client = $this->createPartialMock(Client::class, ['getInstance', 'getNewInstance', 'makeRequest']);
$client->setLogger(new NullLogger());
// Inject the real dependencies using reflection
$reflection = new \ReflectionClass(Client::class);
$accountServiceProperty = $reflection->getProperty('accountService');
$accountServiceProperty->setAccessible(true);
$accountServiceProperty->setValue($client, $this->socialAccountServiceMock);
$paginationServiceProperty = $reflection->getProperty('paginationService');
$paginationServiceProperty->setAccessible(true);
$paginationServiceProperty->setValue($client, $this->paginationServiceMock);
$tokenManagerProperty = $reflection->getProperty('tokenManager');
$tokenManagerProperty->setAccessible(true);
$tokenManagerProperty->setValue($client, $this->tokenManagerMock);
$factoryMock = $this->createMock(Factory::class);
$hubspotClientMock = $this->createMock(HubspotClient::class);
$engagementsMock = $this->createMock(\SevenShores\Hubspot\Endpoints\Engagements::class);
$factoryMock->method('getClient')->willReturn($hubspotClientMock);
$factoryMock->method('__call')->with('engagements')->willReturn($engagementsMock);
$client->method('getInstance')->willReturn($factoryMock);
$discoveryMock = $this->createMock(HubSpotDiscovery\Discovery::class);
$crmMock = $this->createMock(HubSpotDiscovery\Crm\Discovery::class);
$associationsMock = $this->createMock(HubSpotDiscovery\Crm\Associations\Discovery::class);
$propertiesMock = $this->createMock(HubSpotDiscovery\Crm\Properties\Discovery::class);
$pipelinesMock = $this->createMock(HubSpotDiscovery\Crm\Pipelines\Discovery::class);
$coreApiMock = $this->createMock(CoreApi::class);
$pipelinesApiMock = $this->createMock(PipelinesApi::class);
$associationsBatchApiMock = $this->createMock(BatchApi::class);
$dealsBasicApiMock = $this->createMock(DealsBasicApi::class);
$propertiesMock->method('__call')->with('coreApi')->willReturn($coreApiMock);
$pipelinesMock->method('__call')->with('pipelinesApi')->willReturn($pipelinesApiMock);
$associationsMock->method('__call')->with('batchApi')->willReturn($associationsBatchApiMock);
$dealsDiscoveryMock = $this->createMock(DealsDiscovery::class);
$dealsDiscoveryMock->method('__call')->with('basicApi')->willReturn($dealsBasicApiMock);
$returnMap = ['properties' => $propertiesMock, 'pipelines' => $pipelinesMock, 'associations' => $associationsMock, 'deals' => $dealsDiscoveryMock];
$crmMock->method('__call')
->willReturnCallback(static fn (string $name) => $returnMap[$name])
;
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client->method('getNewInstance')->willReturn($discoveryMock);
$this->client = $client;
$this->config = $this->createMock(Configuration::class);
$this->client->setConfiguration($this->config);
$this->coreApiMock = $coreApiMock;
$this->pipelinesApiMock = $pipelinesApiMock;
$this->associationsBatchApiMock = $associationsBatchApiMock;
$this->dealsBasicApiMock = $dealsBasicApiMock;
$this->engagementsMock = $engagementsMock;
$this->hubspotClientMock = $hubspotClientMock;
}
public function testGetMinimumApiVersion(): void
{
$this->assertIsString($this->client->getMinimumApiVersion());
}
public function testGetInstance(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManager = $this->createMock(HubspotTokenManager::class);
$client = new Client($socialAccountService, $paginationService, $tokenManager);
$client->setBaseUrl('[URL_WITH_CREDENTIALS] The class CollectionResponsePipeline will be deprecated in the next Hubspot version
*/
$this->pipelinesApiMock
->method('getAll')
->with('deals')
->willReturn(new CollectionResponsePipeline([
'results' => [$this->generatePipeline()],
]))
;
$this->assertEquals(
[
['id' => 'foo', 'label' => 'bar'],
['id' => 'baz', 'label' => 'qux'],
],
$this->client->fetchOpportunityPipelineStages()
);
}
public function testFetchOpportunityPipelineStagesErrorResponse(): void
{
$this->pipelinesApiMock
->method('getAll')
->with('deals')
->willReturn(new Error(['message' => 'test error']))
;
$this->assertEmpty($this->client->fetchOpportunityPipelineStages());
}
#[\PHPUnit\Framework\Attributes\DataProvider('meetingOutcomeFieldProvider')]
public function testFetchMeetingOutcomeFieldOptions(string $fieldId, string $expectedEndpoint): void
{
$field = new Field(['crm_provider_id' => $fieldId]);
$this->hubspotClientMock
->expects($this->once())
->method('request')
->with('GET', $expectedEndpoint)
->willReturn($this->generateHubSpotResponse(
[
'options' => [
['value' => 'option_1', 'label' => 'Option 1', 'displayOrder' => 0],
['value' => 'option_2', 'label' => 'Option 2', 'displayOrder' => 1],
['value' => 'option_3', 'label' => 'Option 3', 'displayOrder' => 2],
],
]
))
;
$this->assertEquals(
[
['id' => 'option_1', 'value' => 'option_1', 'label' => 'Option 1', 'display_order' => 0],
['id' => 'option_2', 'value' => 'option_2', 'label' => 'Option 2', 'display_order' => 1],
['id' => 'option_3', 'value' => 'option_3', 'label' => 'Option 3', 'display_order' => 2],
],
$this->client->fetchMeetingOutcomeFieldOptions($field)
);
}
public static function meetingOutcomeFieldProvider(): array
{
return [
'meeting outcome field' => [
'meetingOutcome',
'[URL_WITH_CREDENTIALS] The class CollectionResponsePipeline will be deprecated in the next Hubspot version
*/
$pipelineStagesResponse = new CollectionResponsePipeline([
'results' => [$this->generatePipeline()],
]);
$this->pipelinesApiMock
->method('getAll')
->willReturn($pipelineStagesResponse);
}
if ($type === self::RESPONSE_TYPE_PIPELINE_FIELD) {
$field->method('isStageField')->willReturn(false);
$field->method('isPipelineField')->willReturn(true);
$pipelineResponse = $this->generateHubSpotResponse([
'results' => [
['id' => '123', 'label' => 'Sales'],
['id' => 'default', 'label' => 'CS'],
],
]);
$this->client
->method('makeRequest')
->with('/crm/v3/pipelines/deals')
->willReturn($pipelineResponse);
}
$this->assertEquals(
$responses[$type],
$this->client->fetchOpportunityFieldOptions($field)
);
}
public static function opportunityFieldOptionsProvider(): array
{
return [
'stage field' => [
'field' => new Field(['crm_provider_id' => 'dealstage']),
'type' => self::RESPONSE_TYPE_STAGE_FIELD,
],
'pipeline field' => [
'field' => new Field(['crm_provider_id' => 'pipeline']),
'type' => self::RESPONSE_TYPE_PIPELINE_FIELD,
],
'regular field' => [
'field' => new Field(['crm_provider_id' => 'some_property']),
'type' => self::RESPONSE_TYPE_REGULAR_FIELD,
],
];
}
private function generateHubSpotResponse(array $data): HubspotResponse
{
return new HubspotResponse(new Response(200, [], json_encode($data)));
}
private function generateProperty(): Property
{
return new Property([
'name' => 'some_property',
'options' => [
[
'label' => 'label_1',
'value' => 'value_1',
],
[
'label' => 'label_2',
'value' => 'value_2',
],
],
]);
}
private function generatePipeline(): Pipeline
{
return new Pipeline(['stages' => [
new PipelineStage(['id' => 'foo', 'label' => 'bar']),
new PipelineStage(['id' => 'baz', 'label' => 'qux']),
]]);
}
public function testFetchOpportunityPipelines(): void
{
$this->client
->method('makeRequest')
->with('/crm/v3/pipelines/deals')
->willReturn($this->generateHubSpotResponse([
'results' => [
['id' => 'id_1', 'label' => 'Option 1', 'displayOrder' => 0],
['id' => 'id_2', 'label' => 'Option 2', 'displayOrder' => 1],
['id' => 'id_3', 'label' => 'Option 3', 'displayOrder' => 2],
],
]));
$this->assertEquals(
[
['id' => 'id_1', 'label' => 'Option 1'],
['id' => 'id_2', 'label' => 'Option 2'],
['id' => 'id_3', 'label' => 'Option 3'],
],
$this->client->fetchOpportunityPipelines()
);
}
public function testGetPaginatedData(): void
{
$expectedResults = [
['id' => 'id_1', 'properties' => []],
['id' => 'id_2', 'properties' => []],
['id' => 'id_3', 'properties' => []],
];
// Mock the pagination service to return a generator and modify reference parameters
$this->paginationServiceMock
->expects($this->once())
->method('getPaginatedDataGenerator')
->with(
$this->client,
['payload_key' => 'payload_value'],
'foobar',
0,
$this->anything(),
$this->anything()
)
->willReturnCallback(function ($client, $payload, $type, $offset, &$total, &$lastRecordId) use ($expectedResults) {
$total = 3;
$lastRecordId = 'id_3';
foreach ($expectedResults as $result) {
yield $result;
}
});
$this->assertEquals(
[
'results' => $expectedResults,
'total' => 3,
'last_record' => 'id_3',
],
$this->client->getPaginatedData(['payload_key' => 'payload_value'], 'foobar')
);
}
public function testGetAssociationsData(): void
{
$ids = ['1', '2', '3'];
$fromObject = 'deals';
$toObject = 'contacts';
$responseResults = [];
foreach ($ids as $id) {
$from = new PublicObjectId();
$from->setId($id);
$to1 = new PublicObjectId();
$to1->setId('contact_' . $id . '_1');
$to2 = new PublicObjectId();
$to2->setId('contact_' . $id . '_2');
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to1, $to2]);
$responseResults[] = $result;
}
$batchResponse = new BatchResponsePublicAssociationMulti();
$batchResponse->setResults($responseResults);
$this->associationsBatchApiMock->expects($this->once())
->method('read')
->with(
$this->equalTo($fromObject),
$this->equalTo($toObject),
$this->callback(function (BatchInputPublicObjectId $batchInput) use ($ids) {
$inputIds = array_map(
fn ($input) => $input->getId(),
$batchInput->getInputs()
);
return $inputIds === $ids;
})
)
->willReturn($batchResponse);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$expectedResult = [
'1' => ['contact_1_1', 'contact_1_2'],
'2' => ['contact_2_1', 'contact_2_2'],
'3' => ['contact_3_1', 'contact_3_2'],
];
$this->assertEquals($expectedResult, $result);
}
public function testGetAssociationsDataHandlesException(): void
{
$ids = ['1', '2', '3'];
$fromObject = 'deals';
$toObject = 'contacts';
$exception = new \Exception('API Error');
$this->associationsBatchApiMock->expects($this->once())
->method('read')
->with(
$this->equalTo($fromObject),
$this->equalTo($toObject),
$this->callback(function ($batchInput) use ($ids) {
return $batchInput instanceof \HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId
&& count($batchInput->getInputs()) === count($ids);
})
)
->willThrowException($exception);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with(
'[Hubspot] Failed to fetch associations',
[
'from_object' => $fromObject,
'to_object' => $toObject,
'reason' => 'API Error',
]
);
$this->client->setLogger($loggerMock);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$this->assertEmpty($result);
$this->assertIsArray($result);
}
public function testGetAssociationsDataWithLargeDataSet(): void
{
$ids = array_map(fn ($i) => (string) $i, range(1, 2500)); // More than 1000 items
$fromObject = 'deals';
$toObject = 'contacts';
$firstBatchResponse = new BatchResponsePublicAssociationMulti();
$firstBatchResults = array_map(function ($id) {
$from = new PublicObjectId();
$from->setId($id);
$to = new PublicObjectId();
$to->setId('contact_' . $id);
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to]);
return $result;
}, array_slice($ids, 0, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));
$firstBatchResponse->setResults($firstBatchResults);
$secondBatchResponse = new BatchResponsePublicAssociationMulti();
$secondBatchResults = array_map(function ($id) {
$from = new PublicObjectId();
$from->setId($id);
$to = new PublicObjectId();
$to->setId('contact_' . $id);
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to]);
return $result;
}, array_slice($ids, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));
$secondBatchResponse->setResults($secondBatchResults);
$this->associationsBatchApiMock->expects($this->exactly(3))
->method('read')
->willReturnOnConsecutiveCalls($firstBatchResponse, $secondBatchResponse);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$this->assertCount(2500, $result);
$this->assertArrayHasKey('1', $result);
$this->assertArrayHasKey('2500', $result);
$this->assertEquals(['contact_1'], $result['1']);
$this->assertEquals(['contact_2500'], $result['2500']);
}
public function testGetContactByEmailSuccess(): void
{
$email = '[EMAIL]';
$fields = ['firstname', 'lastname', 'email'];
$contactMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$contactMock->method('getId')->willReturn('12345');
$contactMock->method('getProperties')->willReturn([
'firstname' => 'John',
'lastname' => 'Doe',
'email' => '[EMAIL]',
]);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, 'firstname,lastname,email', null, false, 'email')
->willReturn($contactMock);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new NullLogger());
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([
'id' => '12345',
'properties' => [
'firstname' => 'John',
'lastname' => 'Doe',
'email' => '[EMAIL]',
],
], $result);
}
public function testGetContactByEmailWithEmptyFields(): void
{
$email = '[EMAIL]';
$fields = [];
$contactMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$contactMock->method('getId')->willReturn('12345');
$contactMock->method('getProperties')->willReturn(['email' => '[EMAIL]']);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, '', null, false, 'email')
->willReturn($contactMock);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new NullLogger());
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([
'id' => '12345',
'properties' => ['email' => '[EMAIL]'],
], $result);
}
public function testGetContactByEmailApiException(): void
{
$email = '[EMAIL]';
$fields = ['firstname', 'lastname'];
$exception = new \HubSpot\Client\Crm\Contacts\ApiException('Contact not found', 404);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, 'firstname,lastname', null, false, 'email')
->willThrowException($exception);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('info')
->with(
'[Hubspot] Failed to fetch contact',
[
'email' => $email,
'reason' => 'Contact not found',
]
);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger($loggerMock);
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([], $result);
}
public function testGetOpportunityById(): void
{
$opportunityId = '12345';
$expectedProperties = [
'dealname' => 'Test Opportunity',
'amount' => '1000.00',
'closedate' => '2024-12-31T23:59:59.999Z',
'dealstage' => 'presentationscheduled',
'pipeline' => 'default',
];
$mockHubspotOpportunity = $this->createMock(DealWithAssociations::class);
$mockHubspotOpportunity->method('getProperties')->willReturn((object) $expectedProperties);
$mockHubspotOpportunity->method('getId')->willReturn($opportunityId);
$now = new \DateTimeImmutable();
$mockHubspotOpportunity->method('getCreatedAt')->willReturn($now);
$mockHubspotOpportunity->method('getUpdatedAt')->willReturn($now);
$mockHubspotOpportunity->method('getArchived')->willReturn(false);
$this->dealsBasicApiMock
->expects($this->once())
->method('getById')
->willReturn($mockHubspotOpportunity);
// Assuming Client::getOpportunityById processes the SimplePublicObject and returns an associative array.
// The structure might be like: ['id' => ..., 'properties' => [...], 'createdAt' => ..., ...]
// Adjust assertions below based on the actual return structure of your method.
$result = $this->client->getOpportunityById($opportunityId, ['test', 'test']);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertEquals($opportunityId, $result['id']);
}
public function testGetContactById(): void
{
$crmId = 'contact-123';
$fields = ['firstname', 'lastname'];
$expectedProperties = ['firstname' => 'John', 'lastname' => 'Doe'];
$mockContact = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$mockContact->method('getId')->willReturn($crmId);
$mockContact->method('getProperties')->willReturn((object) $expectedProperties);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($crmId, 'firstname,lastname')
->willReturn($mockContact);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new \Psr\Log\NullLogger());
$result = $client->getContactById($crmId, $fields);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('properties', $result);
$this->assertEquals($crmId, $result['id']);
$this->assertEquals((object) $expectedProperties, $result['properties']);
}
public function testGetAccountById(): void
{
$crmId = 'account-123';
$fields = ['name', 'industry'];
$expectedProperties = ['name' => 'Acme Corp', 'industry' => 'Technology'];
$mockCompany = $this->createMock(\HubSpot\Client\Crm\Companies\Model\SimplePublicObjectWithAssociations::class);
$mockCompany->method('getId')->willReturn($crmId);
$mockCompany->method('getProperties')->willReturn((object) $expectedProperties);
$companiesApiMock = $this->createMock(\HubSpot\Client\Crm\Companies\Api\BasicApi::class);
$companiesApiMock->expects($this->once())
->method('getById')
->with($crmId, 'name,industry')
->willReturn($mockCompany);
$companiesDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Companies\Discovery::class);
$companiesDiscoveryMock->method('__call')->with('basicApi')->willReturn($companiesApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($companiesDiscoveryMock) {
if ($name === 'companies') {
return $companiesDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new \Psr\Log\NullLogger());
$result = $client->getAccountById($crmId, $fields);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('properties', $result);
$this->assertEquals($crmId, $result['id']);
$this->assertEquals((object) $expectedProperties, $result['properties']);
}
public function testEnsureValidTokenWithNoTokenUpdate(): void
{
$socialAccountMock = $this->createMock(SocialAccount::class);
// Set up OAuth account
$reflection = new \ReflectionClass($this->client);
$oauthAccountProperty = $reflection->getProperty('oauthAccount');
$oauthAccountProperty->setAccessible(true);
$oauthAccountProperty->setValue($this->client, $socialAccountMock);
$originalToken = 'original_token';
$accessTokenProperty = $reflection->getProperty('accessToken');
$accessTokenProperty->setAccessible(true);
$accessTokenProperty->setValue($this->client, $originalToken);
// Mock token manager to return null (no refresh needed)
$this->tokenManagerMock
->expects($this->once())
->method('ensureValidToken')
->with($socialAccountMock)
->willReturn(null);
// Call ensureValidToken
$this->client->ensureValidToken();
// Verify access token was not changed
$this->assertEquals($originalToken, $accessTokenProperty->getValue($this->client));
}
public function testGetPaginatedDataGeneratorDelegatesToPaginationService(): void
{
$payload = ['filters' => []];
$type = 'contacts';
$offset = 0;
$total = 0;
$lastRecordId = null;
$expectedResults = [
['id' => 'id_1', 'properties' => []],
['id' => 'id_2', 'properties' => []],
];
// Mock the pagination service to return a generator
$this->paginationServiceMock
->expects($this->once())
->method('getPaginatedDataGenerator')
->with(
$this->client,
$payload,
$type,
$offset,
$this->anything(),
$this->anything()
)
->willReturnCallback(function () use ($expectedResults) {
foreach ($expectedResults as $result) {
yield $result;
}
});
// Execute the pagination
$results = [];
foreach ($this->client->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastRecordId) as $result) {
$results[] = $result;
}
$this->assertCount(2, $results);
$this->assertEquals('id_1', $results[0]['id']);
$this->assertEquals('id_2', $results[1]['id']);
}
public function testEnsureValidTokenDelegatesToTokenManager(): void
{
$socialAccountMock = $this->createMock(SocialAccount::class);
// Set up OAuth account
$reflection = new \ReflectionClass($this->client);
$oauthAccountProperty = $reflection->getProperty('oauthAccount');
$oauthAccountProperty->setAccessible(true);
$oauthAccountProperty->setValue($this->client, $socialAccountMock);
// Mock token manager to return new token
$this->tokenManagerMock
->expects($this->once())
->method('ensureValidToken')
->with($socialAccountMock)
->willReturn('new_access_token');
// Call ensureValidToken
$this->client->ensureValidToken();
// Verify access token was updated
$accessTokenProperty = $reflection->getProperty('accessToken');
$accessTokenProperty->setAccessible(true);
$this->assertEquals('new_access_token', $accessTokenProperty->getValue($this->client));
}
public function testGetOwnersArchivedWithValidResponse(): void
{
$responseData = [
'results' => [
[
'id' => '123',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'John',
'lastName' => 'Doe',
'userId' => 456,
'userIdIncludingInactive' => 789,
'createdAt' => '2023-01-01T12:00:00Z',
'updatedAt' => '2023-01-02T12:00:00Z',
'archived' => true,
],
],
];
// Create a mock response object
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn($responseData);
// Set up the client to return our test data
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=true'
)
->willReturn($response);
// Call the method
$result = $this->client->getOwnersArchived(true);
// Assert the results
$this->assertCount(1, $result);
$this->assertEquals('123', $result[0]->getId());
$this->assertEquals('[EMAIL]', $result[0]->getEmail());
$this->assertEquals('John Doe', $result[0]->getFullName());
$this->assertTrue($result[0]->isArchived());
}
public function testGetOwnersArchivedWithEmptyResponse(): void
{
// Create a mock response object with empty results
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn(['results' => []]);
// Set up the client to return empty results
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=false'
)
->willReturn($response);
// Call the method
$result = $this->client->getOwnersArchived(false);
// Assert the results
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithInvalidResponse(): void
{
// Create a mock response that will throw an exception when toArray is called
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willThrowException(new \InvalidArgumentException('Invalid JSON'));
// Set up the client to return the problematic response
$this->client->method('makeRequest')
->willReturn($response);
// Mock the logger to expect an error message
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with($this->stringContains('Failed to fetch owners'));
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
// Call the method and expect an empty array on error
$result = $this->client->getOwnersArchived(true);
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithHttpError(): void
{
// Set up the client to throw an exception
$this->client->method('makeRequest')
->willThrowException(new \Exception('HTTP Error'));
// Mock the logger to expect an error message
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with($this->stringContains('Failed to fetch owners'));
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
// Call the method and expect an empty array on error
$result = $this->client->getOwnersArchived(false);
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithOwnerCreationException(): void
{
$responseData = [
'results' => [
[
'id' => '123',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'John',
'lastName' => 'Doe',
'userId' => 456,
'userIdIncludingInactive' => 789,
'createdAt' => '2023-01-01T12:00:00Z',
'updatedAt' => '2023-01-02T12:00:00Z',
'archived' => false,
],
[
'id' => '456',
'email' => '[EMAIL]',
'type' => 'PERSON',
'createdAt' => 'invalid-date-format',
],
[
'id' => '789',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'Jane',
'lastName' => 'Smith',
'userId' => 999,
'userIdIncludingInactive' => 888,
'createdAt' => '2023-01-03T12:00:00Z',
'updatedAt' => '2023-01-04T12:00:00Z',
'archived' => false,
],
],
];
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn($responseData);
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=false'
)
->willReturn($response);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with(
'[HubSpot] Failed to process owner data',
$this->callback(function ($context) {
return isset($context['result']) &&
isset($context['error']) &&
$context['result']['id'] === '456' &&
$context['result']['email'] === '[EMAIL]' &&
str_contains($context['error'], 'invalid-date-format');
})
);
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
$result = $this->client->getOwnersArchived(false);
$this->assertIsArray($result);
$this->assertCount(2, $result);
$this->assertEquals('123', $result[0]->getId());
$this->assertEquals('[EMAIL]', $result[0]->getEmail());
$this->assertEquals('789', $result[1]->getId());
$this->assertEquals('[EMAIL]', $result[1]->getEmail());
}
public function testMakeRequestWithGetMethod(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManager = $this->createMock(HubspotTokenManager::class);
$psrResponse = new Response(200, [], json_encode(['status' => 'success']));
$expectedResponse = new HubspotResponse($psrResponse);
$hubspotClientMock = $this->createMock(HubspotClient::class);
$hubspotClientMock->expects($this->once())
->method('request')
->with(
'GET',
'https://api.hubapi.com/crm/v3/objects/contacts',
[],
null,
true
)
->willReturn($expectedResponse);
$factoryMock = $this->createMock(Factory::class);
$factoryMock->method('getClient')->willReturn($hubspotClientMock);
$client = $this->getMockBuilder(Client::class)
->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])
->onlyMethods(['getInstance'])
->getMock();
$client->method('getInstance')->willReturn($factoryMock);
$client->setAccessToken(new AccessToken(['access_token' => 'test_token']));
$result = $client->makeRequest('/crm/v3/objects/contacts', 'GET');
$this->assertSame($expectedResponse, $result);
}
public function testMakeRequestWithGetMethodAndQueryString(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManag...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8597075,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ClientTest","depth":6,"bounds":{"left":0.875,"top":0.019952115,"width":0.04055851,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ClientTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ClientTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.50265956,"top":0.17478053,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"144","depth":4,"bounds":{"left":0.5142952,"top":0.17478053,"width":0.011968086,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":4,"bounds":{"left":0.52825797,"top":0.17478053,"width":0.008976064,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.53889626,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.5462101,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Hubspot;\n\nuse GuzzleHttp\\Psr7\\Response;\nuse HubSpot\\Client\\Crm\\Associations\\Api\\BatchApi;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\BatchResponsePublicAssociationMulti;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\PublicObjectId;\nuse HubSpot\\Client\\Crm\\Companies\\ApiException as CompanyApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\ApiException as ContactApiException;\nuse HubSpot\\Client\\Crm\\Deals\\Api\\BasicApi as DealsBasicApi;\nuse HubSpot\\Client\\Crm\\Deals\\ApiException as DealApiException;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectWithAssociations as DealWithAssociations;\nuse HubSpot\\Client\\Crm\\Pipelines\\Api\\PipelinesApi;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\CollectionResponsePipeline;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Error;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Pipeline;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\PipelineStage;\nuse HubSpot\\Client\\Crm\\Properties\\Api\\CoreApi;\nuse HubSpot\\Client\\Crm\\Properties\\Model\\Property;\nuse HubSpot\\Discovery as HubSpotDiscovery;\nuse HubSpot\\Discovery\\Crm\\Deals\\Discovery as DealsDiscovery;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\HubspotTokenManager;\nuse Jiminny\\Services\\Crm\\Hubspot\\Pagination\\HubspotPaginationService;\nuse Jiminny\\Services\\SocialAccountService;\nuse League\\OAuth2\\Client\\Token\\AccessToken;\nuse Mockery;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse PHPUnit\\Framework\\TestCase;\nuse Psr\\Log\\NullLogger;\nuse SevenShores\\Hubspot\\Endpoints\\Engagements;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Factory;\nuse SevenShores\\Hubspot\\Http\\Client as HubspotClient;\nuse SevenShores\\Hubspot\\Http\\Response as HubspotResponse;\n\n/**\n * @runTestsInSeparateProcesses\n *\n * @preserveGlobalState disabled\n */\nclass ClientTest extends TestCase\n{\n private const string RESPONSE_TYPE_STAGE_FIELD = 'stage';\n private const string RESPONSE_TYPE_PIPELINE_FIELD = 'pipeline';\n private const string RESPONSE_TYPE_REGULAR_FIELD = 'regular';\n /**\n * @var Client&MockObject\n */\n private Client $client;\n private Configuration $config;\n\n /**\n * @var SocialAccountService&MockObject\n */\n private SocialAccountService $socialAccountServiceMock;\n\n /**\n * @var HubspotPaginationService&MockObject\n */\n private HubspotPaginationService $paginationServiceMock;\n\n /**\n * @var HubspotTokenManager&MockObject\n */\n private HubspotTokenManager $tokenManagerMock;\n\n /**\n * @var CoreApi&MockObject\n */\n private CoreApi $coreApiMock;\n\n /**\n * @var PipelinesApi&MockObject\n */\n private PipelinesApi $pipelinesApiMock;\n\n /**\n * @var BatchApi&MockObject\n */\n private BatchApi $associationsBatchApiMock;\n\n /**\n * @var DealsBasicApi&MockObject\n */\n private DealsBasicApi $dealsBasicApiMock;\n\n /**\n * @var Engagements&MockObject\n */\n private $engagementsMock;\n\n /**\n * @var HubspotClient&MockObject\n */\n private HubspotClient $hubspotClientMock;\n\n protected function setUp(): void\n {\n // Create mocks for dependencies\n $this->socialAccountServiceMock = $this->createMock(SocialAccountService::class);\n $this->paginationServiceMock = $this->createMock(HubspotPaginationService::class);\n $this->tokenManagerMock = $this->createMock(HubspotTokenManager::class);\n\n // Create a real Client instance with mocked dependencies\n // Create a partial mock only for the methods we need to mock\n $client = $this->createPartialMock(Client::class, ['getInstance', 'getNewInstance', 'makeRequest']);\n $client->setLogger(new NullLogger());\n\n // Inject the real dependencies using reflection\n $reflection = new \\ReflectionClass(Client::class);\n\n $accountServiceProperty = $reflection->getProperty('accountService');\n $accountServiceProperty->setAccessible(true);\n $accountServiceProperty->setValue($client, $this->socialAccountServiceMock);\n\n $paginationServiceProperty = $reflection->getProperty('paginationService');\n $paginationServiceProperty->setAccessible(true);\n $paginationServiceProperty->setValue($client, $this->paginationServiceMock);\n\n $tokenManagerProperty = $reflection->getProperty('tokenManager');\n $tokenManagerProperty->setAccessible(true);\n $tokenManagerProperty->setValue($client, $this->tokenManagerMock);\n $factoryMock = $this->createMock(Factory::class);\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $engagementsMock = $this->createMock(\\SevenShores\\Hubspot\\Endpoints\\Engagements::class);\n\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n $factoryMock->method('__call')->with('engagements')->willReturn($engagementsMock);\n\n $client->method('getInstance')->willReturn($factoryMock);\n\n $discoveryMock = $this->createMock(HubSpotDiscovery\\Discovery::class);\n $crmMock = $this->createMock(HubSpotDiscovery\\Crm\\Discovery::class);\n $associationsMock = $this->createMock(HubSpotDiscovery\\Crm\\Associations\\Discovery::class);\n $propertiesMock = $this->createMock(HubSpotDiscovery\\Crm\\Properties\\Discovery::class);\n $pipelinesMock = $this->createMock(HubSpotDiscovery\\Crm\\Pipelines\\Discovery::class);\n $coreApiMock = $this->createMock(CoreApi::class);\n $pipelinesApiMock = $this->createMock(PipelinesApi::class);\n $associationsBatchApiMock = $this->createMock(BatchApi::class);\n $dealsBasicApiMock = $this->createMock(DealsBasicApi::class);\n\n $propertiesMock->method('__call')->with('coreApi')->willReturn($coreApiMock);\n $pipelinesMock->method('__call')->with('pipelinesApi')->willReturn($pipelinesApiMock);\n $associationsMock->method('__call')->with('batchApi')->willReturn($associationsBatchApiMock);\n $dealsDiscoveryMock = $this->createMock(DealsDiscovery::class);\n $dealsDiscoveryMock->method('__call')->with('basicApi')->willReturn($dealsBasicApiMock);\n\n $returnMap = ['properties' => $propertiesMock, 'pipelines' => $pipelinesMock, 'associations' => $associationsMock, 'deals' => $dealsDiscoveryMock];\n $crmMock->method('__call')\n ->willReturnCallback(static fn (string $name) => $returnMap[$name])\n ;\n\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n\n $this->client = $client;\n $this->config = $this->createMock(Configuration::class);\n $this->client->setConfiguration($this->config);\n $this->coreApiMock = $coreApiMock;\n $this->pipelinesApiMock = $pipelinesApiMock;\n $this->associationsBatchApiMock = $associationsBatchApiMock;\n $this->dealsBasicApiMock = $dealsBasicApiMock;\n $this->engagementsMock = $engagementsMock;\n $this->hubspotClientMock = $hubspotClientMock;\n }\n\n public function testGetMinimumApiVersion(): void\n {\n $this->assertIsString($this->client->getMinimumApiVersion());\n }\n\n public function testGetInstance(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $client = new Client($socialAccountService, $paginationService, $tokenManager);\n $client->setBaseUrl('https://example.com');\n $client->setAccessToken(new AccessToken(['access_token' => 'foo']));\n $factory = $client->getInstance();\n\n $this->assertEquals('foo', $factory->getClient()->key);\n }\n\n public function testGetNewInstance(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $client = new Client($socialAccountService, $paginationService, $tokenManager);\n $client->setAccessToken(new AccessToken(['access_token' => 'foo']));\n\n $factory = $client->getNewInstance();\n $token = $factory->auth()->oAuth()->accessTokensApi()->getConfig()->getAccessToken();\n $this->assertEquals('foo', $token);\n }\n\n public function testFetchProperty(): void\n {\n $this->coreApiMock->method('getByName')->willReturn($this->generateProperty());\n\n $this->assertEquals(\n 'some_property',\n $this->client->fetchProperty('foo', 'test')['name']\n );\n }\n\n public function testFetchPropertyOptions(): void\n {\n $this->coreApiMock->method('getByName')->willReturn($this->generateProperty());\n\n $this->assertEquals(\n [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n $this->client->fetchPropertyOptions('foo', 'test')\n );\n }\n\n public function testFetchCallDispositions(): void\n {\n $this->engagementsMock\n ->method('getCallDispositions')\n ->willReturn($this->generateHubSpotResponse([\n [\n 'id' => 1,\n 'label' => 'foo',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'bar',\n 'deleted' => false,\n ],\n ]))\n ;\n\n $this->assertEquals(\n [\n [\n 'id' => 1,\n 'label' => 'foo',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'bar',\n 'deleted' => false,\n ],\n ],\n $this->client->fetchCallDispositions()\n );\n }\n\n public function testFetchDispositionFieldOptions(): void\n {\n $this->engagementsMock->method('getCallDispositions')\n ->willReturn($this->generateHubSpotResponse(\n [\n [\n 'id' => 1,\n 'label' => 'Label 1',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'Label 2',\n 'deleted' => true,\n ],\n ]\n ))\n ;\n\n $this->assertEquals(\n [\n [\n 'value' => 1,\n 'id' => 1,\n 'label' => 'Label 1',\n ],\n ],\n $this->client->fetchDispositionFieldOptions()\n );\n }\n\n public function testFetchOpportunityPipelineStages(): void\n {\n /**\n * @TODO: The class CollectionResponsePipeline will be deprecated in the next Hubspot version\n */\n $this->pipelinesApiMock\n ->method('getAll')\n ->with('deals')\n ->willReturn(new CollectionResponsePipeline([\n 'results' => [$this->generatePipeline()],\n ]))\n ;\n\n $this->assertEquals(\n [\n ['id' => 'foo', 'label' => 'bar'],\n ['id' => 'baz', 'label' => 'qux'],\n ],\n $this->client->fetchOpportunityPipelineStages()\n );\n }\n\n public function testFetchOpportunityPipelineStagesErrorResponse(): void\n {\n $this->pipelinesApiMock\n ->method('getAll')\n ->with('deals')\n ->willReturn(new Error(['message' => 'test error']))\n ;\n\n $this->assertEmpty($this->client->fetchOpportunityPipelineStages());\n }\n\n #[\\PHPUnit\\Framework\\Attributes\\DataProvider('meetingOutcomeFieldProvider')]\n public function testFetchMeetingOutcomeFieldOptions(string $fieldId, string $expectedEndpoint): void\n {\n $field = new Field(['crm_provider_id' => $fieldId]);\n\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->with('GET', $expectedEndpoint)\n ->willReturn($this->generateHubSpotResponse(\n [\n 'options' => [\n ['value' => 'option_1', 'label' => 'Option 1', 'displayOrder' => 0],\n ['value' => 'option_2', 'label' => 'Option 2', 'displayOrder' => 1],\n ['value' => 'option_3', 'label' => 'Option 3', 'displayOrder' => 2],\n ],\n ]\n ))\n ;\n\n $this->assertEquals(\n [\n ['id' => 'option_1', 'value' => 'option_1', 'label' => 'Option 1', 'display_order' => 0],\n ['id' => 'option_2', 'value' => 'option_2', 'label' => 'Option 2', 'display_order' => 1],\n ['id' => 'option_3', 'value' => 'option_3', 'label' => 'Option 3', 'display_order' => 2],\n ],\n $this->client->fetchMeetingOutcomeFieldOptions($field)\n );\n }\n\n public static function meetingOutcomeFieldProvider(): array\n {\n return [\n 'meeting outcome field' => [\n 'meetingOutcome',\n 'https://api.hubapi.com/crm/v3/properties/meeting/hs_meeting_outcome',\n ],\n 'activity type field' => [\n 'foobar',\n 'https://api.hubapi.com/crm/v3/properties/call/hs_activity_type',\n ],\n ];\n }\n\n #[\\PHPUnit\\Framework\\Attributes\\DataProvider('opportunityFieldOptionsProvider')]\n public function testFetchOpportunityFieldOptions(Field $field, string $type): void\n {\n $responses = [\n self::RESPONSE_TYPE_STAGE_FIELD => [\n ['id' => 'foo', 'label' => 'bar'],\n ['id' => 'baz', 'label' => 'qux'],\n ],\n self::RESPONSE_TYPE_PIPELINE_FIELD => [\n ['id' => '123', 'label' => 'Sales'],\n ['id' => 'default', 'label' => 'CS'],\n ],\n self::RESPONSE_TYPE_REGULAR_FIELD => [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n ];\n\n $field = $this->createMock(Field::class);\n\n if ($type === self::RESPONSE_TYPE_REGULAR_FIELD) {\n $field->method('isStageField')->willReturn(false);\n $field->method('isPipelineField')->willReturn(false);\n $propertyResponse = $this->generateProperty();\n $this->coreApiMock->method('getByName')->willReturn($propertyResponse);\n }\n\n if ($type === self::RESPONSE_TYPE_STAGE_FIELD) {\n $field->method('isStageField')->willReturn(true);\n /**\n * @TODO: The class CollectionResponsePipeline will be deprecated in the next Hubspot version\n */\n\n $pipelineStagesResponse = new CollectionResponsePipeline([\n 'results' => [$this->generatePipeline()],\n ]);\n $this->pipelinesApiMock\n ->method('getAll')\n ->willReturn($pipelineStagesResponse);\n }\n\n if ($type === self::RESPONSE_TYPE_PIPELINE_FIELD) {\n $field->method('isStageField')->willReturn(false);\n $field->method('isPipelineField')->willReturn(true);\n $pipelineResponse = $this->generateHubSpotResponse([\n 'results' => [\n ['id' => '123', 'label' => 'Sales'],\n ['id' => 'default', 'label' => 'CS'],\n ],\n ]);\n $this->client\n ->method('makeRequest')\n ->with('/crm/v3/pipelines/deals')\n ->willReturn($pipelineResponse);\n }\n\n $this->assertEquals(\n $responses[$type],\n $this->client->fetchOpportunityFieldOptions($field)\n );\n }\n\n public static function opportunityFieldOptionsProvider(): array\n {\n return [\n 'stage field' => [\n 'field' => new Field(['crm_provider_id' => 'dealstage']),\n 'type' => self::RESPONSE_TYPE_STAGE_FIELD,\n ],\n 'pipeline field' => [\n 'field' => new Field(['crm_provider_id' => 'pipeline']),\n 'type' => self::RESPONSE_TYPE_PIPELINE_FIELD,\n ],\n 'regular field' => [\n 'field' => new Field(['crm_provider_id' => 'some_property']),\n 'type' => self::RESPONSE_TYPE_REGULAR_FIELD,\n ],\n ];\n }\n\n private function generateHubSpotResponse(array $data): HubspotResponse\n {\n return new HubspotResponse(new Response(200, [], json_encode($data)));\n }\n\n private function generateProperty(): Property\n {\n return new Property([\n 'name' => 'some_property',\n 'options' => [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n ]);\n }\n\n private function generatePipeline(): Pipeline\n {\n return new Pipeline(['stages' => [\n new PipelineStage(['id' => 'foo', 'label' => 'bar']),\n new PipelineStage(['id' => 'baz', 'label' => 'qux']),\n ]]);\n }\n\n public function testFetchOpportunityPipelines(): void\n {\n $this->client\n ->method('makeRequest')\n ->with('/crm/v3/pipelines/deals')\n ->willReturn($this->generateHubSpotResponse([\n 'results' => [\n ['id' => 'id_1', 'label' => 'Option 1', 'displayOrder' => 0],\n ['id' => 'id_2', 'label' => 'Option 2', 'displayOrder' => 1],\n ['id' => 'id_3', 'label' => 'Option 3', 'displayOrder' => 2],\n ],\n ]));\n\n $this->assertEquals(\n [\n ['id' => 'id_1', 'label' => 'Option 1'],\n ['id' => 'id_2', 'label' => 'Option 2'],\n ['id' => 'id_3', 'label' => 'Option 3'],\n ],\n $this->client->fetchOpportunityPipelines()\n );\n }\n\n public function testGetPaginatedData(): void\n {\n $expectedResults = [\n ['id' => 'id_1', 'properties' => []],\n ['id' => 'id_2', 'properties' => []],\n ['id' => 'id_3', 'properties' => []],\n ];\n\n // Mock the pagination service to return a generator and modify reference parameters\n $this->paginationServiceMock\n ->expects($this->once())\n ->method('getPaginatedDataGenerator')\n ->with(\n $this->client,\n ['payload_key' => 'payload_value'],\n 'foobar',\n 0,\n $this->anything(),\n $this->anything()\n )\n ->willReturnCallback(function ($client, $payload, $type, $offset, &$total, &$lastRecordId) use ($expectedResults) {\n $total = 3;\n $lastRecordId = 'id_3';\n foreach ($expectedResults as $result) {\n yield $result;\n }\n });\n\n $this->assertEquals(\n [\n 'results' => $expectedResults,\n 'total' => 3,\n 'last_record' => 'id_3',\n ],\n $this->client->getPaginatedData(['payload_key' => 'payload_value'], 'foobar')\n );\n }\n\n public function testGetAssociationsData(): void\n {\n $ids = ['1', '2', '3'];\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $responseResults = [];\n foreach ($ids as $id) {\n $from = new PublicObjectId();\n $from->setId($id);\n\n $to1 = new PublicObjectId();\n $to1->setId('contact_' . $id . '_1');\n $to2 = new PublicObjectId();\n $to2->setId('contact_' . $id . '_2');\n\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to1, $to2]);\n\n $responseResults[] = $result;\n }\n\n $batchResponse = new BatchResponsePublicAssociationMulti();\n $batchResponse->setResults($responseResults);\n\n $this->associationsBatchApiMock->expects($this->once())\n ->method('read')\n ->with(\n $this->equalTo($fromObject),\n $this->equalTo($toObject),\n $this->callback(function (BatchInputPublicObjectId $batchInput) use ($ids) {\n $inputIds = array_map(\n fn ($input) => $input->getId(),\n $batchInput->getInputs()\n );\n\n return $inputIds === $ids;\n })\n )\n ->willReturn($batchResponse);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $expectedResult = [\n '1' => ['contact_1_1', 'contact_1_2'],\n '2' => ['contact_2_1', 'contact_2_2'],\n '3' => ['contact_3_1', 'contact_3_2'],\n ];\n\n $this->assertEquals($expectedResult, $result);\n }\n\n public function testGetAssociationsDataHandlesException(): void\n {\n $ids = ['1', '2', '3'];\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $exception = new \\Exception('API Error');\n\n $this->associationsBatchApiMock->expects($this->once())\n ->method('read')\n ->with(\n $this->equalTo($fromObject),\n $this->equalTo($toObject),\n $this->callback(function ($batchInput) use ($ids) {\n return $batchInput instanceof \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId\n && count($batchInput->getInputs()) === count($ids);\n })\n )\n ->willThrowException($exception);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with(\n '[Hubspot] Failed to fetch associations',\n [\n 'from_object' => $fromObject,\n 'to_object' => $toObject,\n 'reason' => 'API Error',\n ]\n );\n\n $this->client->setLogger($loggerMock);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $this->assertEmpty($result);\n $this->assertIsArray($result);\n }\n\n public function testGetAssociationsDataWithLargeDataSet(): void\n {\n $ids = array_map(fn ($i) => (string) $i, range(1, 2500)); // More than 1000 items\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $firstBatchResponse = new BatchResponsePublicAssociationMulti();\n $firstBatchResults = array_map(function ($id) {\n $from = new PublicObjectId();\n $from->setId($id);\n $to = new PublicObjectId();\n $to->setId('contact_' . $id);\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to]);\n\n return $result;\n }, array_slice($ids, 0, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));\n $firstBatchResponse->setResults($firstBatchResults);\n\n $secondBatchResponse = new BatchResponsePublicAssociationMulti();\n $secondBatchResults = array_map(function ($id) {\n $from = new PublicObjectId();\n $from->setId($id);\n $to = new PublicObjectId();\n $to->setId('contact_' . $id);\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to]);\n\n return $result;\n }, array_slice($ids, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));\n $secondBatchResponse->setResults($secondBatchResults);\n\n $this->associationsBatchApiMock->expects($this->exactly(3))\n ->method('read')\n ->willReturnOnConsecutiveCalls($firstBatchResponse, $secondBatchResponse);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $this->assertCount(2500, $result);\n $this->assertArrayHasKey('1', $result);\n $this->assertArrayHasKey('2500', $result);\n $this->assertEquals(['contact_1'], $result['1']);\n $this->assertEquals(['contact_2500'], $result['2500']);\n }\n\n public function testGetContactByEmailSuccess(): void\n {\n $email = 'test@example.com';\n $fields = ['firstname', 'lastname', 'email'];\n\n $contactMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $contactMock->method('getId')->willReturn('12345');\n $contactMock->method('getProperties')->willReturn([\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n 'email' => 'test@example.com',\n ]);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, 'firstname,lastname,email', null, false, 'email')\n ->willReturn($contactMock);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new NullLogger());\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([\n 'id' => '12345',\n 'properties' => [\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n 'email' => 'test@example.com',\n ],\n ], $result);\n }\n\n public function testGetContactByEmailWithEmptyFields(): void\n {\n $email = 'test@example.com';\n $fields = [];\n\n $contactMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $contactMock->method('getId')->willReturn('12345');\n $contactMock->method('getProperties')->willReturn(['email' => 'test@example.com']);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, '', null, false, 'email')\n ->willReturn($contactMock);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new NullLogger());\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([\n 'id' => '12345',\n 'properties' => ['email' => 'test@example.com'],\n ], $result);\n }\n\n public function testGetContactByEmailApiException(): void\n {\n $email = 'nonexistent@example.com';\n $fields = ['firstname', 'lastname'];\n\n $exception = new \\HubSpot\\Client\\Crm\\Contacts\\ApiException('Contact not found', 404);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, 'firstname,lastname', null, false, 'email')\n ->willThrowException($exception);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('info')\n ->with(\n '[Hubspot] Failed to fetch contact',\n [\n 'email' => $email,\n 'reason' => 'Contact not found',\n ]\n );\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger($loggerMock);\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([], $result);\n }\n\n public function testGetOpportunityById(): void\n {\n $opportunityId = '12345';\n $expectedProperties = [\n 'dealname' => 'Test Opportunity',\n 'amount' => '1000.00',\n 'closedate' => '2024-12-31T23:59:59.999Z',\n 'dealstage' => 'presentationscheduled',\n 'pipeline' => 'default',\n ];\n\n $mockHubspotOpportunity = $this->createMock(DealWithAssociations::class);\n $mockHubspotOpportunity->method('getProperties')->willReturn((object) $expectedProperties);\n $mockHubspotOpportunity->method('getId')->willReturn($opportunityId);\n $now = new \\DateTimeImmutable();\n $mockHubspotOpportunity->method('getCreatedAt')->willReturn($now);\n $mockHubspotOpportunity->method('getUpdatedAt')->willReturn($now);\n $mockHubspotOpportunity->method('getArchived')->willReturn(false);\n\n $this->dealsBasicApiMock\n ->expects($this->once())\n ->method('getById')\n ->willReturn($mockHubspotOpportunity);\n\n // Assuming Client::getOpportunityById processes the SimplePublicObject and returns an associative array.\n // The structure might be like: ['id' => ..., 'properties' => [...], 'createdAt' => ..., ...]\n // Adjust assertions below based on the actual return structure of your method.\n $result = $this->client->getOpportunityById($opportunityId, ['test', 'test']);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertEquals($opportunityId, $result['id']);\n }\n\n public function testGetContactById(): void\n {\n $crmId = 'contact-123';\n $fields = ['firstname', 'lastname'];\n $expectedProperties = ['firstname' => 'John', 'lastname' => 'Doe'];\n\n $mockContact = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $mockContact->method('getId')->willReturn($crmId);\n $mockContact->method('getProperties')->willReturn((object) $expectedProperties);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($crmId, 'firstname,lastname')\n ->willReturn($mockContact);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new \\Psr\\Log\\NullLogger());\n\n $result = $client->getContactById($crmId, $fields);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertArrayHasKey('properties', $result);\n $this->assertEquals($crmId, $result['id']);\n $this->assertEquals((object) $expectedProperties, $result['properties']);\n }\n\n public function testGetAccountById(): void\n {\n $crmId = 'account-123';\n $fields = ['name', 'industry'];\n $expectedProperties = ['name' => 'Acme Corp', 'industry' => 'Technology'];\n\n $mockCompany = $this->createMock(\\HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectWithAssociations::class);\n $mockCompany->method('getId')->willReturn($crmId);\n $mockCompany->method('getProperties')->willReturn((object) $expectedProperties);\n\n $companiesApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Companies\\Api\\BasicApi::class);\n $companiesApiMock->expects($this->once())\n ->method('getById')\n ->with($crmId, 'name,industry')\n ->willReturn($mockCompany);\n\n $companiesDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Companies\\Discovery::class);\n $companiesDiscoveryMock->method('__call')->with('basicApi')->willReturn($companiesApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($companiesDiscoveryMock) {\n if ($name === 'companies') {\n return $companiesDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new \\Psr\\Log\\NullLogger());\n\n $result = $client->getAccountById($crmId, $fields);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertArrayHasKey('properties', $result);\n $this->assertEquals($crmId, $result['id']);\n $this->assertEquals((object) $expectedProperties, $result['properties']);\n }\n\n public function testEnsureValidTokenWithNoTokenUpdate(): void\n {\n $socialAccountMock = $this->createMock(SocialAccount::class);\n\n // Set up OAuth account\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, $socialAccountMock);\n\n $originalToken = 'original_token';\n $accessTokenProperty = $reflection->getProperty('accessToken');\n $accessTokenProperty->setAccessible(true);\n $accessTokenProperty->setValue($this->client, $originalToken);\n\n // Mock token manager to return null (no refresh needed)\n $this->tokenManagerMock\n ->expects($this->once())\n ->method('ensureValidToken')\n ->with($socialAccountMock)\n ->willReturn(null);\n\n // Call ensureValidToken\n $this->client->ensureValidToken();\n\n // Verify access token was not changed\n $this->assertEquals($originalToken, $accessTokenProperty->getValue($this->client));\n }\n\n\n\n\n\n\n public function testGetPaginatedDataGeneratorDelegatesToPaginationService(): void\n {\n $payload = ['filters' => []];\n $type = 'contacts';\n $offset = 0;\n $total = 0;\n $lastRecordId = null;\n\n $expectedResults = [\n ['id' => 'id_1', 'properties' => []],\n ['id' => 'id_2', 'properties' => []],\n ];\n\n // Mock the pagination service to return a generator\n $this->paginationServiceMock\n ->expects($this->once())\n ->method('getPaginatedDataGenerator')\n ->with(\n $this->client,\n $payload,\n $type,\n $offset,\n $this->anything(),\n $this->anything()\n )\n ->willReturnCallback(function () use ($expectedResults) {\n foreach ($expectedResults as $result) {\n yield $result;\n }\n });\n\n // Execute the pagination\n $results = [];\n foreach ($this->client->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastRecordId) as $result) {\n $results[] = $result;\n }\n\n $this->assertCount(2, $results);\n $this->assertEquals('id_1', $results[0]['id']);\n $this->assertEquals('id_2', $results[1]['id']);\n }\n\n public function testEnsureValidTokenDelegatesToTokenManager(): void\n {\n $socialAccountMock = $this->createMock(SocialAccount::class);\n\n // Set up OAuth account\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, $socialAccountMock);\n\n // Mock token manager to return new token\n $this->tokenManagerMock\n ->expects($this->once())\n ->method('ensureValidToken')\n ->with($socialAccountMock)\n ->willReturn('new_access_token');\n\n // Call ensureValidToken\n $this->client->ensureValidToken();\n\n // Verify access token was updated\n $accessTokenProperty = $reflection->getProperty('accessToken');\n $accessTokenProperty->setAccessible(true);\n $this->assertEquals('new_access_token', $accessTokenProperty->getValue($this->client));\n }\n\n public function testGetOwnersArchivedWithValidResponse(): void\n {\n $responseData = [\n 'results' => [\n [\n 'id' => '123',\n 'email' => 'owner1@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'John',\n 'lastName' => 'Doe',\n 'userId' => 456,\n 'userIdIncludingInactive' => 789,\n 'createdAt' => '2023-01-01T12:00:00Z',\n 'updatedAt' => '2023-01-02T12:00:00Z',\n 'archived' => true,\n ],\n ],\n ];\n\n // Create a mock response object\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn($responseData);\n\n // Set up the client to return our test data\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=true'\n )\n ->willReturn($response);\n\n // Call the method\n $result = $this->client->getOwnersArchived(true);\n\n // Assert the results\n $this->assertCount(1, $result);\n $this->assertEquals('123', $result[0]->getId());\n $this->assertEquals('owner1@example.com', $result[0]->getEmail());\n $this->assertEquals('John Doe', $result[0]->getFullName());\n $this->assertTrue($result[0]->isArchived());\n }\n\n public function testGetOwnersArchivedWithEmptyResponse(): void\n {\n // Create a mock response object with empty results\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn(['results' => []]);\n\n // Set up the client to return empty results\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=false'\n )\n ->willReturn($response);\n\n // Call the method\n $result = $this->client->getOwnersArchived(false);\n\n // Assert the results\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithInvalidResponse(): void\n {\n // Create a mock response that will throw an exception when toArray is called\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willThrowException(new \\InvalidArgumentException('Invalid JSON'));\n\n // Set up the client to return the problematic response\n $this->client->method('makeRequest')\n ->willReturn($response);\n\n // Mock the logger to expect an error message\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with($this->stringContains('Failed to fetch owners'));\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n // Call the method and expect an empty array on error\n $result = $this->client->getOwnersArchived(true);\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithHttpError(): void\n {\n // Set up the client to throw an exception\n $this->client->method('makeRequest')\n ->willThrowException(new \\Exception('HTTP Error'));\n\n // Mock the logger to expect an error message\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with($this->stringContains('Failed to fetch owners'));\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n // Call the method and expect an empty array on error\n $result = $this->client->getOwnersArchived(false);\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithOwnerCreationException(): void\n {\n $responseData = [\n 'results' => [\n [\n 'id' => '123',\n 'email' => 'valid@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'John',\n 'lastName' => 'Doe',\n 'userId' => 456,\n 'userIdIncludingInactive' => 789,\n 'createdAt' => '2023-01-01T12:00:00Z',\n 'updatedAt' => '2023-01-02T12:00:00Z',\n 'archived' => false,\n ],\n [\n 'id' => '456',\n 'email' => 'invalid@example.com',\n 'type' => 'PERSON',\n 'createdAt' => 'invalid-date-format',\n ],\n [\n 'id' => '789',\n 'email' => 'another@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'Jane',\n 'lastName' => 'Smith',\n 'userId' => 999,\n 'userIdIncludingInactive' => 888,\n 'createdAt' => '2023-01-03T12:00:00Z',\n 'updatedAt' => '2023-01-04T12:00:00Z',\n 'archived' => false,\n ],\n ],\n ];\n\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn($responseData);\n\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=false'\n )\n ->willReturn($response);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with(\n '[HubSpot] Failed to process owner data',\n $this->callback(function ($context) {\n return isset($context['result']) &&\n isset($context['error']) &&\n $context['result']['id'] === '456' &&\n $context['result']['email'] === 'invalid@example.com' &&\n str_contains($context['error'], 'invalid-date-format');\n })\n );\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n $result = $this->client->getOwnersArchived(false);\n\n $this->assertIsArray($result);\n $this->assertCount(2, $result);\n $this->assertEquals('123', $result[0]->getId());\n $this->assertEquals('valid@example.com', $result[0]->getEmail());\n $this->assertEquals('789', $result[1]->getId());\n $this->assertEquals('another@example.com', $result[1]->getEmail());\n }\n\n public function testMakeRequestWithGetMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'GET',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n [],\n null,\n true\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'GET');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithGetMethodAndQueryString(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'GET',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n [],\n 'limit=10&offset=0',\n true\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'GET', [], 'limit=10&offset=0');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPostMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'POST',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'POST', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPutMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'firstname' => 'Jane',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345', 'updated' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'PUT',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'PUT', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPatchMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'email' => 'newemail@example.com',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345', 'patched' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'PATCH',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'PATCH', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithDeleteMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['deleted' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'DELETE',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => []]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'DELETE');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithEmptyPayload(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'ok']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'POST',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n ['json' => []]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'POST', []);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestPrependsBaseUrl(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n $this->anything(),\n 'https://api.hubapi.com/test/endpoint',\n $this->anything()\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $client->makeRequest('/test/endpoint', 'GET');\n }\n\n public function testGetOpportunitiesByIds(): void\n {\n $crmIds = ['deal1', 'deal2', 'deal3'];\n $fields = ['dealname', 'amount'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getOpportunitiesByIds'));\n }\n\n public function testGetCompaniesByIds(): void\n {\n $crmIds = ['company1', 'company2'];\n $fields = ['name', 'industry'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getCompaniesByIds'));\n }\n\n public function testGetContactsByIds(): void\n {\n $crmIds = ['contact1', 'contact2'];\n $fields = ['firstname', 'lastname'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getContactsByIds'));\n }\n\n public function testBatchReadObjectsEmptyIds(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $result = $method->invokeArgs($this->client, ['deals', [], ['dealname']]);\n\n $this->assertEquals([], $result);\n }\n\n public function testBatchReadObjectsExceedsBatchSize(): void\n {\n $crmIds = array_fill(0, 101, 'deal_id'); // 101 IDs, exceeds limit of 100\n\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $this->expectException(\\InvalidArgumentException::class);\n $this->expectExceptionMessage('Batch size cannot exceed 100 deals');\n\n $method->invokeArgs($this->client, ['deals', $crmIds, ['dealname']]);\n }\n\n public function testBatchReadObjectsUnsupportedObjectType(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $this->expectException(\\Jiminny\\Exceptions\\CrmException::class);\n $this->expectExceptionMessage('Failed to batch fetch unsupported');\n\n $method->invokeArgs($this->client, ['unsupported', ['id1'], ['field1']]);\n }\n\n public function testIsUnauthorizedExceptionWithBadRequest401(): void\n {\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\BadRequest('Unauthorized', 401);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithBadRequestNon401(): void\n {\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\BadRequest('Bad Request', 400);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertFalse($result);\n }\n\n public function testIsUnauthorizedExceptionWithGuzzleRequestException(): void\n {\n $response = new Response(401, [], 'Unauthorized');\n $exception = new \\GuzzleHttp\\Exception\\RequestException('Unauthorized', new \\GuzzleHttp\\Psr7\\Request('GET', 'test'), $response);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithGuzzleClientException(): void\n {\n $exception = new \\GuzzleHttp\\Exception\\ClientException('Unauthorized', new \\GuzzleHttp\\Psr7\\Request('GET', 'test'), new Response(401));\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithStringMatching(): void\n {\n $exception = new \\Exception('HTTP 401 Unauthorized error occurred');\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithNonUnauthorized(): void\n {\n $exception = new \\Exception('Some other error');\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertFalse($result);\n }\n\n public function testEnsureValidTokenWithNullOauthAccount(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, null);\n\n // Should not throw exception and not call token manager\n $this->tokenManagerMock->expects($this->never())->method('ensureValidToken');\n\n $this->client->ensureValidToken();\n\n $this->assertTrue(true); // Test passes if no exception is thrown\n }\n\n public function testGetConfig(): void\n {\n $result = $this->client->getConfig();\n\n $this->assertSame($this->config, $result);\n }\n\n public function testGetOwners(): void\n {\n // Test that the method exists and can be called\n $this->assertTrue(method_exists($this->client, 'getOwners'));\n\n // Since getOwners has complex HubSpot API dependencies,\n // we'll just verify the method exists for now\n $this->assertIsCallable([$this->client, 'getOwners']);\n }\n\n public function testCreateMeeting(): void\n {\n $payload = [\n 'properties' => [\n 'hs_meeting_title' => 'Test Meeting',\n 'hs_meeting_start_time' => '2024-01-01T10:00:00Z',\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['id' => 'meeting123']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with('/crm/v3/objects/meetings', 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->createMeeting($payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testUpdateMeeting(): void\n {\n $meetingId = 'meeting123';\n $payload = [\n 'properties' => [\n 'hs_meeting_title' => 'Updated Meeting',\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['id' => $meetingId, 'updated' => true]);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v3/objects/meetings/{$meetingId}\", 'PATCH', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->updateMeeting($meetingId, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testAddAssociations(): void\n {\n $objectType = 'deals';\n $associationType = 'contacts';\n $payload = [\n 'inputs' => [\n ['from' => ['id' => 'deal1'], 'to' => ['id' => 'contact1']],\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['status' => 'success']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v4/associations/{$objectType}/{$associationType}/batch/create\", 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->addAssociations($objectType, $associationType, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testRemoveAssociations(): void\n {\n $objectType = 'deals';\n $associationType = 'contacts';\n $payload = [\n 'inputs' => [\n ['from' => ['id' => 'deal1'], 'to' => ['id' => 'contact1']],\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['status' => 'success']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v4/associations/{$objectType}/{$associationType}/batch/archive\", 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->removeAssociations($objectType, $associationType, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n // -------------------------------------------------------------------------\n // search() / executeRequest() tests\n // -------------------------------------------------------------------------\n\n public function testSearchReturnsDecodedArrayOnSuccess(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n\n $this->config->method('getId')->willReturn(42);\n\n $payload = ['filters' => []];\n $responseData = ['results' => [['id' => '1']], 'total' => 1, 'paging' => []];\n $hubspotResponse = new HubspotResponse(new Response(200, [], json_encode($responseData)));\n\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->with('POST', Client::BASE_URL . '/crm/v3/objects/contacts/search', ['json' => $payload])\n ->willReturn($hubspotResponse);\n\n $result = $this->client->search('contacts', $payload);\n\n $this->assertSame($responseData, $result);\n\n Mockery::close();\n }\n\n public function testSearchThrowsRateLimitExceptionWhenCircuitBreakerActive(): void\n {\n $futureTimestamp = (string) (time() + 30);\n\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn($futureTimestamp);\n\n $this->config->method('getId')->willReturn(42);\n\n $this->hubspotClientMock->expects($this->never())->method('request');\n\n $this->expectException(RateLimitException::class);\n $this->expectExceptionMessage('Hubspot rate limit (cached circuit-breaker)');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchThrowsRateLimitExceptionAndSetsNxOnFresh429(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n // NX + TTL option array — exact TTL depends on parseRetryAfter, verified separately\n $redisMock->shouldReceive('set')\n ->once()\n ->with(\n 'hubspot:ratelimit:config:42',\n Mockery::type('string'),\n Mockery::on(fn ($opts) => is_array($opts) && in_array('nx', $opts, true))\n );\n\n $this->config->method('getId')->willReturn(42);\n\n $badRequest = new BadRequest('Rate limit exceeded', 429);\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->willThrowException($badRequest);\n\n $this->expectException(RateLimitException::class);\n $this->expectExceptionMessage('Hubspot returned 429');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchPropagatesNonRateLimitException(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n\n $this->config->method('getId')->willReturn(42);\n\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\HubspotException('Server error', 500);\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->willThrowException($exception);\n\n $this->expectException(\\SevenShores\\Hubspot\\Exceptions\\HubspotException::class);\n $this->expectExceptionMessage('Server error');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchCircuitBreakerRetryAfterComputedFromStoredTimestamp(): void\n {\n $remaining = 45;\n $futureTimestamp = (string) (time() + $remaining);\n\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn($futureTimestamp);\n\n $this->config->method('getId')->willReturn(1);\n\n try {\n $this->client->search('deals', []);\n $this->fail('Expected RateLimitException');\n } catch (RateLimitException $e) {\n $this->assertGreaterThanOrEqual(1, $e->getRetryAfter());\n $this->assertLessThanOrEqual($remaining, $e->getRetryAfter());\n } finally {\n Mockery::close();\n }\n }\n\n // -------------------------------------------------------------------------\n // isHubspotRateLimit() tests (private method — tested via reflection)\n // -------------------------------------------------------------------------\n\n private function callIsHubspotRateLimit(\\Throwable $e): bool\n {\n $method = new \\ReflectionMethod($this->client, 'isHubspotRateLimit');\n $method->setAccessible(true);\n\n return $method->invoke($this->client, $e);\n }\n\n public function testIsHubspotRateLimitReturnsTrueForBadRequest429(): void\n {\n $e = new BadRequest('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForDealApiException429(): void\n {\n $e = new DealApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForContactApiException429(): void\n {\n $e = new ContactApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForCompanyApiException429(): void\n {\n $e = new CompanyApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForGuzzleRequestException429(): void\n {\n $request = new \\GuzzleHttp\\Psr7\\Request('POST', 'https://api.hubapi.com/test');\n $response = new \\GuzzleHttp\\Psr7\\Response(429);\n $e = new \\GuzzleHttp\\Exception\\RequestException('Too Many Requests', $request, $response);\n\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsFalseForBadRequestNon429(): void\n {\n $e = new BadRequest('Bad Request', 400);\n $this->assertFalse($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsFalseForGenericException(): void\n {\n $e = new \\RuntimeException('Something went wrong');\n $this->assertFalse($this->callIsHubspotRateLimit($e));\n }\n\n // -------------------------------------------------------------------------\n // parseRetryAfter() tests (private method — tested via reflection)\n // -------------------------------------------------------------------------\n\n private function callParseRetryAfter(\\Throwable $e): int\n {\n $method = new \\ReflectionMethod($this->client, 'parseRetryAfter');\n $method->setAccessible(true);\n\n return $method->invoke($this->client, $e);\n }\n\n public function testParseRetryAfterReadsRetryAfterHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => ['30']]);\n $this->assertSame(30, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReadsLowercaseRetryAfterHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['retry-after' => ['15']]);\n $this->assertSame(15, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterHandlesScalarHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => '60']);\n $this->assertSame(60, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsDailyLimitDelay(): void\n {\n $e = new BadRequest('Daily rate limit exceeded');\n $this->assertSame(600, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsTenSecondlyDelay(): void\n {\n $e = new BadRequest('Ten secondly rate limit exceeded');\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsSecondlyDelay(): void\n {\n $e = new BadRequest('Secondly rate limit exceeded');\n $this->assertSame(1, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsDefaultWhenNoHint(): void\n {\n $e = new BadRequest('Rate limit exceeded');\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterIgnoresNonNumericHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => ['not-a-number']]);\n // Falls through to message parsing; \"Too Many Requests\" has no known keyword → default 10\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n}","depth":4,"bounds":{"left":0.124667555,"top":0.17158818,"width":0.42852393,"height":0.8284118},"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Hubspot;\n\nuse GuzzleHttp\\Psr7\\Response;\nuse HubSpot\\Client\\Crm\\Associations\\Api\\BatchApi;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\BatchResponsePublicAssociationMulti;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\PublicObjectId;\nuse HubSpot\\Client\\Crm\\Companies\\ApiException as CompanyApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\ApiException as ContactApiException;\nuse HubSpot\\Client\\Crm\\Deals\\Api\\BasicApi as DealsBasicApi;\nuse HubSpot\\Client\\Crm\\Deals\\ApiException as DealApiException;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectWithAssociations as DealWithAssociations;\nuse HubSpot\\Client\\Crm\\Pipelines\\Api\\PipelinesApi;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\CollectionResponsePipeline;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Error;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Pipeline;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\PipelineStage;\nuse HubSpot\\Client\\Crm\\Properties\\Api\\CoreApi;\nuse HubSpot\\Client\\Crm\\Properties\\Model\\Property;\nuse HubSpot\\Discovery as HubSpotDiscovery;\nuse HubSpot\\Discovery\\Crm\\Deals\\Discovery as DealsDiscovery;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\HubspotTokenManager;\nuse Jiminny\\Services\\Crm\\Hubspot\\Pagination\\HubspotPaginationService;\nuse Jiminny\\Services\\SocialAccountService;\nuse League\\OAuth2\\Client\\Token\\AccessToken;\nuse Mockery;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse PHPUnit\\Framework\\TestCase;\nuse Psr\\Log\\NullLogger;\nuse SevenShores\\Hubspot\\Endpoints\\Engagements;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Factory;\nuse SevenShores\\Hubspot\\Http\\Client as HubspotClient;\nuse SevenShores\\Hubspot\\Http\\Response as HubspotResponse;\n\n/**\n * @runTestsInSeparateProcesses\n *\n * @preserveGlobalState disabled\n */\nclass ClientTest extends TestCase\n{\n private const string RESPONSE_TYPE_STAGE_FIELD = 'stage';\n private const string RESPONSE_TYPE_PIPELINE_FIELD = 'pipeline';\n private const string RESPONSE_TYPE_REGULAR_FIELD = 'regular';\n /**\n * @var Client&MockObject\n */\n private Client $client;\n private Configuration $config;\n\n /**\n * @var SocialAccountService&MockObject\n */\n private SocialAccountService $socialAccountServiceMock;\n\n /**\n * @var HubspotPaginationService&MockObject\n */\n private HubspotPaginationService $paginationServiceMock;\n\n /**\n * @var HubspotTokenManager&MockObject\n */\n private HubspotTokenManager $tokenManagerMock;\n\n /**\n * @var CoreApi&MockObject\n */\n private CoreApi $coreApiMock;\n\n /**\n * @var PipelinesApi&MockObject\n */\n private PipelinesApi $pipelinesApiMock;\n\n /**\n * @var BatchApi&MockObject\n */\n private BatchApi $associationsBatchApiMock;\n\n /**\n * @var DealsBasicApi&MockObject\n */\n private DealsBasicApi $dealsBasicApiMock;\n\n /**\n * @var Engagements&MockObject\n */\n private $engagementsMock;\n\n /**\n * @var HubspotClient&MockObject\n */\n private HubspotClient $hubspotClientMock;\n\n protected function setUp(): void\n {\n // Create mocks for dependencies\n $this->socialAccountServiceMock = $this->createMock(SocialAccountService::class);\n $this->paginationServiceMock = $this->createMock(HubspotPaginationService::class);\n $this->tokenManagerMock = $this->createMock(HubspotTokenManager::class);\n\n // Create a real Client instance with mocked dependencies\n // Create a partial mock only for the methods we need to mock\n $client = $this->createPartialMock(Client::class, ['getInstance', 'getNewInstance', 'makeRequest']);\n $client->setLogger(new NullLogger());\n\n // Inject the real dependencies using reflection\n $reflection = new \\ReflectionClass(Client::class);\n\n $accountServiceProperty = $reflection->getProperty('accountService');\n $accountServiceProperty->setAccessible(true);\n $accountServiceProperty->setValue($client, $this->socialAccountServiceMock);\n\n $paginationServiceProperty = $reflection->getProperty('paginationService');\n $paginationServiceProperty->setAccessible(true);\n $paginationServiceProperty->setValue($client, $this->paginationServiceMock);\n\n $tokenManagerProperty = $reflection->getProperty('tokenManager');\n $tokenManagerProperty->setAccessible(true);\n $tokenManagerProperty->setValue($client, $this->tokenManagerMock);\n $factoryMock = $this->createMock(Factory::class);\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $engagementsMock = $this->createMock(\\SevenShores\\Hubspot\\Endpoints\\Engagements::class);\n\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n $factoryMock->method('__call')->with('engagements')->willReturn($engagementsMock);\n\n $client->method('getInstance')->willReturn($factoryMock);\n\n $discoveryMock = $this->createMock(HubSpotDiscovery\\Discovery::class);\n $crmMock = $this->createMock(HubSpotDiscovery\\Crm\\Discovery::class);\n $associationsMock = $this->createMock(HubSpotDiscovery\\Crm\\Associations\\Discovery::class);\n $propertiesMock = $this->createMock(HubSpotDiscovery\\Crm\\Properties\\Discovery::class);\n $pipelinesMock = $this->createMock(HubSpotDiscovery\\Crm\\Pipelines\\Discovery::class);\n $coreApiMock = $this->createMock(CoreApi::class);\n $pipelinesApiMock = $this->createMock(PipelinesApi::class);\n $associationsBatchApiMock = $this->createMock(BatchApi::class);\n $dealsBasicApiMock = $this->createMock(DealsBasicApi::class);\n\n $propertiesMock->method('__call')->with('coreApi')->willReturn($coreApiMock);\n $pipelinesMock->method('__call')->with('pipelinesApi')->willReturn($pipelinesApiMock);\n $associationsMock->method('__call')->with('batchApi')->willReturn($associationsBatchApiMock);\n $dealsDiscoveryMock = $this->createMock(DealsDiscovery::class);\n $dealsDiscoveryMock->method('__call')->with('basicApi')->willReturn($dealsBasicApiMock);\n\n $returnMap = ['properties' => $propertiesMock, 'pipelines' => $pipelinesMock, 'associations' => $associationsMock, 'deals' => $dealsDiscoveryMock];\n $crmMock->method('__call')\n ->willReturnCallback(static fn (string $name) => $returnMap[$name])\n ;\n\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n\n $this->client = $client;\n $this->config = $this->createMock(Configuration::class);\n $this->client->setConfiguration($this->config);\n $this->coreApiMock = $coreApiMock;\n $this->pipelinesApiMock = $pipelinesApiMock;\n $this->associationsBatchApiMock = $associationsBatchApiMock;\n $this->dealsBasicApiMock = $dealsBasicApiMock;\n $this->engagementsMock = $engagementsMock;\n $this->hubspotClientMock = $hubspotClientMock;\n }\n\n public function testGetMinimumApiVersion(): void\n {\n $this->assertIsString($this->client->getMinimumApiVersion());\n }\n\n public function testGetInstance(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $client = new Client($socialAccountService, $paginationService, $tokenManager);\n $client->setBaseUrl('https://example.com');\n $client->setAccessToken(new AccessToken(['access_token' => 'foo']));\n $factory = $client->getInstance();\n\n $this->assertEquals('foo', $factory->getClient()->key);\n }\n\n public function testGetNewInstance(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $client = new Client($socialAccountService, $paginationService, $tokenManager);\n $client->setAccessToken(new AccessToken(['access_token' => 'foo']));\n\n $factory = $client->getNewInstance();\n $token = $factory->auth()->oAuth()->accessTokensApi()->getConfig()->getAccessToken();\n $this->assertEquals('foo', $token);\n }\n\n public function testFetchProperty(): void\n {\n $this->coreApiMock->method('getByName')->willReturn($this->generateProperty());\n\n $this->assertEquals(\n 'some_property',\n $this->client->fetchProperty('foo', 'test')['name']\n );\n }\n\n public function testFetchPropertyOptions(): void\n {\n $this->coreApiMock->method('getByName')->willReturn($this->generateProperty());\n\n $this->assertEquals(\n [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n $this->client->fetchPropertyOptions('foo', 'test')\n );\n }\n\n public function testFetchCallDispositions(): void\n {\n $this->engagementsMock\n ->method('getCallDispositions')\n ->willReturn($this->generateHubSpotResponse([\n [\n 'id' => 1,\n 'label' => 'foo',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'bar',\n 'deleted' => false,\n ],\n ]))\n ;\n\n $this->assertEquals(\n [\n [\n 'id' => 1,\n 'label' => 'foo',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'bar',\n 'deleted' => false,\n ],\n ],\n $this->client->fetchCallDispositions()\n );\n }\n\n public function testFetchDispositionFieldOptions(): void\n {\n $this->engagementsMock->method('getCallDispositions')\n ->willReturn($this->generateHubSpotResponse(\n [\n [\n 'id' => 1,\n 'label' => 'Label 1',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'Label 2',\n 'deleted' => true,\n ],\n ]\n ))\n ;\n\n $this->assertEquals(\n [\n [\n 'value' => 1,\n 'id' => 1,\n 'label' => 'Label 1',\n ],\n ],\n $this->client->fetchDispositionFieldOptions()\n );\n }\n\n public function testFetchOpportunityPipelineStages(): void\n {\n /**\n * @TODO: The class CollectionResponsePipeline will be deprecated in the next Hubspot version\n */\n $this->pipelinesApiMock\n ->method('getAll')\n ->with('deals')\n ->willReturn(new CollectionResponsePipeline([\n 'results' => [$this->generatePipeline()],\n ]))\n ;\n\n $this->assertEquals(\n [\n ['id' => 'foo', 'label' => 'bar'],\n ['id' => 'baz', 'label' => 'qux'],\n ],\n $this->client->fetchOpportunityPipelineStages()\n );\n }\n\n public function testFetchOpportunityPipelineStagesErrorResponse(): void\n {\n $this->pipelinesApiMock\n ->method('getAll')\n ->with('deals')\n ->willReturn(new Error(['message' => 'test error']))\n ;\n\n $this->assertEmpty($this->client->fetchOpportunityPipelineStages());\n }\n\n #[\\PHPUnit\\Framework\\Attributes\\DataProvider('meetingOutcomeFieldProvider')]\n public function testFetchMeetingOutcomeFieldOptions(string $fieldId, string $expectedEndpoint): void\n {\n $field = new Field(['crm_provider_id' => $fieldId]);\n\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->with('GET', $expectedEndpoint)\n ->willReturn($this->generateHubSpotResponse(\n [\n 'options' => [\n ['value' => 'option_1', 'label' => 'Option 1', 'displayOrder' => 0],\n ['value' => 'option_2', 'label' => 'Option 2', 'displayOrder' => 1],\n ['value' => 'option_3', 'label' => 'Option 3', 'displayOrder' => 2],\n ],\n ]\n ))\n ;\n\n $this->assertEquals(\n [\n ['id' => 'option_1', 'value' => 'option_1', 'label' => 'Option 1', 'display_order' => 0],\n ['id' => 'option_2', 'value' => 'option_2', 'label' => 'Option 2', 'display_order' => 1],\n ['id' => 'option_3', 'value' => 'option_3', 'label' => 'Option 3', 'display_order' => 2],\n ],\n $this->client->fetchMeetingOutcomeFieldOptions($field)\n );\n }\n\n public static function meetingOutcomeFieldProvider(): array\n {\n return [\n 'meeting outcome field' => [\n 'meetingOutcome',\n 'https://api.hubapi.com/crm/v3/properties/meeting/hs_meeting_outcome',\n ],\n 'activity type field' => [\n 'foobar',\n 'https://api.hubapi.com/crm/v3/properties/call/hs_activity_type',\n ],\n ];\n }\n\n #[\\PHPUnit\\Framework\\Attributes\\DataProvider('opportunityFieldOptionsProvider')]\n public function testFetchOpportunityFieldOptions(Field $field, string $type): void\n {\n $responses = [\n self::RESPONSE_TYPE_STAGE_FIELD => [\n ['id' => 'foo', 'label' => 'bar'],\n ['id' => 'baz', 'label' => 'qux'],\n ],\n self::RESPONSE_TYPE_PIPELINE_FIELD => [\n ['id' => '123', 'label' => 'Sales'],\n ['id' => 'default', 'label' => 'CS'],\n ],\n self::RESPONSE_TYPE_REGULAR_FIELD => [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n ];\n\n $field = $this->createMock(Field::class);\n\n if ($type === self::RESPONSE_TYPE_REGULAR_FIELD) {\n $field->method('isStageField')->willReturn(false);\n $field->method('isPipelineField')->willReturn(false);\n $propertyResponse = $this->generateProperty();\n $this->coreApiMock->method('getByName')->willReturn($propertyResponse);\n }\n\n if ($type === self::RESPONSE_TYPE_STAGE_FIELD) {\n $field->method('isStageField')->willReturn(true);\n /**\n * @TODO: The class CollectionResponsePipeline will be deprecated in the next Hubspot version\n */\n\n $pipelineStagesResponse = new CollectionResponsePipeline([\n 'results' => [$this->generatePipeline()],\n ]);\n $this->pipelinesApiMock\n ->method('getAll')\n ->willReturn($pipelineStagesResponse);\n }\n\n if ($type === self::RESPONSE_TYPE_PIPELINE_FIELD) {\n $field->method('isStageField')->willReturn(false);\n $field->method('isPipelineField')->willReturn(true);\n $pipelineResponse = $this->generateHubSpotResponse([\n 'results' => [\n ['id' => '123', 'label' => 'Sales'],\n ['id' => 'default', 'label' => 'CS'],\n ],\n ]);\n $this->client\n ->method('makeRequest')\n ->with('/crm/v3/pipelines/deals')\n ->willReturn($pipelineResponse);\n }\n\n $this->assertEquals(\n $responses[$type],\n $this->client->fetchOpportunityFieldOptions($field)\n );\n }\n\n public static function opportunityFieldOptionsProvider(): array\n {\n return [\n 'stage field' => [\n 'field' => new Field(['crm_provider_id' => 'dealstage']),\n 'type' => self::RESPONSE_TYPE_STAGE_FIELD,\n ],\n 'pipeline field' => [\n 'field' => new Field(['crm_provider_id' => 'pipeline']),\n 'type' => self::RESPONSE_TYPE_PIPELINE_FIELD,\n ],\n 'regular field' => [\n 'field' => new Field(['crm_provider_id' => 'some_property']),\n 'type' => self::RESPONSE_TYPE_REGULAR_FIELD,\n ],\n ];\n }\n\n private function generateHubSpotResponse(array $data): HubspotResponse\n {\n return new HubspotResponse(new Response(200, [], json_encode($data)));\n }\n\n private function generateProperty(): Property\n {\n return new Property([\n 'name' => 'some_property',\n 'options' => [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n ]);\n }\n\n private function generatePipeline(): Pipeline\n {\n return new Pipeline(['stages' => [\n new PipelineStage(['id' => 'foo', 'label' => 'bar']),\n new PipelineStage(['id' => 'baz', 'label' => 'qux']),\n ]]);\n }\n\n public function testFetchOpportunityPipelines(): void\n {\n $this->client\n ->method('makeRequest')\n ->with('/crm/v3/pipelines/deals')\n ->willReturn($this->generateHubSpotResponse([\n 'results' => [\n ['id' => 'id_1', 'label' => 'Option 1', 'displayOrder' => 0],\n ['id' => 'id_2', 'label' => 'Option 2', 'displayOrder' => 1],\n ['id' => 'id_3', 'label' => 'Option 3', 'displayOrder' => 2],\n ],\n ]));\n\n $this->assertEquals(\n [\n ['id' => 'id_1', 'label' => 'Option 1'],\n ['id' => 'id_2', 'label' => 'Option 2'],\n ['id' => 'id_3', 'label' => 'Option 3'],\n ],\n $this->client->fetchOpportunityPipelines()\n );\n }\n\n public function testGetPaginatedData(): void\n {\n $expectedResults = [\n ['id' => 'id_1', 'properties' => []],\n ['id' => 'id_2', 'properties' => []],\n ['id' => 'id_3', 'properties' => []],\n ];\n\n // Mock the pagination service to return a generator and modify reference parameters\n $this->paginationServiceMock\n ->expects($this->once())\n ->method('getPaginatedDataGenerator')\n ->with(\n $this->client,\n ['payload_key' => 'payload_value'],\n 'foobar',\n 0,\n $this->anything(),\n $this->anything()\n )\n ->willReturnCallback(function ($client, $payload, $type, $offset, &$total, &$lastRecordId) use ($expectedResults) {\n $total = 3;\n $lastRecordId = 'id_3';\n foreach ($expectedResults as $result) {\n yield $result;\n }\n });\n\n $this->assertEquals(\n [\n 'results' => $expectedResults,\n 'total' => 3,\n 'last_record' => 'id_3',\n ],\n $this->client->getPaginatedData(['payload_key' => 'payload_value'], 'foobar')\n );\n }\n\n public function testGetAssociationsData(): void\n {\n $ids = ['1', '2', '3'];\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $responseResults = [];\n foreach ($ids as $id) {\n $from = new PublicObjectId();\n $from->setId($id);\n\n $to1 = new PublicObjectId();\n $to1->setId('contact_' . $id . '_1');\n $to2 = new PublicObjectId();\n $to2->setId('contact_' . $id . '_2');\n\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to1, $to2]);\n\n $responseResults[] = $result;\n }\n\n $batchResponse = new BatchResponsePublicAssociationMulti();\n $batchResponse->setResults($responseResults);\n\n $this->associationsBatchApiMock->expects($this->once())\n ->method('read')\n ->with(\n $this->equalTo($fromObject),\n $this->equalTo($toObject),\n $this->callback(function (BatchInputPublicObjectId $batchInput) use ($ids) {\n $inputIds = array_map(\n fn ($input) => $input->getId(),\n $batchInput->getInputs()\n );\n\n return $inputIds === $ids;\n })\n )\n ->willReturn($batchResponse);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $expectedResult = [\n '1' => ['contact_1_1', 'contact_1_2'],\n '2' => ['contact_2_1', 'contact_2_2'],\n '3' => ['contact_3_1', 'contact_3_2'],\n ];\n\n $this->assertEquals($expectedResult, $result);\n }\n\n public function testGetAssociationsDataHandlesException(): void\n {\n $ids = ['1', '2', '3'];\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $exception = new \\Exception('API Error');\n\n $this->associationsBatchApiMock->expects($this->once())\n ->method('read')\n ->with(\n $this->equalTo($fromObject),\n $this->equalTo($toObject),\n $this->callback(function ($batchInput) use ($ids) {\n return $batchInput instanceof \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId\n && count($batchInput->getInputs()) === count($ids);\n })\n )\n ->willThrowException($exception);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with(\n '[Hubspot] Failed to fetch associations',\n [\n 'from_object' => $fromObject,\n 'to_object' => $toObject,\n 'reason' => 'API Error',\n ]\n );\n\n $this->client->setLogger($loggerMock);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $this->assertEmpty($result);\n $this->assertIsArray($result);\n }\n\n public function testGetAssociationsDataWithLargeDataSet(): void\n {\n $ids = array_map(fn ($i) => (string) $i, range(1, 2500)); // More than 1000 items\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $firstBatchResponse = new BatchResponsePublicAssociationMulti();\n $firstBatchResults = array_map(function ($id) {\n $from = new PublicObjectId();\n $from->setId($id);\n $to = new PublicObjectId();\n $to->setId('contact_' . $id);\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to]);\n\n return $result;\n }, array_slice($ids, 0, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));\n $firstBatchResponse->setResults($firstBatchResults);\n\n $secondBatchResponse = new BatchResponsePublicAssociationMulti();\n $secondBatchResults = array_map(function ($id) {\n $from = new PublicObjectId();\n $from->setId($id);\n $to = new PublicObjectId();\n $to->setId('contact_' . $id);\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to]);\n\n return $result;\n }, array_slice($ids, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));\n $secondBatchResponse->setResults($secondBatchResults);\n\n $this->associationsBatchApiMock->expects($this->exactly(3))\n ->method('read')\n ->willReturnOnConsecutiveCalls($firstBatchResponse, $secondBatchResponse);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $this->assertCount(2500, $result);\n $this->assertArrayHasKey('1', $result);\n $this->assertArrayHasKey('2500', $result);\n $this->assertEquals(['contact_1'], $result['1']);\n $this->assertEquals(['contact_2500'], $result['2500']);\n }\n\n public function testGetContactByEmailSuccess(): void\n {\n $email = 'test@example.com';\n $fields = ['firstname', 'lastname', 'email'];\n\n $contactMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $contactMock->method('getId')->willReturn('12345');\n $contactMock->method('getProperties')->willReturn([\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n 'email' => 'test@example.com',\n ]);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, 'firstname,lastname,email', null, false, 'email')\n ->willReturn($contactMock);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new NullLogger());\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([\n 'id' => '12345',\n 'properties' => [\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n 'email' => 'test@example.com',\n ],\n ], $result);\n }\n\n public function testGetContactByEmailWithEmptyFields(): void\n {\n $email = 'test@example.com';\n $fields = [];\n\n $contactMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $contactMock->method('getId')->willReturn('12345');\n $contactMock->method('getProperties')->willReturn(['email' => 'test@example.com']);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, '', null, false, 'email')\n ->willReturn($contactMock);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new NullLogger());\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([\n 'id' => '12345',\n 'properties' => ['email' => 'test@example.com'],\n ], $result);\n }\n\n public function testGetContactByEmailApiException(): void\n {\n $email = 'nonexistent@example.com';\n $fields = ['firstname', 'lastname'];\n\n $exception = new \\HubSpot\\Client\\Crm\\Contacts\\ApiException('Contact not found', 404);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, 'firstname,lastname', null, false, 'email')\n ->willThrowException($exception);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('info')\n ->with(\n '[Hubspot] Failed to fetch contact',\n [\n 'email' => $email,\n 'reason' => 'Contact not found',\n ]\n );\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger($loggerMock);\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([], $result);\n }\n\n public function testGetOpportunityById(): void\n {\n $opportunityId = '12345';\n $expectedProperties = [\n 'dealname' => 'Test Opportunity',\n 'amount' => '1000.00',\n 'closedate' => '2024-12-31T23:59:59.999Z',\n 'dealstage' => 'presentationscheduled',\n 'pipeline' => 'default',\n ];\n\n $mockHubspotOpportunity = $this->createMock(DealWithAssociations::class);\n $mockHubspotOpportunity->method('getProperties')->willReturn((object) $expectedProperties);\n $mockHubspotOpportunity->method('getId')->willReturn($opportunityId);\n $now = new \\DateTimeImmutable();\n $mockHubspotOpportunity->method('getCreatedAt')->willReturn($now);\n $mockHubspotOpportunity->method('getUpdatedAt')->willReturn($now);\n $mockHubspotOpportunity->method('getArchived')->willReturn(false);\n\n $this->dealsBasicApiMock\n ->expects($this->once())\n ->method('getById')\n ->willReturn($mockHubspotOpportunity);\n\n // Assuming Client::getOpportunityById processes the SimplePublicObject and returns an associative array.\n // The structure might be like: ['id' => ..., 'properties' => [...], 'createdAt' => ..., ...]\n // Adjust assertions below based on the actual return structure of your method.\n $result = $this->client->getOpportunityById($opportunityId, ['test', 'test']);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertEquals($opportunityId, $result['id']);\n }\n\n public function testGetContactById(): void\n {\n $crmId = 'contact-123';\n $fields = ['firstname', 'lastname'];\n $expectedProperties = ['firstname' => 'John', 'lastname' => 'Doe'];\n\n $mockContact = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $mockContact->method('getId')->willReturn($crmId);\n $mockContact->method('getProperties')->willReturn((object) $expectedProperties);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($crmId, 'firstname,lastname')\n ->willReturn($mockContact);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new \\Psr\\Log\\NullLogger());\n\n $result = $client->getContactById($crmId, $fields);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertArrayHasKey('properties', $result);\n $this->assertEquals($crmId, $result['id']);\n $this->assertEquals((object) $expectedProperties, $result['properties']);\n }\n\n public function testGetAccountById(): void\n {\n $crmId = 'account-123';\n $fields = ['name', 'industry'];\n $expectedProperties = ['name' => 'Acme Corp', 'industry' => 'Technology'];\n\n $mockCompany = $this->createMock(\\HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectWithAssociations::class);\n $mockCompany->method('getId')->willReturn($crmId);\n $mockCompany->method('getProperties')->willReturn((object) $expectedProperties);\n\n $companiesApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Companies\\Api\\BasicApi::class);\n $companiesApiMock->expects($this->once())\n ->method('getById')\n ->with($crmId, 'name,industry')\n ->willReturn($mockCompany);\n\n $companiesDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Companies\\Discovery::class);\n $companiesDiscoveryMock->method('__call')->with('basicApi')->willReturn($companiesApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($companiesDiscoveryMock) {\n if ($name === 'companies') {\n return $companiesDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new \\Psr\\Log\\NullLogger());\n\n $result = $client->getAccountById($crmId, $fields);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertArrayHasKey('properties', $result);\n $this->assertEquals($crmId, $result['id']);\n $this->assertEquals((object) $expectedProperties, $result['properties']);\n }\n\n public function testEnsureValidTokenWithNoTokenUpdate(): void\n {\n $socialAccountMock = $this->createMock(SocialAccount::class);\n\n // Set up OAuth account\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, $socialAccountMock);\n\n $originalToken = 'original_token';\n $accessTokenProperty = $reflection->getProperty('accessToken');\n $accessTokenProperty->setAccessible(true);\n $accessTokenProperty->setValue($this->client, $originalToken);\n\n // Mock token manager to return null (no refresh needed)\n $this->tokenManagerMock\n ->expects($this->once())\n ->method('ensureValidToken')\n ->with($socialAccountMock)\n ->willReturn(null);\n\n // Call ensureValidToken\n $this->client->ensureValidToken();\n\n // Verify access token was not changed\n $this->assertEquals($originalToken, $accessTokenProperty->getValue($this->client));\n }\n\n\n\n\n\n\n public function testGetPaginatedDataGeneratorDelegatesToPaginationService(): void\n {\n $payload = ['filters' => []];\n $type = 'contacts';\n $offset = 0;\n $total = 0;\n $lastRecordId = null;\n\n $expectedResults = [\n ['id' => 'id_1', 'properties' => []],\n ['id' => 'id_2', 'properties' => []],\n ];\n\n // Mock the pagination service to return a generator\n $this->paginationServiceMock\n ->expects($this->once())\n ->method('getPaginatedDataGenerator')\n ->with(\n $this->client,\n $payload,\n $type,\n $offset,\n $this->anything(),\n $this->anything()\n )\n ->willReturnCallback(function () use ($expectedResults) {\n foreach ($expectedResults as $result) {\n yield $result;\n }\n });\n\n // Execute the pagination\n $results = [];\n foreach ($this->client->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastRecordId) as $result) {\n $results[] = $result;\n }\n\n $this->assertCount(2, $results);\n $this->assertEquals('id_1', $results[0]['id']);\n $this->assertEquals('id_2', $results[1]['id']);\n }\n\n public function testEnsureValidTokenDelegatesToTokenManager(): void\n {\n $socialAccountMock = $this->createMock(SocialAccount::class);\n\n // Set up OAuth account\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, $socialAccountMock);\n\n // Mock token manager to return new token\n $this->tokenManagerMock\n ->expects($this->once())\n ->method('ensureValidToken')\n ->with($socialAccountMock)\n ->willReturn('new_access_token');\n\n // Call ensureValidToken\n $this->client->ensureValidToken();\n\n // Verify access token was updated\n $accessTokenProperty = $reflection->getProperty('accessToken');\n $accessTokenProperty->setAccessible(true);\n $this->assertEquals('new_access_token', $accessTokenProperty->getValue($this->client));\n }\n\n public function testGetOwnersArchivedWithValidResponse(): void\n {\n $responseData = [\n 'results' => [\n [\n 'id' => '123',\n 'email' => 'owner1@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'John',\n 'lastName' => 'Doe',\n 'userId' => 456,\n 'userIdIncludingInactive' => 789,\n 'createdAt' => '2023-01-01T12:00:00Z',\n 'updatedAt' => '2023-01-02T12:00:00Z',\n 'archived' => true,\n ],\n ],\n ];\n\n // Create a mock response object\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn($responseData);\n\n // Set up the client to return our test data\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=true'\n )\n ->willReturn($response);\n\n // Call the method\n $result = $this->client->getOwnersArchived(true);\n\n // Assert the results\n $this->assertCount(1, $result);\n $this->assertEquals('123', $result[0]->getId());\n $this->assertEquals('owner1@example.com', $result[0]->getEmail());\n $this->assertEquals('John Doe', $result[0]->getFullName());\n $this->assertTrue($result[0]->isArchived());\n }\n\n public function testGetOwnersArchivedWithEmptyResponse(): void\n {\n // Create a mock response object with empty results\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn(['results' => []]);\n\n // Set up the client to return empty results\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=false'\n )\n ->willReturn($response);\n\n // Call the method\n $result = $this->client->getOwnersArchived(false);\n\n // Assert the results\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithInvalidResponse(): void\n {\n // Create a mock response that will throw an exception when toArray is called\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willThrowException(new \\InvalidArgumentException('Invalid JSON'));\n\n // Set up the client to return the problematic response\n $this->client->method('makeRequest')\n ->willReturn($response);\n\n // Mock the logger to expect an error message\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with($this->stringContains('Failed to fetch owners'));\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n // Call the method and expect an empty array on error\n $result = $this->client->getOwnersArchived(true);\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithHttpError(): void\n {\n // Set up the client to throw an exception\n $this->client->method('makeRequest')\n ->willThrowException(new \\Exception('HTTP Error'));\n\n // Mock the logger to expect an error message\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with($this->stringContains('Failed to fetch owners'));\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n // Call the method and expect an empty array on error\n $result = $this->client->getOwnersArchived(false);\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithOwnerCreationException(): void\n {\n $responseData = [\n 'results' => [\n [\n 'id' => '123',\n 'email' => 'valid@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'John',\n 'lastName' => 'Doe',\n 'userId' => 456,\n 'userIdIncludingInactive' => 789,\n 'createdAt' => '2023-01-01T12:00:00Z',\n 'updatedAt' => '2023-01-02T12:00:00Z',\n 'archived' => false,\n ],\n [\n 'id' => '456',\n 'email' => 'invalid@example.com',\n 'type' => 'PERSON',\n 'createdAt' => 'invalid-date-format',\n ],\n [\n 'id' => '789',\n 'email' => 'another@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'Jane',\n 'lastName' => 'Smith',\n 'userId' => 999,\n 'userIdIncludingInactive' => 888,\n 'createdAt' => '2023-01-03T12:00:00Z',\n 'updatedAt' => '2023-01-04T12:00:00Z',\n 'archived' => false,\n ],\n ],\n ];\n\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn($responseData);\n\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=false'\n )\n ->willReturn($response);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with(\n '[HubSpot] Failed to process owner data',\n $this->callback(function ($context) {\n return isset($context['result']) &&\n isset($context['error']) &&\n $context['result']['id'] === '456' &&\n $context['result']['email'] === 'invalid@example.com' &&\n str_contains($context['error'], 'invalid-date-format');\n })\n );\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n $result = $this->client->getOwnersArchived(false);\n\n $this->assertIsArray($result);\n $this->assertCount(2, $result);\n $this->assertEquals('123', $result[0]->getId());\n $this->assertEquals('valid@example.com', $result[0]->getEmail());\n $this->assertEquals('789', $result[1]->getId());\n $this->assertEquals('another@example.com', $result[1]->getEmail());\n }\n\n public function testMakeRequestWithGetMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'GET',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n [],\n null,\n true\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'GET');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithGetMethodAndQueryString(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'GET',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n [],\n 'limit=10&offset=0',\n true\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'GET', [], 'limit=10&offset=0');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPostMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'POST',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'POST', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPutMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'firstname' => 'Jane',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345', 'updated' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'PUT',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'PUT', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPatchMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'email' => 'newemail@example.com',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345', 'patched' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'PATCH',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'PATCH', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithDeleteMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['deleted' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'DELETE',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => []]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'DELETE');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithEmptyPayload(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'ok']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'POST',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n ['json' => []]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'POST', []);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestPrependsBaseUrl(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n $this->anything(),\n 'https://api.hubapi.com/test/endpoint',\n $this->anything()\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $client->makeRequest('/test/endpoint', 'GET');\n }\n\n public function testGetOpportunitiesByIds(): void\n {\n $crmIds = ['deal1', 'deal2', 'deal3'];\n $fields = ['dealname', 'amount'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getOpportunitiesByIds'));\n }\n\n public function testGetCompaniesByIds(): void\n {\n $crmIds = ['company1', 'company2'];\n $fields = ['name', 'industry'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getCompaniesByIds'));\n }\n\n public function testGetContactsByIds(): void\n {\n $crmIds = ['contact1', 'contact2'];\n $fields = ['firstname', 'lastname'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getContactsByIds'));\n }\n\n public function testBatchReadObjectsEmptyIds(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $result = $method->invokeArgs($this->client, ['deals', [], ['dealname']]);\n\n $this->assertEquals([], $result);\n }\n\n public function testBatchReadObjectsExceedsBatchSize(): void\n {\n $crmIds = array_fill(0, 101, 'deal_id'); // 101 IDs, exceeds limit of 100\n\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $this->expectException(\\InvalidArgumentException::class);\n $this->expectExceptionMessage('Batch size cannot exceed 100 deals');\n\n $method->invokeArgs($this->client, ['deals', $crmIds, ['dealname']]);\n }\n\n public function testBatchReadObjectsUnsupportedObjectType(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $this->expectException(\\Jiminny\\Exceptions\\CrmException::class);\n $this->expectExceptionMessage('Failed to batch fetch unsupported');\n\n $method->invokeArgs($this->client, ['unsupported', ['id1'], ['field1']]);\n }\n\n public function testIsUnauthorizedExceptionWithBadRequest401(): void\n {\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\BadRequest('Unauthorized', 401);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithBadRequestNon401(): void\n {\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\BadRequest('Bad Request', 400);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertFalse($result);\n }\n\n public function testIsUnauthorizedExceptionWithGuzzleRequestException(): void\n {\n $response = new Response(401, [], 'Unauthorized');\n $exception = new \\GuzzleHttp\\Exception\\RequestException('Unauthorized', new \\GuzzleHttp\\Psr7\\Request('GET', 'test'), $response);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithGuzzleClientException(): void\n {\n $exception = new \\GuzzleHttp\\Exception\\ClientException('Unauthorized', new \\GuzzleHttp\\Psr7\\Request('GET', 'test'), new Response(401));\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithStringMatching(): void\n {\n $exception = new \\Exception('HTTP 401 Unauthorized error occurred');\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithNonUnauthorized(): void\n {\n $exception = new \\Exception('Some other error');\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertFalse($result);\n }\n\n public function testEnsureValidTokenWithNullOauthAccount(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, null);\n\n // Should not throw exception and not call token manager\n $this->tokenManagerMock->expects($this->never())->method('ensureValidToken');\n\n $this->client->ensureValidToken();\n\n $this->assertTrue(true); // Test passes if no exception is thrown\n }\n\n public function testGetConfig(): void\n {\n $result = $this->client->getConfig();\n\n $this->assertSame($this->config, $result);\n }\n\n public function testGetOwners(): void\n {\n // Test that the method exists and can be called\n $this->assertTrue(method_exists($this->client, 'getOwners'));\n\n // Since getOwners has complex HubSpot API dependencies,\n // we'll just verify the method exists for now\n $this->assertIsCallable([$this->client, 'getOwners']);\n }\n\n public function testCreateMeeting(): void\n {\n $payload = [\n 'properties' => [\n 'hs_meeting_title' => 'Test Meeting',\n 'hs_meeting_start_time' => '2024-01-01T10:00:00Z',\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['id' => 'meeting123']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with('/crm/v3/objects/meetings', 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->createMeeting($payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testUpdateMeeting(): void\n {\n $meetingId = 'meeting123';\n $payload = [\n 'properties' => [\n 'hs_meeting_title' => 'Updated Meeting',\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['id' => $meetingId, 'updated' => true]);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v3/objects/meetings/{$meetingId}\", 'PATCH', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->updateMeeting($meetingId, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testAddAssociations(): void\n {\n $objectType = 'deals';\n $associationType = 'contacts';\n $payload = [\n 'inputs' => [\n ['from' => ['id' => 'deal1'], 'to' => ['id' => 'contact1']],\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['status' => 'success']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v4/associations/{$objectType}/{$associationType}/batch/create\", 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->addAssociations($objectType, $associationType, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testRemoveAssociations(): void\n {\n $objectType = 'deals';\n $associationType = 'contacts';\n $payload = [\n 'inputs' => [\n ['from' => ['id' => 'deal1'], 'to' => ['id' => 'contact1']],\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['status' => 'success']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v4/associations/{$objectType}/{$associationType}/batch/archive\", 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->removeAssociations($objectType, $associationType, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n // -------------------------------------------------------------------------\n // search() / executeRequest() tests\n // -------------------------------------------------------------------------\n\n public function testSearchReturnsDecodedArrayOnSuccess(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n\n $this->config->method('getId')->willReturn(42);\n\n $payload = ['filters' => []];\n $responseData = ['results' => [['id' => '1']], 'total' => 1, 'paging' => []];\n $hubspotResponse = new HubspotResponse(new Response(200, [], json_encode($responseData)));\n\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->with('POST', Client::BASE_URL . '/crm/v3/objects/contacts/search', ['json' => $payload])\n ->willReturn($hubspotResponse);\n\n $result = $this->client->search('contacts', $payload);\n\n $this->assertSame($responseData, $result);\n\n Mockery::close();\n }\n\n public function testSearchThrowsRateLimitExceptionWhenCircuitBreakerActive(): void\n {\n $futureTimestamp = (string) (time() + 30);\n\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn($futureTimestamp);\n\n $this->config->method('getId')->willReturn(42);\n\n $this->hubspotClientMock->expects($this->never())->method('request');\n\n $this->expectException(RateLimitException::class);\n $this->expectExceptionMessage('Hubspot rate limit (cached circuit-breaker)');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchThrowsRateLimitExceptionAndSetsNxOnFresh429(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n // NX + TTL option array — exact TTL depends on parseRetryAfter, verified separately\n $redisMock->shouldReceive('set')\n ->once()\n ->with(\n 'hubspot:ratelimit:config:42',\n Mockery::type('string'),\n Mockery::on(fn ($opts) => is_array($opts) && in_array('nx', $opts, true))\n );\n\n $this->config->method('getId')->willReturn(42);\n\n $badRequest = new BadRequest('Rate limit exceeded', 429);\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->willThrowException($badRequest);\n\n $this->expectException(RateLimitException::class);\n $this->expectExceptionMessage('Hubspot returned 429');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchPropagatesNonRateLimitException(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n\n $this->config->method('getId')->willReturn(42);\n\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\HubspotException('Server error', 500);\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->willThrowException($exception);\n\n $this->expectException(\\SevenShores\\Hubspot\\Exceptions\\HubspotException::class);\n $this->expectExceptionMessage('Server error');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchCircuitBreakerRetryAfterComputedFromStoredTimestamp(): void\n {\n $remaining = 45;\n $futureTimestamp = (string) (time() + $remaining);\n\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn($futureTimestamp);\n\n $this->config->method('getId')->willReturn(1);\n\n try {\n $this->client->search('deals', []);\n $this->fail('Expected RateLimitException');\n } catch (RateLimitException $e) {\n $this->assertGreaterThanOrEqual(1, $e->getRetryAfter());\n $this->assertLessThanOrEqual($remaining, $e->getRetryAfter());\n } finally {\n Mockery::close();\n }\n }\n\n // -------------------------------------------------------------------------\n // isHubspotRateLimit() tests (private method — tested via reflection)\n // -------------------------------------------------------------------------\n\n private function callIsHubspotRateLimit(\\Throwable $e): bool\n {\n $method = new \\ReflectionMethod($this->client, 'isHubspotRateLimit');\n $method->setAccessible(true);\n\n return $method->invoke($this->client, $e);\n }\n\n public function testIsHubspotRateLimitReturnsTrueForBadRequest429(): void\n {\n $e = new BadRequest('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForDealApiException429(): void\n {\n $e = new DealApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForContactApiException429(): void\n {\n $e = new ContactApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForCompanyApiException429(): void\n {\n $e = new CompanyApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForGuzzleRequestException429(): void\n {\n $request = new \\GuzzleHttp\\Psr7\\Request('POST', 'https://api.hubapi.com/test');\n $response = new \\GuzzleHttp\\Psr7\\Response(429);\n $e = new \\GuzzleHttp\\Exception\\RequestException('Too Many Requests', $request, $response);\n\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsFalseForBadRequestNon429(): void\n {\n $e = new BadRequest('Bad Request', 400);\n $this->assertFalse($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsFalseForGenericException(): void\n {\n $e = new \\RuntimeException('Something went wrong');\n $this->assertFalse($this->callIsHubspotRateLimit($e));\n }\n\n // -------------------------------------------------------------------------\n // parseRetryAfter() tests (private method — tested via reflection)\n // -------------------------------------------------------------------------\n\n private function callParseRetryAfter(\\Throwable $e): int\n {\n $method = new \\ReflectionMethod($this->client, 'parseRetryAfter');\n $method->setAccessible(true);\n\n return $method->invoke($this->client, $e);\n }\n\n public function testParseRetryAfterReadsRetryAfterHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => ['30']]);\n $this->assertSame(30, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReadsLowercaseRetryAfterHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['retry-after' => ['15']]);\n $this->assertSame(15, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterHandlesScalarHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => '60']);\n $this->assertSame(60, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsDailyLimitDelay(): void\n {\n $e = new BadRequest('Daily rate limit exceeded');\n $this->assertSame(600, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsTenSecondlyDelay(): void\n {\n $e = new BadRequest('Ten secondly rate limit exceeded');\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsSecondlyDelay(): void\n {\n $e = new BadRequest('Secondly rate limit exceeded');\n $this->assertSame(1, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsDefaultWhenNoHint(): void\n {\n $e = new BadRequest('Rate limit exceeded');\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterIgnoresNonNumericHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => ['not-a-number']]);\n // Falls through to message parsing; \"Too Many Requests\" has no known keyword → default 10\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.96276593,"top":0.07581804,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.9740692,"top":0.074221864,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.98138297,"top":0.074221864,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
4682894321548166980
|
4446428687123393012
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
ClientTest
Run 'ClientTest'
Debug 'ClientTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
144
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Hubspot;
use GuzzleHttp\Psr7\Response;
use HubSpot\Client\Crm\Associations\Api\BatchApi;
use HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId;
use HubSpot\Client\Crm\Associations\Model\BatchResponsePublicAssociationMulti;
use HubSpot\Client\Crm\Associations\Model\PublicObjectId;
use HubSpot\Client\Crm\Companies\ApiException as CompanyApiException;
use HubSpot\Client\Crm\Contacts\ApiException as ContactApiException;
use HubSpot\Client\Crm\Deals\Api\BasicApi as DealsBasicApi;
use HubSpot\Client\Crm\Deals\ApiException as DealApiException;
use HubSpot\Client\Crm\Deals\Model\SimplePublicObjectWithAssociations as DealWithAssociations;
use HubSpot\Client\Crm\Pipelines\Api\PipelinesApi;
use HubSpot\Client\Crm\Pipelines\Model\CollectionResponsePipeline;
use HubSpot\Client\Crm\Pipelines\Model\Error;
use HubSpot\Client\Crm\Pipelines\Model\Pipeline;
use HubSpot\Client\Crm\Pipelines\Model\PipelineStage;
use HubSpot\Client\Crm\Properties\Api\CoreApi;
use HubSpot\Client\Crm\Properties\Model\Property;
use HubSpot\Discovery as HubSpotDiscovery;
use HubSpot\Discovery\Crm\Deals\Discovery as DealsDiscovery;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\SocialAccount;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\HubspotTokenManager;
use Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService;
use Jiminny\Services\SocialAccountService;
use League\OAuth2\Client\Token\AccessToken;
use Mockery;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use SevenShores\Hubspot\Endpoints\Engagements;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Factory;
use SevenShores\Hubspot\Http\Client as HubspotClient;
use SevenShores\Hubspot\Http\Response as HubspotResponse;
/**
* @runTestsInSeparateProcesses
*
* @preserveGlobalState disabled
*/
class ClientTest extends TestCase
{
private const string RESPONSE_TYPE_STAGE_FIELD = 'stage';
private const string RESPONSE_TYPE_PIPELINE_FIELD = 'pipeline';
private const string RESPONSE_TYPE_REGULAR_FIELD = 'regular';
/**
* @var Client&MockObject
*/
private Client $client;
private Configuration $config;
/**
* @var SocialAccountService&MockObject
*/
private SocialAccountService $socialAccountServiceMock;
/**
* @var HubspotPaginationService&MockObject
*/
private HubspotPaginationService $paginationServiceMock;
/**
* @var HubspotTokenManager&MockObject
*/
private HubspotTokenManager $tokenManagerMock;
/**
* @var CoreApi&MockObject
*/
private CoreApi $coreApiMock;
/**
* @var PipelinesApi&MockObject
*/
private PipelinesApi $pipelinesApiMock;
/**
* @var BatchApi&MockObject
*/
private BatchApi $associationsBatchApiMock;
/**
* @var DealsBasicApi&MockObject
*/
private DealsBasicApi $dealsBasicApiMock;
/**
* @var Engagements&MockObject
*/
private $engagementsMock;
/**
* @var HubspotClient&MockObject
*/
private HubspotClient $hubspotClientMock;
protected function setUp(): void
{
// Create mocks for dependencies
$this->socialAccountServiceMock = $this->createMock(SocialAccountService::class);
$this->paginationServiceMock = $this->createMock(HubspotPaginationService::class);
$this->tokenManagerMock = $this->createMock(HubspotTokenManager::class);
// Create a real Client instance with mocked dependencies
// Create a partial mock only for the methods we need to mock
$client = $this->createPartialMock(Client::class, ['getInstance', 'getNewInstance', 'makeRequest']);
$client->setLogger(new NullLogger());
// Inject the real dependencies using reflection
$reflection = new \ReflectionClass(Client::class);
$accountServiceProperty = $reflection->getProperty('accountService');
$accountServiceProperty->setAccessible(true);
$accountServiceProperty->setValue($client, $this->socialAccountServiceMock);
$paginationServiceProperty = $reflection->getProperty('paginationService');
$paginationServiceProperty->setAccessible(true);
$paginationServiceProperty->setValue($client, $this->paginationServiceMock);
$tokenManagerProperty = $reflection->getProperty('tokenManager');
$tokenManagerProperty->setAccessible(true);
$tokenManagerProperty->setValue($client, $this->tokenManagerMock);
$factoryMock = $this->createMock(Factory::class);
$hubspotClientMock = $this->createMock(HubspotClient::class);
$engagementsMock = $this->createMock(\SevenShores\Hubspot\Endpoints\Engagements::class);
$factoryMock->method('getClient')->willReturn($hubspotClientMock);
$factoryMock->method('__call')->with('engagements')->willReturn($engagementsMock);
$client->method('getInstance')->willReturn($factoryMock);
$discoveryMock = $this->createMock(HubSpotDiscovery\Discovery::class);
$crmMock = $this->createMock(HubSpotDiscovery\Crm\Discovery::class);
$associationsMock = $this->createMock(HubSpotDiscovery\Crm\Associations\Discovery::class);
$propertiesMock = $this->createMock(HubSpotDiscovery\Crm\Properties\Discovery::class);
$pipelinesMock = $this->createMock(HubSpotDiscovery\Crm\Pipelines\Discovery::class);
$coreApiMock = $this->createMock(CoreApi::class);
$pipelinesApiMock = $this->createMock(PipelinesApi::class);
$associationsBatchApiMock = $this->createMock(BatchApi::class);
$dealsBasicApiMock = $this->createMock(DealsBasicApi::class);
$propertiesMock->method('__call')->with('coreApi')->willReturn($coreApiMock);
$pipelinesMock->method('__call')->with('pipelinesApi')->willReturn($pipelinesApiMock);
$associationsMock->method('__call')->with('batchApi')->willReturn($associationsBatchApiMock);
$dealsDiscoveryMock = $this->createMock(DealsDiscovery::class);
$dealsDiscoveryMock->method('__call')->with('basicApi')->willReturn($dealsBasicApiMock);
$returnMap = ['properties' => $propertiesMock, 'pipelines' => $pipelinesMock, 'associations' => $associationsMock, 'deals' => $dealsDiscoveryMock];
$crmMock->method('__call')
->willReturnCallback(static fn (string $name) => $returnMap[$name])
;
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client->method('getNewInstance')->willReturn($discoveryMock);
$this->client = $client;
$this->config = $this->createMock(Configuration::class);
$this->client->setConfiguration($this->config);
$this->coreApiMock = $coreApiMock;
$this->pipelinesApiMock = $pipelinesApiMock;
$this->associationsBatchApiMock = $associationsBatchApiMock;
$this->dealsBasicApiMock = $dealsBasicApiMock;
$this->engagementsMock = $engagementsMock;
$this->hubspotClientMock = $hubspotClientMock;
}
public function testGetMinimumApiVersion(): void
{
$this->assertIsString($this->client->getMinimumApiVersion());
}
public function testGetInstance(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManager = $this->createMock(HubspotTokenManager::class);
$client = new Client($socialAccountService, $paginationService, $tokenManager);
$client->setBaseUrl('[URL_WITH_CREDENTIALS] The class CollectionResponsePipeline will be deprecated in the next Hubspot version
*/
$this->pipelinesApiMock
->method('getAll')
->with('deals')
->willReturn(new CollectionResponsePipeline([
'results' => [$this->generatePipeline()],
]))
;
$this->assertEquals(
[
['id' => 'foo', 'label' => 'bar'],
['id' => 'baz', 'label' => 'qux'],
],
$this->client->fetchOpportunityPipelineStages()
);
}
public function testFetchOpportunityPipelineStagesErrorResponse(): void
{
$this->pipelinesApiMock
->method('getAll')
->with('deals')
->willReturn(new Error(['message' => 'test error']))
;
$this->assertEmpty($this->client->fetchOpportunityPipelineStages());
}
#[\PHPUnit\Framework\Attributes\DataProvider('meetingOutcomeFieldProvider')]
public function testFetchMeetingOutcomeFieldOptions(string $fieldId, string $expectedEndpoint): void
{
$field = new Field(['crm_provider_id' => $fieldId]);
$this->hubspotClientMock
->expects($this->once())
->method('request')
->with('GET', $expectedEndpoint)
->willReturn($this->generateHubSpotResponse(
[
'options' => [
['value' => 'option_1', 'label' => 'Option 1', 'displayOrder' => 0],
['value' => 'option_2', 'label' => 'Option 2', 'displayOrder' => 1],
['value' => 'option_3', 'label' => 'Option 3', 'displayOrder' => 2],
],
]
))
;
$this->assertEquals(
[
['id' => 'option_1', 'value' => 'option_1', 'label' => 'Option 1', 'display_order' => 0],
['id' => 'option_2', 'value' => 'option_2', 'label' => 'Option 2', 'display_order' => 1],
['id' => 'option_3', 'value' => 'option_3', 'label' => 'Option 3', 'display_order' => 2],
],
$this->client->fetchMeetingOutcomeFieldOptions($field)
);
}
public static function meetingOutcomeFieldProvider(): array
{
return [
'meeting outcome field' => [
'meetingOutcome',
'[URL_WITH_CREDENTIALS] The class CollectionResponsePipeline will be deprecated in the next Hubspot version
*/
$pipelineStagesResponse = new CollectionResponsePipeline([
'results' => [$this->generatePipeline()],
]);
$this->pipelinesApiMock
->method('getAll')
->willReturn($pipelineStagesResponse);
}
if ($type === self::RESPONSE_TYPE_PIPELINE_FIELD) {
$field->method('isStageField')->willReturn(false);
$field->method('isPipelineField')->willReturn(true);
$pipelineResponse = $this->generateHubSpotResponse([
'results' => [
['id' => '123', 'label' => 'Sales'],
['id' => 'default', 'label' => 'CS'],
],
]);
$this->client
->method('makeRequest')
->with('/crm/v3/pipelines/deals')
->willReturn($pipelineResponse);
}
$this->assertEquals(
$responses[$type],
$this->client->fetchOpportunityFieldOptions($field)
);
}
public static function opportunityFieldOptionsProvider(): array
{
return [
'stage field' => [
'field' => new Field(['crm_provider_id' => 'dealstage']),
'type' => self::RESPONSE_TYPE_STAGE_FIELD,
],
'pipeline field' => [
'field' => new Field(['crm_provider_id' => 'pipeline']),
'type' => self::RESPONSE_TYPE_PIPELINE_FIELD,
],
'regular field' => [
'field' => new Field(['crm_provider_id' => 'some_property']),
'type' => self::RESPONSE_TYPE_REGULAR_FIELD,
],
];
}
private function generateHubSpotResponse(array $data): HubspotResponse
{
return new HubspotResponse(new Response(200, [], json_encode($data)));
}
private function generateProperty(): Property
{
return new Property([
'name' => 'some_property',
'options' => [
[
'label' => 'label_1',
'value' => 'value_1',
],
[
'label' => 'label_2',
'value' => 'value_2',
],
],
]);
}
private function generatePipeline(): Pipeline
{
return new Pipeline(['stages' => [
new PipelineStage(['id' => 'foo', 'label' => 'bar']),
new PipelineStage(['id' => 'baz', 'label' => 'qux']),
]]);
}
public function testFetchOpportunityPipelines(): void
{
$this->client
->method('makeRequest')
->with('/crm/v3/pipelines/deals')
->willReturn($this->generateHubSpotResponse([
'results' => [
['id' => 'id_1', 'label' => 'Option 1', 'displayOrder' => 0],
['id' => 'id_2', 'label' => 'Option 2', 'displayOrder' => 1],
['id' => 'id_3', 'label' => 'Option 3', 'displayOrder' => 2],
],
]));
$this->assertEquals(
[
['id' => 'id_1', 'label' => 'Option 1'],
['id' => 'id_2', 'label' => 'Option 2'],
['id' => 'id_3', 'label' => 'Option 3'],
],
$this->client->fetchOpportunityPipelines()
);
}
public function testGetPaginatedData(): void
{
$expectedResults = [
['id' => 'id_1', 'properties' => []],
['id' => 'id_2', 'properties' => []],
['id' => 'id_3', 'properties' => []],
];
// Mock the pagination service to return a generator and modify reference parameters
$this->paginationServiceMock
->expects($this->once())
->method('getPaginatedDataGenerator')
->with(
$this->client,
['payload_key' => 'payload_value'],
'foobar',
0,
$this->anything(),
$this->anything()
)
->willReturnCallback(function ($client, $payload, $type, $offset, &$total, &$lastRecordId) use ($expectedResults) {
$total = 3;
$lastRecordId = 'id_3';
foreach ($expectedResults as $result) {
yield $result;
}
});
$this->assertEquals(
[
'results' => $expectedResults,
'total' => 3,
'last_record' => 'id_3',
],
$this->client->getPaginatedData(['payload_key' => 'payload_value'], 'foobar')
);
}
public function testGetAssociationsData(): void
{
$ids = ['1', '2', '3'];
$fromObject = 'deals';
$toObject = 'contacts';
$responseResults = [];
foreach ($ids as $id) {
$from = new PublicObjectId();
$from->setId($id);
$to1 = new PublicObjectId();
$to1->setId('contact_' . $id . '_1');
$to2 = new PublicObjectId();
$to2->setId('contact_' . $id . '_2');
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to1, $to2]);
$responseResults[] = $result;
}
$batchResponse = new BatchResponsePublicAssociationMulti();
$batchResponse->setResults($responseResults);
$this->associationsBatchApiMock->expects($this->once())
->method('read')
->with(
$this->equalTo($fromObject),
$this->equalTo($toObject),
$this->callback(function (BatchInputPublicObjectId $batchInput) use ($ids) {
$inputIds = array_map(
fn ($input) => $input->getId(),
$batchInput->getInputs()
);
return $inputIds === $ids;
})
)
->willReturn($batchResponse);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$expectedResult = [
'1' => ['contact_1_1', 'contact_1_2'],
'2' => ['contact_2_1', 'contact_2_2'],
'3' => ['contact_3_1', 'contact_3_2'],
];
$this->assertEquals($expectedResult, $result);
}
public function testGetAssociationsDataHandlesException(): void
{
$ids = ['1', '2', '3'];
$fromObject = 'deals';
$toObject = 'contacts';
$exception = new \Exception('API Error');
$this->associationsBatchApiMock->expects($this->once())
->method('read')
->with(
$this->equalTo($fromObject),
$this->equalTo($toObject),
$this->callback(function ($batchInput) use ($ids) {
return $batchInput instanceof \HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId
&& count($batchInput->getInputs()) === count($ids);
})
)
->willThrowException($exception);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with(
'[Hubspot] Failed to fetch associations',
[
'from_object' => $fromObject,
'to_object' => $toObject,
'reason' => 'API Error',
]
);
$this->client->setLogger($loggerMock);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$this->assertEmpty($result);
$this->assertIsArray($result);
}
public function testGetAssociationsDataWithLargeDataSet(): void
{
$ids = array_map(fn ($i) => (string) $i, range(1, 2500)); // More than 1000 items
$fromObject = 'deals';
$toObject = 'contacts';
$firstBatchResponse = new BatchResponsePublicAssociationMulti();
$firstBatchResults = array_map(function ($id) {
$from = new PublicObjectId();
$from->setId($id);
$to = new PublicObjectId();
$to->setId('contact_' . $id);
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to]);
return $result;
}, array_slice($ids, 0, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));
$firstBatchResponse->setResults($firstBatchResults);
$secondBatchResponse = new BatchResponsePublicAssociationMulti();
$secondBatchResults = array_map(function ($id) {
$from = new PublicObjectId();
$from->setId($id);
$to = new PublicObjectId();
$to->setId('contact_' . $id);
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to]);
return $result;
}, array_slice($ids, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));
$secondBatchResponse->setResults($secondBatchResults);
$this->associationsBatchApiMock->expects($this->exactly(3))
->method('read')
->willReturnOnConsecutiveCalls($firstBatchResponse, $secondBatchResponse);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$this->assertCount(2500, $result);
$this->assertArrayHasKey('1', $result);
$this->assertArrayHasKey('2500', $result);
$this->assertEquals(['contact_1'], $result['1']);
$this->assertEquals(['contact_2500'], $result['2500']);
}
public function testGetContactByEmailSuccess(): void
{
$email = '[EMAIL]';
$fields = ['firstname', 'lastname', 'email'];
$contactMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$contactMock->method('getId')->willReturn('12345');
$contactMock->method('getProperties')->willReturn([
'firstname' => 'John',
'lastname' => 'Doe',
'email' => '[EMAIL]',
]);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, 'firstname,lastname,email', null, false, 'email')
->willReturn($contactMock);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new NullLogger());
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([
'id' => '12345',
'properties' => [
'firstname' => 'John',
'lastname' => 'Doe',
'email' => '[EMAIL]',
],
], $result);
}
public function testGetContactByEmailWithEmptyFields(): void
{
$email = '[EMAIL]';
$fields = [];
$contactMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$contactMock->method('getId')->willReturn('12345');
$contactMock->method('getProperties')->willReturn(['email' => '[EMAIL]']);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, '', null, false, 'email')
->willReturn($contactMock);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new NullLogger());
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([
'id' => '12345',
'properties' => ['email' => '[EMAIL]'],
], $result);
}
public function testGetContactByEmailApiException(): void
{
$email = '[EMAIL]';
$fields = ['firstname', 'lastname'];
$exception = new \HubSpot\Client\Crm\Contacts\ApiException('Contact not found', 404);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, 'firstname,lastname', null, false, 'email')
->willThrowException($exception);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('info')
->with(
'[Hubspot] Failed to fetch contact',
[
'email' => $email,
'reason' => 'Contact not found',
]
);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger($loggerMock);
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([], $result);
}
public function testGetOpportunityById(): void
{
$opportunityId = '12345';
$expectedProperties = [
'dealname' => 'Test Opportunity',
'amount' => '1000.00',
'closedate' => '2024-12-31T23:59:59.999Z',
'dealstage' => 'presentationscheduled',
'pipeline' => 'default',
];
$mockHubspotOpportunity = $this->createMock(DealWithAssociations::class);
$mockHubspotOpportunity->method('getProperties')->willReturn((object) $expectedProperties);
$mockHubspotOpportunity->method('getId')->willReturn($opportunityId);
$now = new \DateTimeImmutable();
$mockHubspotOpportunity->method('getCreatedAt')->willReturn($now);
$mockHubspotOpportunity->method('getUpdatedAt')->willReturn($now);
$mockHubspotOpportunity->method('getArchived')->willReturn(false);
$this->dealsBasicApiMock
->expects($this->once())
->method('getById')
->willReturn($mockHubspotOpportunity);
// Assuming Client::getOpportunityById processes the SimplePublicObject and returns an associative array.
// The structure might be like: ['id' => ..., 'properties' => [...], 'createdAt' => ..., ...]
// Adjust assertions below based on the actual return structure of your method.
$result = $this->client->getOpportunityById($opportunityId, ['test', 'test']);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertEquals($opportunityId, $result['id']);
}
public function testGetContactById(): void
{
$crmId = 'contact-123';
$fields = ['firstname', 'lastname'];
$expectedProperties = ['firstname' => 'John', 'lastname' => 'Doe'];
$mockContact = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$mockContact->method('getId')->willReturn($crmId);
$mockContact->method('getProperties')->willReturn((object) $expectedProperties);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($crmId, 'firstname,lastname')
->willReturn($mockContact);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new \Psr\Log\NullLogger());
$result = $client->getContactById($crmId, $fields);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('properties', $result);
$this->assertEquals($crmId, $result['id']);
$this->assertEquals((object) $expectedProperties, $result['properties']);
}
public function testGetAccountById(): void
{
$crmId = 'account-123';
$fields = ['name', 'industry'];
$expectedProperties = ['name' => 'Acme Corp', 'industry' => 'Technology'];
$mockCompany = $this->createMock(\HubSpot\Client\Crm\Companies\Model\SimplePublicObjectWithAssociations::class);
$mockCompany->method('getId')->willReturn($crmId);
$mockCompany->method('getProperties')->willReturn((object) $expectedProperties);
$companiesApiMock = $this->createMock(\HubSpot\Client\Crm\Companies\Api\BasicApi::class);
$companiesApiMock->expects($this->once())
->method('getById')
->with($crmId, 'name,industry')
->willReturn($mockCompany);
$companiesDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Companies\Discovery::class);
$companiesDiscoveryMock->method('__call')->with('basicApi')->willReturn($companiesApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($companiesDiscoveryMock) {
if ($name === 'companies') {
return $companiesDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new \Psr\Log\NullLogger());
$result = $client->getAccountById($crmId, $fields);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('properties', $result);
$this->assertEquals($crmId, $result['id']);
$this->assertEquals((object) $expectedProperties, $result['properties']);
}
public function testEnsureValidTokenWithNoTokenUpdate(): void
{
$socialAccountMock = $this->createMock(SocialAccount::class);
// Set up OAuth account
$reflection = new \ReflectionClass($this->client);
$oauthAccountProperty = $reflection->getProperty('oauthAccount');
$oauthAccountProperty->setAccessible(true);
$oauthAccountProperty->setValue($this->client, $socialAccountMock);
$originalToken = 'original_token';
$accessTokenProperty = $reflection->getProperty('accessToken');
$accessTokenProperty->setAccessible(true);
$accessTokenProperty->setValue($this->client, $originalToken);
// Mock token manager to return null (no refresh needed)
$this->tokenManagerMock
->expects($this->once())
->method('ensureValidToken')
->with($socialAccountMock)
->willReturn(null);
// Call ensureValidToken
$this->client->ensureValidToken();
// Verify access token was not changed
$this->assertEquals($originalToken, $accessTokenProperty->getValue($this->client));
}
public function testGetPaginatedDataGeneratorDelegatesToPaginationService(): void
{
$payload = ['filters' => []];
$type = 'contacts';
$offset = 0;
$total = 0;
$lastRecordId = null;
$expectedResults = [
['id' => 'id_1', 'properties' => []],
['id' => 'id_2', 'properties' => []],
];
// Mock the pagination service to return a generator
$this->paginationServiceMock
->expects($this->once())
->method('getPaginatedDataGenerator')
->with(
$this->client,
$payload,
$type,
$offset,
$this->anything(),
$this->anything()
)
->willReturnCallback(function () use ($expectedResults) {
foreach ($expectedResults as $result) {
yield $result;
}
});
// Execute the pagination
$results = [];
foreach ($this->client->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastRecordId) as $result) {
$results[] = $result;
}
$this->assertCount(2, $results);
$this->assertEquals('id_1', $results[0]['id']);
$this->assertEquals('id_2', $results[1]['id']);
}
public function testEnsureValidTokenDelegatesToTokenManager(): void
{
$socialAccountMock = $this->createMock(SocialAccount::class);
// Set up OAuth account
$reflection = new \ReflectionClass($this->client);
$oauthAccountProperty = $reflection->getProperty('oauthAccount');
$oauthAccountProperty->setAccessible(true);
$oauthAccountProperty->setValue($this->client, $socialAccountMock);
// Mock token manager to return new token
$this->tokenManagerMock
->expects($this->once())
->method('ensureValidToken')
->with($socialAccountMock)
->willReturn('new_access_token');
// Call ensureValidToken
$this->client->ensureValidToken();
// Verify access token was updated
$accessTokenProperty = $reflection->getProperty('accessToken');
$accessTokenProperty->setAccessible(true);
$this->assertEquals('new_access_token', $accessTokenProperty->getValue($this->client));
}
public function testGetOwnersArchivedWithValidResponse(): void
{
$responseData = [
'results' => [
[
'id' => '123',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'John',
'lastName' => 'Doe',
'userId' => 456,
'userIdIncludingInactive' => 789,
'createdAt' => '2023-01-01T12:00:00Z',
'updatedAt' => '2023-01-02T12:00:00Z',
'archived' => true,
],
],
];
// Create a mock response object
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn($responseData);
// Set up the client to return our test data
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=true'
)
->willReturn($response);
// Call the method
$result = $this->client->getOwnersArchived(true);
// Assert the results
$this->assertCount(1, $result);
$this->assertEquals('123', $result[0]->getId());
$this->assertEquals('[EMAIL]', $result[0]->getEmail());
$this->assertEquals('John Doe', $result[0]->getFullName());
$this->assertTrue($result[0]->isArchived());
}
public function testGetOwnersArchivedWithEmptyResponse(): void
{
// Create a mock response object with empty results
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn(['results' => []]);
// Set up the client to return empty results
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=false'
)
->willReturn($response);
// Call the method
$result = $this->client->getOwnersArchived(false);
// Assert the results
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithInvalidResponse(): void
{
// Create a mock response that will throw an exception when toArray is called
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willThrowException(new \InvalidArgumentException('Invalid JSON'));
// Set up the client to return the problematic response
$this->client->method('makeRequest')
->willReturn($response);
// Mock the logger to expect an error message
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with($this->stringContains('Failed to fetch owners'));
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
// Call the method and expect an empty array on error
$result = $this->client->getOwnersArchived(true);
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithHttpError(): void
{
// Set up the client to throw an exception
$this->client->method('makeRequest')
->willThrowException(new \Exception('HTTP Error'));
// Mock the logger to expect an error message
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with($this->stringContains('Failed to fetch owners'));
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
// Call the method and expect an empty array on error
$result = $this->client->getOwnersArchived(false);
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithOwnerCreationException(): void
{
$responseData = [
'results' => [
[
'id' => '123',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'John',
'lastName' => 'Doe',
'userId' => 456,
'userIdIncludingInactive' => 789,
'createdAt' => '2023-01-01T12:00:00Z',
'updatedAt' => '2023-01-02T12:00:00Z',
'archived' => false,
],
[
'id' => '456',
'email' => '[EMAIL]',
'type' => 'PERSON',
'createdAt' => 'invalid-date-format',
],
[
'id' => '789',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'Jane',
'lastName' => 'Smith',
'userId' => 999,
'userIdIncludingInactive' => 888,
'createdAt' => '2023-01-03T12:00:00Z',
'updatedAt' => '2023-01-04T12:00:00Z',
'archived' => false,
],
],
];
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn($responseData);
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=false'
)
->willReturn($response);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with(
'[HubSpot] Failed to process owner data',
$this->callback(function ($context) {
return isset($context['result']) &&
isset($context['error']) &&
$context['result']['id'] === '456' &&
$context['result']['email'] === '[EMAIL]' &&
str_contains($context['error'], 'invalid-date-format');
})
);
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
$result = $this->client->getOwnersArchived(false);
$this->assertIsArray($result);
$this->assertCount(2, $result);
$this->assertEquals('123', $result[0]->getId());
$this->assertEquals('[EMAIL]', $result[0]->getEmail());
$this->assertEquals('789', $result[1]->getId());
$this->assertEquals('[EMAIL]', $result[1]->getEmail());
}
public function testMakeRequestWithGetMethod(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManager = $this->createMock(HubspotTokenManager::class);
$psrResponse = new Response(200, [], json_encode(['status' => 'success']));
$expectedResponse = new HubspotResponse($psrResponse);
$hubspotClientMock = $this->createMock(HubspotClient::class);
$hubspotClientMock->expects($this->once())
->method('request')
->with(
'GET',
'https://api.hubapi.com/crm/v3/objects/contacts',
[],
null,
true
)
->willReturn($expectedResponse);
$factoryMock = $this->createMock(Factory::class);
$factoryMock->method('getClient')->willReturn($hubspotClientMock);
$client = $this->getMockBuilder(Client::class)
->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])
->onlyMethods(['getInstance'])
->getMock();
$client->method('getInstance')->willReturn($factoryMock);
$client->setAccessToken(new AccessToken(['access_token' => 'test_token']));
$result = $client->makeRequest('/crm/v3/objects/contacts', 'GET');
$this->assertSame($expectedResponse, $result);
}
public function testMakeRequestWithGetMethodAndQueryString(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManag...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20570
|
893
|
10
|
2026-05-11T15:52:46.536353+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778514766536_m2.jpg...
|
PhpStorm
|
faVsco.js – ClientTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
ClientTest
Run 'ClientTest'
Debug 'ClientTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
144
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Hubspot;
use GuzzleHttp\Psr7\Response;
use HubSpot\Client\Crm\Associations\Api\BatchApi;
use HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId;
use HubSpot\Client\Crm\Associations\Model\BatchResponsePublicAssociationMulti;
use HubSpot\Client\Crm\Associations\Model\PublicObjectId;
use HubSpot\Client\Crm\Companies\ApiException as CompanyApiException;
use HubSpot\Client\Crm\Contacts\ApiException as ContactApiException;
use HubSpot\Client\Crm\Deals\Api\BasicApi as DealsBasicApi;
use HubSpot\Client\Crm\Deals\ApiException as DealApiException;
use HubSpot\Client\Crm\Deals\Model\SimplePublicObjectWithAssociations as DealWithAssociations;
use HubSpot\Client\Crm\Pipelines\Api\PipelinesApi;
use HubSpot\Client\Crm\Pipelines\Model\CollectionResponsePipeline;
use HubSpot\Client\Crm\Pipelines\Model\Error;
use HubSpot\Client\Crm\Pipelines\Model\Pipeline;
use HubSpot\Client\Crm\Pipelines\Model\PipelineStage;
use HubSpot\Client\Crm\Properties\Api\CoreApi;
use HubSpot\Client\Crm\Properties\Model\Property;
use HubSpot\Discovery as HubSpotDiscovery;
use HubSpot\Discovery\Crm\Deals\Discovery as DealsDiscovery;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\SocialAccount;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\HubspotTokenManager;
use Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService;
use Jiminny\Services\SocialAccountService;
use League\OAuth2\Client\Token\AccessToken;
use Mockery;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use SevenShores\Hubspot\Endpoints\Engagements;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Factory;
use SevenShores\Hubspot\Http\Client as HubspotClient;
use SevenShores\Hubspot\Http\Response as HubspotResponse;
/**
* @runTestsInSeparateProcesses
*
* @preserveGlobalState disabled
*/
class ClientTest extends TestCase
{
private const string RESPONSE_TYPE_STAGE_FIELD = 'stage';
private const string RESPONSE_TYPE_PIPELINE_FIELD = 'pipeline';
private const string RESPONSE_TYPE_REGULAR_FIELD = 'regular';
/**
* @var Client&MockObject
*/
private Client $client;
private Configuration $config;
/**
* @var SocialAccountService&MockObject
*/
private SocialAccountService $socialAccountServiceMock;
/**
* @var HubspotPaginationService&MockObject
*/
private HubspotPaginationService $paginationServiceMock;
/**
* @var HubspotTokenManager&MockObject
*/
private HubspotTokenManager $tokenManagerMock;
/**
* @var CoreApi&MockObject
*/
private CoreApi $coreApiMock;
/**
* @var PipelinesApi&MockObject
*/
private PipelinesApi $pipelinesApiMock;
/**
* @var BatchApi&MockObject
*/
private BatchApi $associationsBatchApiMock;
/**
* @var DealsBasicApi&MockObject
*/
private DealsBasicApi $dealsBasicApiMock;
/**
* @var Engagements&MockObject
*/
private $engagementsMock;
/**
* @var HubspotClient&MockObject
*/
private HubspotClient $hubspotClientMock;
protected function setUp(): void
{
// Create mocks for dependencies
$this->socialAccountServiceMock = $this->createMock(SocialAccountService::class);
$this->paginationServiceMock = $this->createMock(HubspotPaginationService::class);
$this->tokenManagerMock = $this->createMock(HubspotTokenManager::class);
// Create a real Client instance with mocked dependencies
// Create a partial mock only for the methods we need to mock
$client = $this->createPartialMock(Client::class, ['getInstance', 'getNewInstance', 'makeRequest']);
$client->setLogger(new NullLogger());
// Inject the real dependencies using reflection
$reflection = new \ReflectionClass(Client::class);
$accountServiceProperty = $reflection->getProperty('accountService');
$accountServiceProperty->setAccessible(true);
$accountServiceProperty->setValue($client, $this->socialAccountServiceMock);
$paginationServiceProperty = $reflection->getProperty('paginationService');
$paginationServiceProperty->setAccessible(true);
$paginationServiceProperty->setValue($client, $this->paginationServiceMock);
$tokenManagerProperty = $reflection->getProperty('tokenManager');
$tokenManagerProperty->setAccessible(true);
$tokenManagerProperty->setValue($client, $this->tokenManagerMock);
$factoryMock = $this->createMock(Factory::class);
$hubspotClientMock = $this->createMock(HubspotClient::class);
$engagementsMock = $this->createMock(\SevenShores\Hubspot\Endpoints\Engagements::class);
$factoryMock->method('getClient')->willReturn($hubspotClientMock);
$factoryMock->method('__call')->with('engagements')->willReturn($engagementsMock);
$client->method('getInstance')->willReturn($factoryMock);
$discoveryMock = $this->createMock(HubSpotDiscovery\Discovery::class);
$crmMock = $this->createMock(HubSpotDiscovery\Crm\Discovery::class);
$associationsMock = $this->createMock(HubSpotDiscovery\Crm\Associations\Discovery::class);
$propertiesMock = $this->createMock(HubSpotDiscovery\Crm\Properties\Discovery::class);
$pipelinesMock = $this->createMock(HubSpotDiscovery\Crm\Pipelines\Discovery::class);
$coreApiMock = $this->createMock(CoreApi::class);
$pipelinesApiMock = $this->createMock(PipelinesApi::class);
$associationsBatchApiMock = $this->createMock(BatchApi::class);
$dealsBasicApiMock = $this->createMock(DealsBasicApi::class);
$propertiesMock->method('__call')->with('coreApi')->willReturn($coreApiMock);
$pipelinesMock->method('__call')->with('pipelinesApi')->willReturn($pipelinesApiMock);
$associationsMock->method('__call')->with('batchApi')->willReturn($associationsBatchApiMock);
$dealsDiscoveryMock = $this->createMock(DealsDiscovery::class);
$dealsDiscoveryMock->method('__call')->with('basicApi')->willReturn($dealsBasicApiMock);
$returnMap = ['properties' => $propertiesMock, 'pipelines' => $pipelinesMock, 'associations' => $associationsMock, 'deals' => $dealsDiscoveryMock];
$crmMock->method('__call')
->willReturnCallback(static fn (string $name) => $returnMap[$name])
;
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client->method('getNewInstance')->willReturn($discoveryMock);
$this->client = $client;
$this->config = $this->createMock(Configuration::class);
$this->client->setConfiguration($this->config);
$this->coreApiMock = $coreApiMock;
$this->pipelinesApiMock = $pipelinesApiMock;
$this->associationsBatchApiMock = $associationsBatchApiMock;
$this->dealsBasicApiMock = $dealsBasicApiMock;
$this->engagementsMock = $engagementsMock;
$this->hubspotClientMock = $hubspotClientMock;
}
public function testGetMinimumApiVersion(): void
{
$this->assertIsString($this->client->getMinimumApiVersion());
}
public function testGetInstance(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManager = $this->createMock(HubspotTokenManager::class);
$client = new Client($socialAccountService, $paginationService, $tokenManager);
$client->setBaseUrl('[URL_WITH_CREDENTIALS] The class CollectionResponsePipeline will be deprecated in the next Hubspot version
*/
$this->pipelinesApiMock
->method('getAll')
->with('deals')
->willReturn(new CollectionResponsePipeline([
'results' => [$this->generatePipeline()],
]))
;
$this->assertEquals(
[
['id' => 'foo', 'label' => 'bar'],
['id' => 'baz', 'label' => 'qux'],
],
$this->client->fetchOpportunityPipelineStages()
);
}
public function testFetchOpportunityPipelineStagesErrorResponse(): void
{
$this->pipelinesApiMock
->method('getAll')
->with('deals')
->willReturn(new Error(['message' => 'test error']))
;
$this->assertEmpty($this->client->fetchOpportunityPipelineStages());
}
#[\PHPUnit\Framework\Attributes\DataProvider('meetingOutcomeFieldProvider')]
public function testFetchMeetingOutcomeFieldOptions(string $fieldId, string $expectedEndpoint): void
{
$field = new Field(['crm_provider_id' => $fieldId]);
$this->hubspotClientMock
->expects($this->once())
->method('request')
->with('GET', $expectedEndpoint)
->willReturn($this->generateHubSpotResponse(
[
'options' => [
['value' => 'option_1', 'label' => 'Option 1', 'displayOrder' => 0],
['value' => 'option_2', 'label' => 'Option 2', 'displayOrder' => 1],
['value' => 'option_3', 'label' => 'Option 3', 'displayOrder' => 2],
],
]
))
;
$this->assertEquals(
[
['id' => 'option_1', 'value' => 'option_1', 'label' => 'Option 1', 'display_order' => 0],
['id' => 'option_2', 'value' => 'option_2', 'label' => 'Option 2', 'display_order' => 1],
['id' => 'option_3', 'value' => 'option_3', 'label' => 'Option 3', 'display_order' => 2],
],
$this->client->fetchMeetingOutcomeFieldOptions($field)
);
}
public static function meetingOutcomeFieldProvider(): array
{
return [
'meeting outcome field' => [
'meetingOutcome',
'[URL_WITH_CREDENTIALS] The class CollectionResponsePipeline will be deprecated in the next Hubspot version
*/
$pipelineStagesResponse = new CollectionResponsePipeline([
'results' => [$this->generatePipeline()],
]);
$this->pipelinesApiMock
->method('getAll')
->willReturn($pipelineStagesResponse);
}
if ($type === self::RESPONSE_TYPE_PIPELINE_FIELD) {
$field->method('isStageField')->willReturn(false);
$field->method('isPipelineField')->willReturn(true);
$pipelineResponse = $this->generateHubSpotResponse([
'results' => [
['id' => '123', 'label' => 'Sales'],
['id' => 'default', 'label' => 'CS'],
],
]);
$this->client
->method('makeRequest')
->with('/crm/v3/pipelines/deals')
->willReturn($pipelineResponse);
}
$this->assertEquals(
$responses[$type],
$this->client->fetchOpportunityFieldOptions($field)
);
}
public static function opportunityFieldOptionsProvider(): array
{
return [
'stage field' => [
'field' => new Field(['crm_provider_id' => 'dealstage']),
'type' => self::RESPONSE_TYPE_STAGE_FIELD,
],
'pipeline field' => [
'field' => new Field(['crm_provider_id' => 'pipeline']),
'type' => self::RESPONSE_TYPE_PIPELINE_FIELD,
],
'regular field' => [
'field' => new Field(['crm_provider_id' => 'some_property']),
'type' => self::RESPONSE_TYPE_REGULAR_FIELD,
],
];
}
private function generateHubSpotResponse(array $data): HubspotResponse
{
return new HubspotResponse(new Response(200, [], json_encode($data)));
}
private function generateProperty(): Property
{
return new Property([
'name' => 'some_property',
'options' => [
[
'label' => 'label_1',
'value' => 'value_1',
],
[
'label' => 'label_2',
'value' => 'value_2',
],
],
]);
}
private function generatePipeline(): Pipeline
{
return new Pipeline(['stages' => [
new PipelineStage(['id' => 'foo', 'label' => 'bar']),
new PipelineStage(['id' => 'baz', 'label' => 'qux']),
]]);
}
public function testFetchOpportunityPipelines(): void
{
$this->client
->method('makeRequest')
->with('/crm/v3/pipelines/deals')
->willReturn($this->generateHubSpotResponse([
'results' => [
['id' => 'id_1', 'label' => 'Option 1', 'displayOrder' => 0],
['id' => 'id_2', 'label' => 'Option 2', 'displayOrder' => 1],
['id' => 'id_3', 'label' => 'Option 3', 'displayOrder' => 2],
],
]));
$this->assertEquals(
[
['id' => 'id_1', 'label' => 'Option 1'],
['id' => 'id_2', 'label' => 'Option 2'],
['id' => 'id_3', 'label' => 'Option 3'],
],
$this->client->fetchOpportunityPipelines()
);
}
public function testGetPaginatedData(): void
{
$expectedResults = [
['id' => 'id_1', 'properties' => []],
['id' => 'id_2', 'properties' => []],
['id' => 'id_3', 'properties' => []],
];
// Mock the pagination service to return a generator and modify reference parameters
$this->paginationServiceMock
->expects($this->once())
->method('getPaginatedDataGenerator')
->with(
$this->client,
['payload_key' => 'payload_value'],
'foobar',
0,
$this->anything(),
$this->anything()
)
->willReturnCallback(function ($client, $payload, $type, $offset, &$total, &$lastRecordId) use ($expectedResults) {
$total = 3;
$lastRecordId = 'id_3';
foreach ($expectedResults as $result) {
yield $result;
}
});
$this->assertEquals(
[
'results' => $expectedResults,
'total' => 3,
'last_record' => 'id_3',
],
$this->client->getPaginatedData(['payload_key' => 'payload_value'], 'foobar')
);
}
public function testGetAssociationsData(): void
{
$ids = ['1', '2', '3'];
$fromObject = 'deals';
$toObject = 'contacts';
$responseResults = [];
foreach ($ids as $id) {
$from = new PublicObjectId();
$from->setId($id);
$to1 = new PublicObjectId();
$to1->setId('contact_' . $id . '_1');
$to2 = new PublicObjectId();
$to2->setId('contact_' . $id . '_2');
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to1, $to2]);
$responseResults[] = $result;
}
$batchResponse = new BatchResponsePublicAssociationMulti();
$batchResponse->setResults($responseResults);
$this->associationsBatchApiMock->expects($this->once())
->method('read')
->with(
$this->equalTo($fromObject),
$this->equalTo($toObject),
$this->callback(function (BatchInputPublicObjectId $batchInput) use ($ids) {
$inputIds = array_map(
fn ($input) => $input->getId(),
$batchInput->getInputs()
);
return $inputIds === $ids;
})
)
->willReturn($batchResponse);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$expectedResult = [
'1' => ['contact_1_1', 'contact_1_2'],
'2' => ['contact_2_1', 'contact_2_2'],
'3' => ['contact_3_1', 'contact_3_2'],
];
$this->assertEquals($expectedResult, $result);
}
public function testGetAssociationsDataHandlesException(): void
{
$ids = ['1', '2', '3'];
$fromObject = 'deals';
$toObject = 'contacts';
$exception = new \Exception('API Error');
$this->associationsBatchApiMock->expects($this->once())
->method('read')
->with(
$this->equalTo($fromObject),
$this->equalTo($toObject),
$this->callback(function ($batchInput) use ($ids) {
return $batchInput instanceof \HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId
&& count($batchInput->getInputs()) === count($ids);
})
)
->willThrowException($exception);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with(
'[Hubspot] Failed to fetch associations',
[
'from_object' => $fromObject,
'to_object' => $toObject,
'reason' => 'API Error',
]
);
$this->client->setLogger($loggerMock);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$this->assertEmpty($result);
$this->assertIsArray($result);
}
public function testGetAssociationsDataWithLargeDataSet(): void
{
$ids = array_map(fn ($i) => (string) $i, range(1, 2500)); // More than 1000 items
$fromObject = 'deals';
$toObject = 'contacts';
$firstBatchResponse = new BatchResponsePublicAssociationMulti();
$firstBatchResults = array_map(function ($id) {
$from = new PublicObjectId();
$from->setId($id);
$to = new PublicObjectId();
$to->setId('contact_' . $id);
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to]);
return $result;
}, array_slice($ids, 0, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));
$firstBatchResponse->setResults($firstBatchResults);
$secondBatchResponse = new BatchResponsePublicAssociationMulti();
$secondBatchResults = array_map(function ($id) {
$from = new PublicObjectId();
$from->setId($id);
$to = new PublicObjectId();
$to->setId('contact_' . $id);
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to]);
return $result;
}, array_slice($ids, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));
$secondBatchResponse->setResults($secondBatchResults);
$this->associationsBatchApiMock->expects($this->exactly(3))
->method('read')
->willReturnOnConsecutiveCalls($firstBatchResponse, $secondBatchResponse);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$this->assertCount(2500, $result);
$this->assertArrayHasKey('1', $result);
$this->assertArrayHasKey('2500', $result);
$this->assertEquals(['contact_1'], $result['1']);
$this->assertEquals(['contact_2500'], $result['2500']);
}
public function testGetContactByEmailSuccess(): void
{
$email = '[EMAIL]';
$fields = ['firstname', 'lastname', 'email'];
$contactMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$contactMock->method('getId')->willReturn('12345');
$contactMock->method('getProperties')->willReturn([
'firstname' => 'John',
'lastname' => 'Doe',
'email' => '[EMAIL]',
]);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, 'firstname,lastname,email', null, false, 'email')
->willReturn($contactMock);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new NullLogger());
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([
'id' => '12345',
'properties' => [
'firstname' => 'John',
'lastname' => 'Doe',
'email' => '[EMAIL]',
],
], $result);
}
public function testGetContactByEmailWithEmptyFields(): void
{
$email = '[EMAIL]';
$fields = [];
$contactMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$contactMock->method('getId')->willReturn('12345');
$contactMock->method('getProperties')->willReturn(['email' => '[EMAIL]']);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, '', null, false, 'email')
->willReturn($contactMock);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new NullLogger());
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([
'id' => '12345',
'properties' => ['email' => '[EMAIL]'],
], $result);
}
public function testGetContactByEmailApiException(): void
{
$email = '[EMAIL]';
$fields = ['firstname', 'lastname'];
$exception = new \HubSpot\Client\Crm\Contacts\ApiException('Contact not found', 404);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, 'firstname,lastname', null, false, 'email')
->willThrowException($exception);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('info')
->with(
'[Hubspot] Failed to fetch contact',
[
'email' => $email,
'reason' => 'Contact not found',
]
);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger($loggerMock);
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([], $result);
}
public function testGetOpportunityById(): void
{
$opportunityId = '12345';
$expectedProperties = [
'dealname' => 'Test Opportunity',
'amount' => '1000.00',
'closedate' => '2024-12-31T23:59:59.999Z',
'dealstage' => 'presentationscheduled',
'pipeline' => 'default',
];
$mockHubspotOpportunity = $this->createMock(DealWithAssociations::class);
$mockHubspotOpportunity->method('getProperties')->willReturn((object) $expectedProperties);
$mockHubspotOpportunity->method('getId')->willReturn($opportunityId);
$now = new \DateTimeImmutable();
$mockHubspotOpportunity->method('getCreatedAt')->willReturn($now);
$mockHubspotOpportunity->method('getUpdatedAt')->willReturn($now);
$mockHubspotOpportunity->method('getArchived')->willReturn(false);
$this->dealsBasicApiMock
->expects($this->once())
->method('getById')
->willReturn($mockHubspotOpportunity);
// Assuming Client::getOpportunityById processes the SimplePublicObject and returns an associative array.
// The structure might be like: ['id' => ..., 'properties' => [...], 'createdAt' => ..., ...]
// Adjust assertions below based on the actual return structure of your method.
$result = $this->client->getOpportunityById($opportunityId, ['test', 'test']);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertEquals($opportunityId, $result['id']);
}
public function testGetContactById(): void
{
$crmId = 'contact-123';
$fields = ['firstname', 'lastname'];
$expectedProperties = ['firstname' => 'John', 'lastname' => 'Doe'];
$mockContact = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$mockContact->method('getId')->willReturn($crmId);
$mockContact->method('getProperties')->willReturn((object) $expectedProperties);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($crmId, 'firstname,lastname')
->willReturn($mockContact);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new \Psr\Log\NullLogger());
$result = $client->getContactById($crmId, $fields);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('properties', $result);
$this->assertEquals($crmId, $result['id']);
$this->assertEquals((object) $expectedProperties, $result['properties']);
}
public function testGetAccountById(): void
{
$crmId = 'account-123';
$fields = ['name', 'industry'];
$expectedProperties = ['name' => 'Acme Corp', 'industry' => 'Technology'];
$mockCompany = $this->createMock(\HubSpot\Client\Crm\Companies\Model\SimplePublicObjectWithAssociations::class);
$mockCompany->method('getId')->willReturn($crmId);
$mockCompany->method('getProperties')->willReturn((object) $expectedProperties);
$companiesApiMock = $this->createMock(\HubSpot\Client\Crm\Companies\Api\BasicApi::class);
$companiesApiMock->expects($this->once())
->method('getById')
->with($crmId, 'name,industry')
->willReturn($mockCompany);
$companiesDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Companies\Discovery::class);
$companiesDiscoveryMock->method('__call')->with('basicApi')->willReturn($companiesApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($companiesDiscoveryMock) {
if ($name === 'companies') {
return $companiesDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new \Psr\Log\NullLogger());
$result = $client->getAccountById($crmId, $fields);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('properties', $result);
$this->assertEquals($crmId, $result['id']);
$this->assertEquals((object) $expectedProperties, $result['properties']);
}
public function testEnsureValidTokenWithNoTokenUpdate(): void
{
$socialAccountMock = $this->createMock(SocialAccount::class);
// Set up OAuth account
$reflection = new \ReflectionClass($this->client);
$oauthAccountProperty = $reflection->getProperty('oauthAccount');
$oauthAccountProperty->setAccessible(true);
$oauthAccountProperty->setValue($this->client, $socialAccountMock);
$originalToken = 'original_token';
$accessTokenProperty = $reflection->getProperty('accessToken');
$accessTokenProperty->setAccessible(true);
$accessTokenProperty->setValue($this->client, $originalToken);
// Mock token manager to return null (no refresh needed)
$this->tokenManagerMock
->expects($this->once())
->method('ensureValidToken')
->with($socialAccountMock)
->willReturn(null);
// Call ensureValidToken
$this->client->ensureValidToken();
// Verify access token was not changed
$this->assertEquals($originalToken, $accessTokenProperty->getValue($this->client));
}
public function testGetPaginatedDataGeneratorDelegatesToPaginationService(): void
{
$payload = ['filters' => []];
$type = 'contacts';
$offset = 0;
$total = 0;
$lastRecordId = null;
$expectedResults = [
['id' => 'id_1', 'properties' => []],
['id' => 'id_2', 'properties' => []],
];
// Mock the pagination service to return a generator
$this->paginationServiceMock
->expects($this->once())
->method('getPaginatedDataGenerator')
->with(
$this->client,
$payload,
$type,
$offset,
$this->anything(),
$this->anything()
)
->willReturnCallback(function () use ($expectedResults) {
foreach ($expectedResults as $result) {
yield $result;
}
});
// Execute the pagination
$results = [];
foreach ($this->client->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastRecordId) as $result) {
$results[] = $result;
}
$this->assertCount(2, $results);
$this->assertEquals('id_1', $results[0]['id']);
$this->assertEquals('id_2', $results[1]['id']);
}
public function testEnsureValidTokenDelegatesToTokenManager(): void
{
$socialAccountMock = $this->createMock(SocialAccount::class);
// Set up OAuth account
$reflection = new \ReflectionClass($this->client);
$oauthAccountProperty = $reflection->getProperty('oauthAccount');
$oauthAccountProperty->setAccessible(true);
$oauthAccountProperty->setValue($this->client, $socialAccountMock);
// Mock token manager to return new token
$this->tokenManagerMock
->expects($this->once())
->method('ensureValidToken')
->with($socialAccountMock)
->willReturn('new_access_token');
// Call ensureValidToken
$this->client->ensureValidToken();
// Verify access token was updated
$accessTokenProperty = $reflection->getProperty('accessToken');
$accessTokenProperty->setAccessible(true);
$this->assertEquals('new_access_token', $accessTokenProperty->getValue($this->client));
}
public function testGetOwnersArchivedWithValidResponse(): void
{
$responseData = [
'results' => [
[
'id' => '123',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'John',
'lastName' => 'Doe',
'userId' => 456,
'userIdIncludingInactive' => 789,
'createdAt' => '2023-01-01T12:00:00Z',
'updatedAt' => '2023-01-02T12:00:00Z',
'archived' => true,
],
],
];
// Create a mock response object
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn($responseData);
// Set up the client to return our test data
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=true'
)
->willReturn($response);
// Call the method
$result = $this->client->getOwnersArchived(true);
// Assert the results
$this->assertCount(1, $result);
$this->assertEquals('123', $result[0]->getId());
$this->assertEquals('[EMAIL]', $result[0]->getEmail());
$this->assertEquals('John Doe', $result[0]->getFullName());
$this->assertTrue($result[0]->isArchived());
}
public function testGetOwnersArchivedWithEmptyResponse(): void
{
// Create a mock response object with empty results
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn(['results' => []]);
// Set up the client to return empty results
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=false'
)
->willReturn($response);
// Call the method
$result = $this->client->getOwnersArchived(false);
// Assert the results
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithInvalidResponse(): void
{
// Create a mock response that will throw an exception when toArray is called
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willThrowException(new \InvalidArgumentException('Invalid JSON'));
// Set up the client to return the problematic response
$this->client->method('makeRequest')
->willReturn($response);
// Mock the logger to expect an error message
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with($this->stringContains('Failed to fetch owners'));
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
// Call the method and expect an empty array on error
$result = $this->client->getOwnersArchived(true);
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithHttpError(): void
{
// Set up the client to throw an exception
$this->client->method('makeRequest')
->willThrowException(new \Exception('HTTP Error'));
// Mock the logger to expect an error message
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with($this->stringContains('Failed to fetch owners'));
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
// Call the method and expect an empty array on error
$result = $this->client->getOwnersArchived(false);
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithOwnerCreationException(): void
{
$responseData = [
'results' => [
[
'id' => '123',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'John',
'lastName' => 'Doe',
'userId' => 456,
'userIdIncludingInactive' => 789,
'createdAt' => '2023-01-01T12:00:00Z',
'updatedAt' => '2023-01-02T12:00:00Z',
'archived' => false,
],
[
'id' => '456',
'email' => '[EMAIL]',
'type' => 'PERSON',
'createdAt' => 'invalid-date-format',
],
[
'id' => '789',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'Jane',
'lastName' => 'Smith',
'userId' => 999,
'userIdIncludingInactive' => 888,
'createdAt' => '2023-01-03T12:00:00Z',
'updatedAt' => '2023-01-04T12:00:00Z',
'archived' => false,
],
],
];
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn($responseData);
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=false'
)
->willReturn($response);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with(
'[HubSpot] Failed to process owner data',
$this->callback(function ($context) {
return isset($context['result']) &&
isset($context['error']) &&
$context['result']['id'] === '456' &&
$context['result']['email'] === '[EMAIL]' &&
str_contains($context['error'], 'invalid-date-format');
})
);
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
$result = $this->client->getOwnersArchived(false);
$this->assertIsArray($result);
$this->assertCount(2, $result);
$this->assertEquals('123', $result[0]->getId());
$this->assertEquals('[EMAIL]', $result[0]->getEmail());
$this->assertEquals('789', $result[1]->getId());
$this->assertEquals('[EMAIL]', $result[1]->getEmail());
}
public function testMakeRequestWithGetMethod(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManager = $this->createMock(HubspotTokenManager::class);
$psrResponse = new Response(200, [], json_encode(['status' => 'success']));
$expectedResponse = new HubspotResponse($psrResponse);
$hubspotClientMock = $this->createMock(HubspotClient::class);
$hubspotClientMock->expects($this->once())
->method('request')
->with(
'GET',
'https://api.hubapi.com/crm/v3/objects/contacts',
[],
null,
true
)
->willReturn($expectedResponse);
$factoryMock = $this->createMock(Factory::class);
$factoryMock->method('getClient')->willReturn($hubspotClientMock);
$client = $this->getMockBuilder(Client::class)
->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])
->onlyMethods(['getInstance'])
->getMock();
$client->method('getInstance')->willReturn($factoryMock);
$client->setAccessToken(new AccessToken(['access_token' => 'test_token']));
$result = $client->makeRequest('/crm/v3/objects/contacts', 'GET');
$this->assertSame($expectedResponse, $result);
}
public function testMakeRequestWithGetMethodAndQueryString(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManag...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8597075,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ClientTest","depth":6,"bounds":{"left":0.875,"top":0.019952115,"width":0.04055851,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ClientTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ClientTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.50265956,"top":0.17478053,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"144","depth":4,"bounds":{"left":0.5142952,"top":0.17478053,"width":0.011968086,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":4,"bounds":{"left":0.52825797,"top":0.17478053,"width":0.008976064,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.53889626,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.5462101,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Hubspot;\n\nuse GuzzleHttp\\Psr7\\Response;\nuse HubSpot\\Client\\Crm\\Associations\\Api\\BatchApi;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\BatchResponsePublicAssociationMulti;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\PublicObjectId;\nuse HubSpot\\Client\\Crm\\Companies\\ApiException as CompanyApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\ApiException as ContactApiException;\nuse HubSpot\\Client\\Crm\\Deals\\Api\\BasicApi as DealsBasicApi;\nuse HubSpot\\Client\\Crm\\Deals\\ApiException as DealApiException;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectWithAssociations as DealWithAssociations;\nuse HubSpot\\Client\\Crm\\Pipelines\\Api\\PipelinesApi;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\CollectionResponsePipeline;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Error;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Pipeline;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\PipelineStage;\nuse HubSpot\\Client\\Crm\\Properties\\Api\\CoreApi;\nuse HubSpot\\Client\\Crm\\Properties\\Model\\Property;\nuse HubSpot\\Discovery as HubSpotDiscovery;\nuse HubSpot\\Discovery\\Crm\\Deals\\Discovery as DealsDiscovery;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\HubspotTokenManager;\nuse Jiminny\\Services\\Crm\\Hubspot\\Pagination\\HubspotPaginationService;\nuse Jiminny\\Services\\SocialAccountService;\nuse League\\OAuth2\\Client\\Token\\AccessToken;\nuse Mockery;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse PHPUnit\\Framework\\TestCase;\nuse Psr\\Log\\NullLogger;\nuse SevenShores\\Hubspot\\Endpoints\\Engagements;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Factory;\nuse SevenShores\\Hubspot\\Http\\Client as HubspotClient;\nuse SevenShores\\Hubspot\\Http\\Response as HubspotResponse;\n\n/**\n * @runTestsInSeparateProcesses\n *\n * @preserveGlobalState disabled\n */\nclass ClientTest extends TestCase\n{\n private const string RESPONSE_TYPE_STAGE_FIELD = 'stage';\n private const string RESPONSE_TYPE_PIPELINE_FIELD = 'pipeline';\n private const string RESPONSE_TYPE_REGULAR_FIELD = 'regular';\n /**\n * @var Client&MockObject\n */\n private Client $client;\n private Configuration $config;\n\n /**\n * @var SocialAccountService&MockObject\n */\n private SocialAccountService $socialAccountServiceMock;\n\n /**\n * @var HubspotPaginationService&MockObject\n */\n private HubspotPaginationService $paginationServiceMock;\n\n /**\n * @var HubspotTokenManager&MockObject\n */\n private HubspotTokenManager $tokenManagerMock;\n\n /**\n * @var CoreApi&MockObject\n */\n private CoreApi $coreApiMock;\n\n /**\n * @var PipelinesApi&MockObject\n */\n private PipelinesApi $pipelinesApiMock;\n\n /**\n * @var BatchApi&MockObject\n */\n private BatchApi $associationsBatchApiMock;\n\n /**\n * @var DealsBasicApi&MockObject\n */\n private DealsBasicApi $dealsBasicApiMock;\n\n /**\n * @var Engagements&MockObject\n */\n private $engagementsMock;\n\n /**\n * @var HubspotClient&MockObject\n */\n private HubspotClient $hubspotClientMock;\n\n protected function setUp(): void\n {\n // Create mocks for dependencies\n $this->socialAccountServiceMock = $this->createMock(SocialAccountService::class);\n $this->paginationServiceMock = $this->createMock(HubspotPaginationService::class);\n $this->tokenManagerMock = $this->createMock(HubspotTokenManager::class);\n\n // Create a real Client instance with mocked dependencies\n // Create a partial mock only for the methods we need to mock\n $client = $this->createPartialMock(Client::class, ['getInstance', 'getNewInstance', 'makeRequest']);\n $client->setLogger(new NullLogger());\n\n // Inject the real dependencies using reflection\n $reflection = new \\ReflectionClass(Client::class);\n\n $accountServiceProperty = $reflection->getProperty('accountService');\n $accountServiceProperty->setAccessible(true);\n $accountServiceProperty->setValue($client, $this->socialAccountServiceMock);\n\n $paginationServiceProperty = $reflection->getProperty('paginationService');\n $paginationServiceProperty->setAccessible(true);\n $paginationServiceProperty->setValue($client, $this->paginationServiceMock);\n\n $tokenManagerProperty = $reflection->getProperty('tokenManager');\n $tokenManagerProperty->setAccessible(true);\n $tokenManagerProperty->setValue($client, $this->tokenManagerMock);\n $factoryMock = $this->createMock(Factory::class);\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $engagementsMock = $this->createMock(\\SevenShores\\Hubspot\\Endpoints\\Engagements::class);\n\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n $factoryMock->method('__call')->with('engagements')->willReturn($engagementsMock);\n\n $client->method('getInstance')->willReturn($factoryMock);\n\n $discoveryMock = $this->createMock(HubSpotDiscovery\\Discovery::class);\n $crmMock = $this->createMock(HubSpotDiscovery\\Crm\\Discovery::class);\n $associationsMock = $this->createMock(HubSpotDiscovery\\Crm\\Associations\\Discovery::class);\n $propertiesMock = $this->createMock(HubSpotDiscovery\\Crm\\Properties\\Discovery::class);\n $pipelinesMock = $this->createMock(HubSpotDiscovery\\Crm\\Pipelines\\Discovery::class);\n $coreApiMock = $this->createMock(CoreApi::class);\n $pipelinesApiMock = $this->createMock(PipelinesApi::class);\n $associationsBatchApiMock = $this->createMock(BatchApi::class);\n $dealsBasicApiMock = $this->createMock(DealsBasicApi::class);\n\n $propertiesMock->method('__call')->with('coreApi')->willReturn($coreApiMock);\n $pipelinesMock->method('__call')->with('pipelinesApi')->willReturn($pipelinesApiMock);\n $associationsMock->method('__call')->with('batchApi')->willReturn($associationsBatchApiMock);\n $dealsDiscoveryMock = $this->createMock(DealsDiscovery::class);\n $dealsDiscoveryMock->method('__call')->with('basicApi')->willReturn($dealsBasicApiMock);\n\n $returnMap = ['properties' => $propertiesMock, 'pipelines' => $pipelinesMock, 'associations' => $associationsMock, 'deals' => $dealsDiscoveryMock];\n $crmMock->method('__call')\n ->willReturnCallback(static fn (string $name) => $returnMap[$name])\n ;\n\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n\n $this->client = $client;\n $this->config = $this->createMock(Configuration::class);\n $this->client->setConfiguration($this->config);\n $this->coreApiMock = $coreApiMock;\n $this->pipelinesApiMock = $pipelinesApiMock;\n $this->associationsBatchApiMock = $associationsBatchApiMock;\n $this->dealsBasicApiMock = $dealsBasicApiMock;\n $this->engagementsMock = $engagementsMock;\n $this->hubspotClientMock = $hubspotClientMock;\n }\n\n public function testGetMinimumApiVersion(): void\n {\n $this->assertIsString($this->client->getMinimumApiVersion());\n }\n\n public function testGetInstance(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $client = new Client($socialAccountService, $paginationService, $tokenManager);\n $client->setBaseUrl('https://example.com');\n $client->setAccessToken(new AccessToken(['access_token' => 'foo']));\n $factory = $client->getInstance();\n\n $this->assertEquals('foo', $factory->getClient()->key);\n }\n\n public function testGetNewInstance(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $client = new Client($socialAccountService, $paginationService, $tokenManager);\n $client->setAccessToken(new AccessToken(['access_token' => 'foo']));\n\n $factory = $client->getNewInstance();\n $token = $factory->auth()->oAuth()->accessTokensApi()->getConfig()->getAccessToken();\n $this->assertEquals('foo', $token);\n }\n\n public function testFetchProperty(): void\n {\n $this->coreApiMock->method('getByName')->willReturn($this->generateProperty());\n\n $this->assertEquals(\n 'some_property',\n $this->client->fetchProperty('foo', 'test')['name']\n );\n }\n\n public function testFetchPropertyOptions(): void\n {\n $this->coreApiMock->method('getByName')->willReturn($this->generateProperty());\n\n $this->assertEquals(\n [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n $this->client->fetchPropertyOptions('foo', 'test')\n );\n }\n\n public function testFetchCallDispositions(): void\n {\n $this->engagementsMock\n ->method('getCallDispositions')\n ->willReturn($this->generateHubSpotResponse([\n [\n 'id' => 1,\n 'label' => 'foo',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'bar',\n 'deleted' => false,\n ],\n ]))\n ;\n\n $this->assertEquals(\n [\n [\n 'id' => 1,\n 'label' => 'foo',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'bar',\n 'deleted' => false,\n ],\n ],\n $this->client->fetchCallDispositions()\n );\n }\n\n public function testFetchDispositionFieldOptions(): void\n {\n $this->engagementsMock->method('getCallDispositions')\n ->willReturn($this->generateHubSpotResponse(\n [\n [\n 'id' => 1,\n 'label' => 'Label 1',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'Label 2',\n 'deleted' => true,\n ],\n ]\n ))\n ;\n\n $this->assertEquals(\n [\n [\n 'value' => 1,\n 'id' => 1,\n 'label' => 'Label 1',\n ],\n ],\n $this->client->fetchDispositionFieldOptions()\n );\n }\n\n public function testFetchOpportunityPipelineStages(): void\n {\n /**\n * @TODO: The class CollectionResponsePipeline will be deprecated in the next Hubspot version\n */\n $this->pipelinesApiMock\n ->method('getAll')\n ->with('deals')\n ->willReturn(new CollectionResponsePipeline([\n 'results' => [$this->generatePipeline()],\n ]))\n ;\n\n $this->assertEquals(\n [\n ['id' => 'foo', 'label' => 'bar'],\n ['id' => 'baz', 'label' => 'qux'],\n ],\n $this->client->fetchOpportunityPipelineStages()\n );\n }\n\n public function testFetchOpportunityPipelineStagesErrorResponse(): void\n {\n $this->pipelinesApiMock\n ->method('getAll')\n ->with('deals')\n ->willReturn(new Error(['message' => 'test error']))\n ;\n\n $this->assertEmpty($this->client->fetchOpportunityPipelineStages());\n }\n\n #[\\PHPUnit\\Framework\\Attributes\\DataProvider('meetingOutcomeFieldProvider')]\n public function testFetchMeetingOutcomeFieldOptions(string $fieldId, string $expectedEndpoint): void\n {\n $field = new Field(['crm_provider_id' => $fieldId]);\n\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->with('GET', $expectedEndpoint)\n ->willReturn($this->generateHubSpotResponse(\n [\n 'options' => [\n ['value' => 'option_1', 'label' => 'Option 1', 'displayOrder' => 0],\n ['value' => 'option_2', 'label' => 'Option 2', 'displayOrder' => 1],\n ['value' => 'option_3', 'label' => 'Option 3', 'displayOrder' => 2],\n ],\n ]\n ))\n ;\n\n $this->assertEquals(\n [\n ['id' => 'option_1', 'value' => 'option_1', 'label' => 'Option 1', 'display_order' => 0],\n ['id' => 'option_2', 'value' => 'option_2', 'label' => 'Option 2', 'display_order' => 1],\n ['id' => 'option_3', 'value' => 'option_3', 'label' => 'Option 3', 'display_order' => 2],\n ],\n $this->client->fetchMeetingOutcomeFieldOptions($field)\n );\n }\n\n public static function meetingOutcomeFieldProvider(): array\n {\n return [\n 'meeting outcome field' => [\n 'meetingOutcome',\n 'https://api.hubapi.com/crm/v3/properties/meeting/hs_meeting_outcome',\n ],\n 'activity type field' => [\n 'foobar',\n 'https://api.hubapi.com/crm/v3/properties/call/hs_activity_type',\n ],\n ];\n }\n\n #[\\PHPUnit\\Framework\\Attributes\\DataProvider('opportunityFieldOptionsProvider')]\n public function testFetchOpportunityFieldOptions(Field $field, string $type): void\n {\n $responses = [\n self::RESPONSE_TYPE_STAGE_FIELD => [\n ['id' => 'foo', 'label' => 'bar'],\n ['id' => 'baz', 'label' => 'qux'],\n ],\n self::RESPONSE_TYPE_PIPELINE_FIELD => [\n ['id' => '123', 'label' => 'Sales'],\n ['id' => 'default', 'label' => 'CS'],\n ],\n self::RESPONSE_TYPE_REGULAR_FIELD => [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n ];\n\n $field = $this->createMock(Field::class);\n\n if ($type === self::RESPONSE_TYPE_REGULAR_FIELD) {\n $field->method('isStageField')->willReturn(false);\n $field->method('isPipelineField')->willReturn(false);\n $propertyResponse = $this->generateProperty();\n $this->coreApiMock->method('getByName')->willReturn($propertyResponse);\n }\n\n if ($type === self::RESPONSE_TYPE_STAGE_FIELD) {\n $field->method('isStageField')->willReturn(true);\n /**\n * @TODO: The class CollectionResponsePipeline will be deprecated in the next Hubspot version\n */\n\n $pipelineStagesResponse = new CollectionResponsePipeline([\n 'results' => [$this->generatePipeline()],\n ]);\n $this->pipelinesApiMock\n ->method('getAll')\n ->willReturn($pipelineStagesResponse);\n }\n\n if ($type === self::RESPONSE_TYPE_PIPELINE_FIELD) {\n $field->method('isStageField')->willReturn(false);\n $field->method('isPipelineField')->willReturn(true);\n $pipelineResponse = $this->generateHubSpotResponse([\n 'results' => [\n ['id' => '123', 'label' => 'Sales'],\n ['id' => 'default', 'label' => 'CS'],\n ],\n ]);\n $this->client\n ->method('makeRequest')\n ->with('/crm/v3/pipelines/deals')\n ->willReturn($pipelineResponse);\n }\n\n $this->assertEquals(\n $responses[$type],\n $this->client->fetchOpportunityFieldOptions($field)\n );\n }\n\n public static function opportunityFieldOptionsProvider(): array\n {\n return [\n 'stage field' => [\n 'field' => new Field(['crm_provider_id' => 'dealstage']),\n 'type' => self::RESPONSE_TYPE_STAGE_FIELD,\n ],\n 'pipeline field' => [\n 'field' => new Field(['crm_provider_id' => 'pipeline']),\n 'type' => self::RESPONSE_TYPE_PIPELINE_FIELD,\n ],\n 'regular field' => [\n 'field' => new Field(['crm_provider_id' => 'some_property']),\n 'type' => self::RESPONSE_TYPE_REGULAR_FIELD,\n ],\n ];\n }\n\n private function generateHubSpotResponse(array $data): HubspotResponse\n {\n return new HubspotResponse(new Response(200, [], json_encode($data)));\n }\n\n private function generateProperty(): Property\n {\n return new Property([\n 'name' => 'some_property',\n 'options' => [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n ]);\n }\n\n private function generatePipeline(): Pipeline\n {\n return new Pipeline(['stages' => [\n new PipelineStage(['id' => 'foo', 'label' => 'bar']),\n new PipelineStage(['id' => 'baz', 'label' => 'qux']),\n ]]);\n }\n\n public function testFetchOpportunityPipelines(): void\n {\n $this->client\n ->method('makeRequest')\n ->with('/crm/v3/pipelines/deals')\n ->willReturn($this->generateHubSpotResponse([\n 'results' => [\n ['id' => 'id_1', 'label' => 'Option 1', 'displayOrder' => 0],\n ['id' => 'id_2', 'label' => 'Option 2', 'displayOrder' => 1],\n ['id' => 'id_3', 'label' => 'Option 3', 'displayOrder' => 2],\n ],\n ]));\n\n $this->assertEquals(\n [\n ['id' => 'id_1', 'label' => 'Option 1'],\n ['id' => 'id_2', 'label' => 'Option 2'],\n ['id' => 'id_3', 'label' => 'Option 3'],\n ],\n $this->client->fetchOpportunityPipelines()\n );\n }\n\n public function testGetPaginatedData(): void\n {\n $expectedResults = [\n ['id' => 'id_1', 'properties' => []],\n ['id' => 'id_2', 'properties' => []],\n ['id' => 'id_3', 'properties' => []],\n ];\n\n // Mock the pagination service to return a generator and modify reference parameters\n $this->paginationServiceMock\n ->expects($this->once())\n ->method('getPaginatedDataGenerator')\n ->with(\n $this->client,\n ['payload_key' => 'payload_value'],\n 'foobar',\n 0,\n $this->anything(),\n $this->anything()\n )\n ->willReturnCallback(function ($client, $payload, $type, $offset, &$total, &$lastRecordId) use ($expectedResults) {\n $total = 3;\n $lastRecordId = 'id_3';\n foreach ($expectedResults as $result) {\n yield $result;\n }\n });\n\n $this->assertEquals(\n [\n 'results' => $expectedResults,\n 'total' => 3,\n 'last_record' => 'id_3',\n ],\n $this->client->getPaginatedData(['payload_key' => 'payload_value'], 'foobar')\n );\n }\n\n public function testGetAssociationsData(): void\n {\n $ids = ['1', '2', '3'];\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $responseResults = [];\n foreach ($ids as $id) {\n $from = new PublicObjectId();\n $from->setId($id);\n\n $to1 = new PublicObjectId();\n $to1->setId('contact_' . $id . '_1');\n $to2 = new PublicObjectId();\n $to2->setId('contact_' . $id . '_2');\n\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to1, $to2]);\n\n $responseResults[] = $result;\n }\n\n $batchResponse = new BatchResponsePublicAssociationMulti();\n $batchResponse->setResults($responseResults);\n\n $this->associationsBatchApiMock->expects($this->once())\n ->method('read')\n ->with(\n $this->equalTo($fromObject),\n $this->equalTo($toObject),\n $this->callback(function (BatchInputPublicObjectId $batchInput) use ($ids) {\n $inputIds = array_map(\n fn ($input) => $input->getId(),\n $batchInput->getInputs()\n );\n\n return $inputIds === $ids;\n })\n )\n ->willReturn($batchResponse);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $expectedResult = [\n '1' => ['contact_1_1', 'contact_1_2'],\n '2' => ['contact_2_1', 'contact_2_2'],\n '3' => ['contact_3_1', 'contact_3_2'],\n ];\n\n $this->assertEquals($expectedResult, $result);\n }\n\n public function testGetAssociationsDataHandlesException(): void\n {\n $ids = ['1', '2', '3'];\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $exception = new \\Exception('API Error');\n\n $this->associationsBatchApiMock->expects($this->once())\n ->method('read')\n ->with(\n $this->equalTo($fromObject),\n $this->equalTo($toObject),\n $this->callback(function ($batchInput) use ($ids) {\n return $batchInput instanceof \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId\n && count($batchInput->getInputs()) === count($ids);\n })\n )\n ->willThrowException($exception);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with(\n '[Hubspot] Failed to fetch associations',\n [\n 'from_object' => $fromObject,\n 'to_object' => $toObject,\n 'reason' => 'API Error',\n ]\n );\n\n $this->client->setLogger($loggerMock);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $this->assertEmpty($result);\n $this->assertIsArray($result);\n }\n\n public function testGetAssociationsDataWithLargeDataSet(): void\n {\n $ids = array_map(fn ($i) => (string) $i, range(1, 2500)); // More than 1000 items\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $firstBatchResponse = new BatchResponsePublicAssociationMulti();\n $firstBatchResults = array_map(function ($id) {\n $from = new PublicObjectId();\n $from->setId($id);\n $to = new PublicObjectId();\n $to->setId('contact_' . $id);\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to]);\n\n return $result;\n }, array_slice($ids, 0, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));\n $firstBatchResponse->setResults($firstBatchResults);\n\n $secondBatchResponse = new BatchResponsePublicAssociationMulti();\n $secondBatchResults = array_map(function ($id) {\n $from = new PublicObjectId();\n $from->setId($id);\n $to = new PublicObjectId();\n $to->setId('contact_' . $id);\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to]);\n\n return $result;\n }, array_slice($ids, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));\n $secondBatchResponse->setResults($secondBatchResults);\n\n $this->associationsBatchApiMock->expects($this->exactly(3))\n ->method('read')\n ->willReturnOnConsecutiveCalls($firstBatchResponse, $secondBatchResponse);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $this->assertCount(2500, $result);\n $this->assertArrayHasKey('1', $result);\n $this->assertArrayHasKey('2500', $result);\n $this->assertEquals(['contact_1'], $result['1']);\n $this->assertEquals(['contact_2500'], $result['2500']);\n }\n\n public function testGetContactByEmailSuccess(): void\n {\n $email = 'test@example.com';\n $fields = ['firstname', 'lastname', 'email'];\n\n $contactMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $contactMock->method('getId')->willReturn('12345');\n $contactMock->method('getProperties')->willReturn([\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n 'email' => 'test@example.com',\n ]);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, 'firstname,lastname,email', null, false, 'email')\n ->willReturn($contactMock);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new NullLogger());\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([\n 'id' => '12345',\n 'properties' => [\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n 'email' => 'test@example.com',\n ],\n ], $result);\n }\n\n public function testGetContactByEmailWithEmptyFields(): void\n {\n $email = 'test@example.com';\n $fields = [];\n\n $contactMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $contactMock->method('getId')->willReturn('12345');\n $contactMock->method('getProperties')->willReturn(['email' => 'test@example.com']);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, '', null, false, 'email')\n ->willReturn($contactMock);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new NullLogger());\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([\n 'id' => '12345',\n 'properties' => ['email' => 'test@example.com'],\n ], $result);\n }\n\n public function testGetContactByEmailApiException(): void\n {\n $email = 'nonexistent@example.com';\n $fields = ['firstname', 'lastname'];\n\n $exception = new \\HubSpot\\Client\\Crm\\Contacts\\ApiException('Contact not found', 404);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, 'firstname,lastname', null, false, 'email')\n ->willThrowException($exception);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('info')\n ->with(\n '[Hubspot] Failed to fetch contact',\n [\n 'email' => $email,\n 'reason' => 'Contact not found',\n ]\n );\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger($loggerMock);\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([], $result);\n }\n\n public function testGetOpportunityById(): void\n {\n $opportunityId = '12345';\n $expectedProperties = [\n 'dealname' => 'Test Opportunity',\n 'amount' => '1000.00',\n 'closedate' => '2024-12-31T23:59:59.999Z',\n 'dealstage' => 'presentationscheduled',\n 'pipeline' => 'default',\n ];\n\n $mockHubspotOpportunity = $this->createMock(DealWithAssociations::class);\n $mockHubspotOpportunity->method('getProperties')->willReturn((object) $expectedProperties);\n $mockHubspotOpportunity->method('getId')->willReturn($opportunityId);\n $now = new \\DateTimeImmutable();\n $mockHubspotOpportunity->method('getCreatedAt')->willReturn($now);\n $mockHubspotOpportunity->method('getUpdatedAt')->willReturn($now);\n $mockHubspotOpportunity->method('getArchived')->willReturn(false);\n\n $this->dealsBasicApiMock\n ->expects($this->once())\n ->method('getById')\n ->willReturn($mockHubspotOpportunity);\n\n // Assuming Client::getOpportunityById processes the SimplePublicObject and returns an associative array.\n // The structure might be like: ['id' => ..., 'properties' => [...], 'createdAt' => ..., ...]\n // Adjust assertions below based on the actual return structure of your method.\n $result = $this->client->getOpportunityById($opportunityId, ['test', 'test']);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertEquals($opportunityId, $result['id']);\n }\n\n public function testGetContactById(): void\n {\n $crmId = 'contact-123';\n $fields = ['firstname', 'lastname'];\n $expectedProperties = ['firstname' => 'John', 'lastname' => 'Doe'];\n\n $mockContact = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $mockContact->method('getId')->willReturn($crmId);\n $mockContact->method('getProperties')->willReturn((object) $expectedProperties);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($crmId, 'firstname,lastname')\n ->willReturn($mockContact);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new \\Psr\\Log\\NullLogger());\n\n $result = $client->getContactById($crmId, $fields);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertArrayHasKey('properties', $result);\n $this->assertEquals($crmId, $result['id']);\n $this->assertEquals((object) $expectedProperties, $result['properties']);\n }\n\n public function testGetAccountById(): void\n {\n $crmId = 'account-123';\n $fields = ['name', 'industry'];\n $expectedProperties = ['name' => 'Acme Corp', 'industry' => 'Technology'];\n\n $mockCompany = $this->createMock(\\HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectWithAssociations::class);\n $mockCompany->method('getId')->willReturn($crmId);\n $mockCompany->method('getProperties')->willReturn((object) $expectedProperties);\n\n $companiesApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Companies\\Api\\BasicApi::class);\n $companiesApiMock->expects($this->once())\n ->method('getById')\n ->with($crmId, 'name,industry')\n ->willReturn($mockCompany);\n\n $companiesDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Companies\\Discovery::class);\n $companiesDiscoveryMock->method('__call')->with('basicApi')->willReturn($companiesApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($companiesDiscoveryMock) {\n if ($name === 'companies') {\n return $companiesDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new \\Psr\\Log\\NullLogger());\n\n $result = $client->getAccountById($crmId, $fields);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertArrayHasKey('properties', $result);\n $this->assertEquals($crmId, $result['id']);\n $this->assertEquals((object) $expectedProperties, $result['properties']);\n }\n\n public function testEnsureValidTokenWithNoTokenUpdate(): void\n {\n $socialAccountMock = $this->createMock(SocialAccount::class);\n\n // Set up OAuth account\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, $socialAccountMock);\n\n $originalToken = 'original_token';\n $accessTokenProperty = $reflection->getProperty('accessToken');\n $accessTokenProperty->setAccessible(true);\n $accessTokenProperty->setValue($this->client, $originalToken);\n\n // Mock token manager to return null (no refresh needed)\n $this->tokenManagerMock\n ->expects($this->once())\n ->method('ensureValidToken')\n ->with($socialAccountMock)\n ->willReturn(null);\n\n // Call ensureValidToken\n $this->client->ensureValidToken();\n\n // Verify access token was not changed\n $this->assertEquals($originalToken, $accessTokenProperty->getValue($this->client));\n }\n\n\n\n\n\n\n public function testGetPaginatedDataGeneratorDelegatesToPaginationService(): void\n {\n $payload = ['filters' => []];\n $type = 'contacts';\n $offset = 0;\n $total = 0;\n $lastRecordId = null;\n\n $expectedResults = [\n ['id' => 'id_1', 'properties' => []],\n ['id' => 'id_2', 'properties' => []],\n ];\n\n // Mock the pagination service to return a generator\n $this->paginationServiceMock\n ->expects($this->once())\n ->method('getPaginatedDataGenerator')\n ->with(\n $this->client,\n $payload,\n $type,\n $offset,\n $this->anything(),\n $this->anything()\n )\n ->willReturnCallback(function () use ($expectedResults) {\n foreach ($expectedResults as $result) {\n yield $result;\n }\n });\n\n // Execute the pagination\n $results = [];\n foreach ($this->client->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastRecordId) as $result) {\n $results[] = $result;\n }\n\n $this->assertCount(2, $results);\n $this->assertEquals('id_1', $results[0]['id']);\n $this->assertEquals('id_2', $results[1]['id']);\n }\n\n public function testEnsureValidTokenDelegatesToTokenManager(): void\n {\n $socialAccountMock = $this->createMock(SocialAccount::class);\n\n // Set up OAuth account\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, $socialAccountMock);\n\n // Mock token manager to return new token\n $this->tokenManagerMock\n ->expects($this->once())\n ->method('ensureValidToken')\n ->with($socialAccountMock)\n ->willReturn('new_access_token');\n\n // Call ensureValidToken\n $this->client->ensureValidToken();\n\n // Verify access token was updated\n $accessTokenProperty = $reflection->getProperty('accessToken');\n $accessTokenProperty->setAccessible(true);\n $this->assertEquals('new_access_token', $accessTokenProperty->getValue($this->client));\n }\n\n public function testGetOwnersArchivedWithValidResponse(): void\n {\n $responseData = [\n 'results' => [\n [\n 'id' => '123',\n 'email' => 'owner1@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'John',\n 'lastName' => 'Doe',\n 'userId' => 456,\n 'userIdIncludingInactive' => 789,\n 'createdAt' => '2023-01-01T12:00:00Z',\n 'updatedAt' => '2023-01-02T12:00:00Z',\n 'archived' => true,\n ],\n ],\n ];\n\n // Create a mock response object\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn($responseData);\n\n // Set up the client to return our test data\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=true'\n )\n ->willReturn($response);\n\n // Call the method\n $result = $this->client->getOwnersArchived(true);\n\n // Assert the results\n $this->assertCount(1, $result);\n $this->assertEquals('123', $result[0]->getId());\n $this->assertEquals('owner1@example.com', $result[0]->getEmail());\n $this->assertEquals('John Doe', $result[0]->getFullName());\n $this->assertTrue($result[0]->isArchived());\n }\n\n public function testGetOwnersArchivedWithEmptyResponse(): void\n {\n // Create a mock response object with empty results\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn(['results' => []]);\n\n // Set up the client to return empty results\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=false'\n )\n ->willReturn($response);\n\n // Call the method\n $result = $this->client->getOwnersArchived(false);\n\n // Assert the results\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithInvalidResponse(): void\n {\n // Create a mock response that will throw an exception when toArray is called\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willThrowException(new \\InvalidArgumentException('Invalid JSON'));\n\n // Set up the client to return the problematic response\n $this->client->method('makeRequest')\n ->willReturn($response);\n\n // Mock the logger to expect an error message\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with($this->stringContains('Failed to fetch owners'));\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n // Call the method and expect an empty array on error\n $result = $this->client->getOwnersArchived(true);\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithHttpError(): void\n {\n // Set up the client to throw an exception\n $this->client->method('makeRequest')\n ->willThrowException(new \\Exception('HTTP Error'));\n\n // Mock the logger to expect an error message\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with($this->stringContains('Failed to fetch owners'));\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n // Call the method and expect an empty array on error\n $result = $this->client->getOwnersArchived(false);\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithOwnerCreationException(): void\n {\n $responseData = [\n 'results' => [\n [\n 'id' => '123',\n 'email' => 'valid@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'John',\n 'lastName' => 'Doe',\n 'userId' => 456,\n 'userIdIncludingInactive' => 789,\n 'createdAt' => '2023-01-01T12:00:00Z',\n 'updatedAt' => '2023-01-02T12:00:00Z',\n 'archived' => false,\n ],\n [\n 'id' => '456',\n 'email' => 'invalid@example.com',\n 'type' => 'PERSON',\n 'createdAt' => 'invalid-date-format',\n ],\n [\n 'id' => '789',\n 'email' => 'another@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'Jane',\n 'lastName' => 'Smith',\n 'userId' => 999,\n 'userIdIncludingInactive' => 888,\n 'createdAt' => '2023-01-03T12:00:00Z',\n 'updatedAt' => '2023-01-04T12:00:00Z',\n 'archived' => false,\n ],\n ],\n ];\n\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn($responseData);\n\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=false'\n )\n ->willReturn($response);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with(\n '[HubSpot] Failed to process owner data',\n $this->callback(function ($context) {\n return isset($context['result']) &&\n isset($context['error']) &&\n $context['result']['id'] === '456' &&\n $context['result']['email'] === 'invalid@example.com' &&\n str_contains($context['error'], 'invalid-date-format');\n })\n );\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n $result = $this->client->getOwnersArchived(false);\n\n $this->assertIsArray($result);\n $this->assertCount(2, $result);\n $this->assertEquals('123', $result[0]->getId());\n $this->assertEquals('valid@example.com', $result[0]->getEmail());\n $this->assertEquals('789', $result[1]->getId());\n $this->assertEquals('another@example.com', $result[1]->getEmail());\n }\n\n public function testMakeRequestWithGetMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'GET',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n [],\n null,\n true\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'GET');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithGetMethodAndQueryString(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'GET',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n [],\n 'limit=10&offset=0',\n true\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'GET', [], 'limit=10&offset=0');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPostMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'POST',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'POST', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPutMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'firstname' => 'Jane',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345', 'updated' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'PUT',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'PUT', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPatchMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'email' => 'newemail@example.com',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345', 'patched' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'PATCH',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'PATCH', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithDeleteMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['deleted' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'DELETE',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => []]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'DELETE');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithEmptyPayload(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'ok']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'POST',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n ['json' => []]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'POST', []);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestPrependsBaseUrl(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n $this->anything(),\n 'https://api.hubapi.com/test/endpoint',\n $this->anything()\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $client->makeRequest('/test/endpoint', 'GET');\n }\n\n public function testGetOpportunitiesByIds(): void\n {\n $crmIds = ['deal1', 'deal2', 'deal3'];\n $fields = ['dealname', 'amount'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getOpportunitiesByIds'));\n }\n\n public function testGetCompaniesByIds(): void\n {\n $crmIds = ['company1', 'company2'];\n $fields = ['name', 'industry'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getCompaniesByIds'));\n }\n\n public function testGetContactsByIds(): void\n {\n $crmIds = ['contact1', 'contact2'];\n $fields = ['firstname', 'lastname'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getContactsByIds'));\n }\n\n public function testBatchReadObjectsEmptyIds(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $result = $method->invokeArgs($this->client, ['deals', [], ['dealname']]);\n\n $this->assertEquals([], $result);\n }\n\n public function testBatchReadObjectsExceedsBatchSize(): void\n {\n $crmIds = array_fill(0, 101, 'deal_id'); // 101 IDs, exceeds limit of 100\n\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $this->expectException(\\InvalidArgumentException::class);\n $this->expectExceptionMessage('Batch size cannot exceed 100 deals');\n\n $method->invokeArgs($this->client, ['deals', $crmIds, ['dealname']]);\n }\n\n public function testBatchReadObjectsUnsupportedObjectType(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $this->expectException(\\Jiminny\\Exceptions\\CrmException::class);\n $this->expectExceptionMessage('Failed to batch fetch unsupported');\n\n $method->invokeArgs($this->client, ['unsupported', ['id1'], ['field1']]);\n }\n\n public function testIsUnauthorizedExceptionWithBadRequest401(): void\n {\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\BadRequest('Unauthorized', 401);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithBadRequestNon401(): void\n {\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\BadRequest('Bad Request', 400);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertFalse($result);\n }\n\n public function testIsUnauthorizedExceptionWithGuzzleRequestException(): void\n {\n $response = new Response(401, [], 'Unauthorized');\n $exception = new \\GuzzleHttp\\Exception\\RequestException('Unauthorized', new \\GuzzleHttp\\Psr7\\Request('GET', 'test'), $response);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithGuzzleClientException(): void\n {\n $exception = new \\GuzzleHttp\\Exception\\ClientException('Unauthorized', new \\GuzzleHttp\\Psr7\\Request('GET', 'test'), new Response(401));\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithStringMatching(): void\n {\n $exception = new \\Exception('HTTP 401 Unauthorized error occurred');\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithNonUnauthorized(): void\n {\n $exception = new \\Exception('Some other error');\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertFalse($result);\n }\n\n public function testEnsureValidTokenWithNullOauthAccount(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, null);\n\n // Should not throw exception and not call token manager\n $this->tokenManagerMock->expects($this->never())->method('ensureValidToken');\n\n $this->client->ensureValidToken();\n\n $this->assertTrue(true); // Test passes if no exception is thrown\n }\n\n public function testGetConfig(): void\n {\n $result = $this->client->getConfig();\n\n $this->assertSame($this->config, $result);\n }\n\n public function testGetOwners(): void\n {\n // Test that the method exists and can be called\n $this->assertTrue(method_exists($this->client, 'getOwners'));\n\n // Since getOwners has complex HubSpot API dependencies,\n // we'll just verify the method exists for now\n $this->assertIsCallable([$this->client, 'getOwners']);\n }\n\n public function testCreateMeeting(): void\n {\n $payload = [\n 'properties' => [\n 'hs_meeting_title' => 'Test Meeting',\n 'hs_meeting_start_time' => '2024-01-01T10:00:00Z',\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['id' => 'meeting123']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with('/crm/v3/objects/meetings', 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->createMeeting($payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testUpdateMeeting(): void\n {\n $meetingId = 'meeting123';\n $payload = [\n 'properties' => [\n 'hs_meeting_title' => 'Updated Meeting',\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['id' => $meetingId, 'updated' => true]);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v3/objects/meetings/{$meetingId}\", 'PATCH', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->updateMeeting($meetingId, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testAddAssociations(): void\n {\n $objectType = 'deals';\n $associationType = 'contacts';\n $payload = [\n 'inputs' => [\n ['from' => ['id' => 'deal1'], 'to' => ['id' => 'contact1']],\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['status' => 'success']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v4/associations/{$objectType}/{$associationType}/batch/create\", 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->addAssociations($objectType, $associationType, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testRemoveAssociations(): void\n {\n $objectType = 'deals';\n $associationType = 'contacts';\n $payload = [\n 'inputs' => [\n ['from' => ['id' => 'deal1'], 'to' => ['id' => 'contact1']],\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['status' => 'success']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v4/associations/{$objectType}/{$associationType}/batch/archive\", 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->removeAssociations($objectType, $associationType, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n // -------------------------------------------------------------------------\n // search() / executeRequest() tests\n // -------------------------------------------------------------------------\n\n public function testSearchReturnsDecodedArrayOnSuccess(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n\n $this->config->method('getId')->willReturn(42);\n\n $payload = ['filters' => []];\n $responseData = ['results' => [['id' => '1']], 'total' => 1, 'paging' => []];\n $hubspotResponse = new HubspotResponse(new Response(200, [], json_encode($responseData)));\n\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->with('POST', Client::BASE_URL . '/crm/v3/objects/contacts/search', ['json' => $payload])\n ->willReturn($hubspotResponse);\n\n $result = $this->client->search('contacts', $payload);\n\n $this->assertSame($responseData, $result);\n\n Mockery::close();\n }\n\n public function testSearchThrowsRateLimitExceptionWhenCircuitBreakerActive(): void\n {\n $futureTimestamp = (string) (time() + 30);\n\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn($futureTimestamp);\n\n $this->config->method('getId')->willReturn(42);\n\n $this->hubspotClientMock->expects($this->never())->method('request');\n\n $this->expectException(RateLimitException::class);\n $this->expectExceptionMessage('Hubspot rate limit (cached circuit-breaker)');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchThrowsRateLimitExceptionAndSetsNxOnFresh429(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n // NX + TTL option array — exact TTL depends on parseRetryAfter, verified separately\n $redisMock->shouldReceive('set')\n ->once()\n ->with(\n 'hubspot:ratelimit:config:42',\n Mockery::type('string'),\n Mockery::on(fn ($opts) => is_array($opts) && in_array('nx', $opts, true))\n );\n\n $this->config->method('getId')->willReturn(42);\n\n $badRequest = new BadRequest('Rate limit exceeded', 429);\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->willThrowException($badRequest);\n\n $this->expectException(RateLimitException::class);\n $this->expectExceptionMessage('Hubspot returned 429');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchPropagatesNonRateLimitException(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n\n $this->config->method('getId')->willReturn(42);\n\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\HubspotException('Server error', 500);\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->willThrowException($exception);\n\n $this->expectException(\\SevenShores\\Hubspot\\Exceptions\\HubspotException::class);\n $this->expectExceptionMessage('Server error');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchCircuitBreakerRetryAfterComputedFromStoredTimestamp(): void\n {\n $remaining = 45;\n $futureTimestamp = (string) (time() + $remaining);\n\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn($futureTimestamp);\n\n $this->config->method('getId')->willReturn(1);\n\n try {\n $this->client->search('deals', []);\n $this->fail('Expected RateLimitException');\n } catch (RateLimitException $e) {\n $this->assertGreaterThanOrEqual(1, $e->getRetryAfter());\n $this->assertLessThanOrEqual($remaining, $e->getRetryAfter());\n } finally {\n Mockery::close();\n }\n }\n\n // -------------------------------------------------------------------------\n // isHubspotRateLimit() tests (private method — tested via reflection)\n // -------------------------------------------------------------------------\n\n private function callIsHubspotRateLimit(\\Throwable $e): bool\n {\n $method = new \\ReflectionMethod($this->client, 'isHubspotRateLimit');\n $method->setAccessible(true);\n\n return $method->invoke($this->client, $e);\n }\n\n public function testIsHubspotRateLimitReturnsTrueForBadRequest429(): void\n {\n $e = new BadRequest('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForDealApiException429(): void\n {\n $e = new DealApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForContactApiException429(): void\n {\n $e = new ContactApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForCompanyApiException429(): void\n {\n $e = new CompanyApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForGuzzleRequestException429(): void\n {\n $request = new \\GuzzleHttp\\Psr7\\Request('POST', 'https://api.hubapi.com/test');\n $response = new \\GuzzleHttp\\Psr7\\Response(429);\n $e = new \\GuzzleHttp\\Exception\\RequestException('Too Many Requests', $request, $response);\n\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsFalseForBadRequestNon429(): void\n {\n $e = new BadRequest('Bad Request', 400);\n $this->assertFalse($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsFalseForGenericException(): void\n {\n $e = new \\RuntimeException('Something went wrong');\n $this->assertFalse($this->callIsHubspotRateLimit($e));\n }\n\n // -------------------------------------------------------------------------\n // parseRetryAfter() tests (private method — tested via reflection)\n // -------------------------------------------------------------------------\n\n private function callParseRetryAfter(\\Throwable $e): int\n {\n $method = new \\ReflectionMethod($this->client, 'parseRetryAfter');\n $method->setAccessible(true);\n\n return $method->invoke($this->client, $e);\n }\n\n public function testParseRetryAfterReadsRetryAfterHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => ['30']]);\n $this->assertSame(30, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReadsLowercaseRetryAfterHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['retry-after' => ['15']]);\n $this->assertSame(15, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterHandlesScalarHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => '60']);\n $this->assertSame(60, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsDailyLimitDelay(): void\n {\n $e = new BadRequest('Daily rate limit exceeded');\n $this->assertSame(600, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsTenSecondlyDelay(): void\n {\n $e = new BadRequest('Ten secondly rate limit exceeded');\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsSecondlyDelay(): void\n {\n $e = new BadRequest('Secondly rate limit exceeded');\n $this->assertSame(1, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsDefaultWhenNoHint(): void\n {\n $e = new BadRequest('Rate limit exceeded');\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterIgnoresNonNumericHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => ['not-a-number']]);\n // Falls through to message parsing; \"Too Many Requests\" has no known keyword → default 10\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n}","depth":4,"bounds":{"left":0.124667555,"top":0.17158818,"width":0.42852393,"height":0.8284118},"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Hubspot;\n\nuse GuzzleHttp\\Psr7\\Response;\nuse HubSpot\\Client\\Crm\\Associations\\Api\\BatchApi;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\BatchResponsePublicAssociationMulti;\nuse HubSpot\\Client\\Crm\\Associations\\Model\\PublicObjectId;\nuse HubSpot\\Client\\Crm\\Companies\\ApiException as CompanyApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\ApiException as ContactApiException;\nuse HubSpot\\Client\\Crm\\Deals\\Api\\BasicApi as DealsBasicApi;\nuse HubSpot\\Client\\Crm\\Deals\\ApiException as DealApiException;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectWithAssociations as DealWithAssociations;\nuse HubSpot\\Client\\Crm\\Pipelines\\Api\\PipelinesApi;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\CollectionResponsePipeline;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Error;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Pipeline;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\PipelineStage;\nuse HubSpot\\Client\\Crm\\Properties\\Api\\CoreApi;\nuse HubSpot\\Client\\Crm\\Properties\\Model\\Property;\nuse HubSpot\\Discovery as HubSpotDiscovery;\nuse HubSpot\\Discovery\\Crm\\Deals\\Discovery as DealsDiscovery;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\HubspotTokenManager;\nuse Jiminny\\Services\\Crm\\Hubspot\\Pagination\\HubspotPaginationService;\nuse Jiminny\\Services\\SocialAccountService;\nuse League\\OAuth2\\Client\\Token\\AccessToken;\nuse Mockery;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse PHPUnit\\Framework\\TestCase;\nuse Psr\\Log\\NullLogger;\nuse SevenShores\\Hubspot\\Endpoints\\Engagements;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Factory;\nuse SevenShores\\Hubspot\\Http\\Client as HubspotClient;\nuse SevenShores\\Hubspot\\Http\\Response as HubspotResponse;\n\n/**\n * @runTestsInSeparateProcesses\n *\n * @preserveGlobalState disabled\n */\nclass ClientTest extends TestCase\n{\n private const string RESPONSE_TYPE_STAGE_FIELD = 'stage';\n private const string RESPONSE_TYPE_PIPELINE_FIELD = 'pipeline';\n private const string RESPONSE_TYPE_REGULAR_FIELD = 'regular';\n /**\n * @var Client&MockObject\n */\n private Client $client;\n private Configuration $config;\n\n /**\n * @var SocialAccountService&MockObject\n */\n private SocialAccountService $socialAccountServiceMock;\n\n /**\n * @var HubspotPaginationService&MockObject\n */\n private HubspotPaginationService $paginationServiceMock;\n\n /**\n * @var HubspotTokenManager&MockObject\n */\n private HubspotTokenManager $tokenManagerMock;\n\n /**\n * @var CoreApi&MockObject\n */\n private CoreApi $coreApiMock;\n\n /**\n * @var PipelinesApi&MockObject\n */\n private PipelinesApi $pipelinesApiMock;\n\n /**\n * @var BatchApi&MockObject\n */\n private BatchApi $associationsBatchApiMock;\n\n /**\n * @var DealsBasicApi&MockObject\n */\n private DealsBasicApi $dealsBasicApiMock;\n\n /**\n * @var Engagements&MockObject\n */\n private $engagementsMock;\n\n /**\n * @var HubspotClient&MockObject\n */\n private HubspotClient $hubspotClientMock;\n\n protected function setUp(): void\n {\n // Create mocks for dependencies\n $this->socialAccountServiceMock = $this->createMock(SocialAccountService::class);\n $this->paginationServiceMock = $this->createMock(HubspotPaginationService::class);\n $this->tokenManagerMock = $this->createMock(HubspotTokenManager::class);\n\n // Create a real Client instance with mocked dependencies\n // Create a partial mock only for the methods we need to mock\n $client = $this->createPartialMock(Client::class, ['getInstance', 'getNewInstance', 'makeRequest']);\n $client->setLogger(new NullLogger());\n\n // Inject the real dependencies using reflection\n $reflection = new \\ReflectionClass(Client::class);\n\n $accountServiceProperty = $reflection->getProperty('accountService');\n $accountServiceProperty->setAccessible(true);\n $accountServiceProperty->setValue($client, $this->socialAccountServiceMock);\n\n $paginationServiceProperty = $reflection->getProperty('paginationService');\n $paginationServiceProperty->setAccessible(true);\n $paginationServiceProperty->setValue($client, $this->paginationServiceMock);\n\n $tokenManagerProperty = $reflection->getProperty('tokenManager');\n $tokenManagerProperty->setAccessible(true);\n $tokenManagerProperty->setValue($client, $this->tokenManagerMock);\n $factoryMock = $this->createMock(Factory::class);\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $engagementsMock = $this->createMock(\\SevenShores\\Hubspot\\Endpoints\\Engagements::class);\n\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n $factoryMock->method('__call')->with('engagements')->willReturn($engagementsMock);\n\n $client->method('getInstance')->willReturn($factoryMock);\n\n $discoveryMock = $this->createMock(HubSpotDiscovery\\Discovery::class);\n $crmMock = $this->createMock(HubSpotDiscovery\\Crm\\Discovery::class);\n $associationsMock = $this->createMock(HubSpotDiscovery\\Crm\\Associations\\Discovery::class);\n $propertiesMock = $this->createMock(HubSpotDiscovery\\Crm\\Properties\\Discovery::class);\n $pipelinesMock = $this->createMock(HubSpotDiscovery\\Crm\\Pipelines\\Discovery::class);\n $coreApiMock = $this->createMock(CoreApi::class);\n $pipelinesApiMock = $this->createMock(PipelinesApi::class);\n $associationsBatchApiMock = $this->createMock(BatchApi::class);\n $dealsBasicApiMock = $this->createMock(DealsBasicApi::class);\n\n $propertiesMock->method('__call')->with('coreApi')->willReturn($coreApiMock);\n $pipelinesMock->method('__call')->with('pipelinesApi')->willReturn($pipelinesApiMock);\n $associationsMock->method('__call')->with('batchApi')->willReturn($associationsBatchApiMock);\n $dealsDiscoveryMock = $this->createMock(DealsDiscovery::class);\n $dealsDiscoveryMock->method('__call')->with('basicApi')->willReturn($dealsBasicApiMock);\n\n $returnMap = ['properties' => $propertiesMock, 'pipelines' => $pipelinesMock, 'associations' => $associationsMock, 'deals' => $dealsDiscoveryMock];\n $crmMock->method('__call')\n ->willReturnCallback(static fn (string $name) => $returnMap[$name])\n ;\n\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n\n $this->client = $client;\n $this->config = $this->createMock(Configuration::class);\n $this->client->setConfiguration($this->config);\n $this->coreApiMock = $coreApiMock;\n $this->pipelinesApiMock = $pipelinesApiMock;\n $this->associationsBatchApiMock = $associationsBatchApiMock;\n $this->dealsBasicApiMock = $dealsBasicApiMock;\n $this->engagementsMock = $engagementsMock;\n $this->hubspotClientMock = $hubspotClientMock;\n }\n\n public function testGetMinimumApiVersion(): void\n {\n $this->assertIsString($this->client->getMinimumApiVersion());\n }\n\n public function testGetInstance(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $client = new Client($socialAccountService, $paginationService, $tokenManager);\n $client->setBaseUrl('https://example.com');\n $client->setAccessToken(new AccessToken(['access_token' => 'foo']));\n $factory = $client->getInstance();\n\n $this->assertEquals('foo', $factory->getClient()->key);\n }\n\n public function testGetNewInstance(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $client = new Client($socialAccountService, $paginationService, $tokenManager);\n $client->setAccessToken(new AccessToken(['access_token' => 'foo']));\n\n $factory = $client->getNewInstance();\n $token = $factory->auth()->oAuth()->accessTokensApi()->getConfig()->getAccessToken();\n $this->assertEquals('foo', $token);\n }\n\n public function testFetchProperty(): void\n {\n $this->coreApiMock->method('getByName')->willReturn($this->generateProperty());\n\n $this->assertEquals(\n 'some_property',\n $this->client->fetchProperty('foo', 'test')['name']\n );\n }\n\n public function testFetchPropertyOptions(): void\n {\n $this->coreApiMock->method('getByName')->willReturn($this->generateProperty());\n\n $this->assertEquals(\n [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n $this->client->fetchPropertyOptions('foo', 'test')\n );\n }\n\n public function testFetchCallDispositions(): void\n {\n $this->engagementsMock\n ->method('getCallDispositions')\n ->willReturn($this->generateHubSpotResponse([\n [\n 'id' => 1,\n 'label' => 'foo',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'bar',\n 'deleted' => false,\n ],\n ]))\n ;\n\n $this->assertEquals(\n [\n [\n 'id' => 1,\n 'label' => 'foo',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'bar',\n 'deleted' => false,\n ],\n ],\n $this->client->fetchCallDispositions()\n );\n }\n\n public function testFetchDispositionFieldOptions(): void\n {\n $this->engagementsMock->method('getCallDispositions')\n ->willReturn($this->generateHubSpotResponse(\n [\n [\n 'id' => 1,\n 'label' => 'Label 1',\n 'deleted' => false,\n ],\n [\n 'id' => 2,\n 'label' => 'Label 2',\n 'deleted' => true,\n ],\n ]\n ))\n ;\n\n $this->assertEquals(\n [\n [\n 'value' => 1,\n 'id' => 1,\n 'label' => 'Label 1',\n ],\n ],\n $this->client->fetchDispositionFieldOptions()\n );\n }\n\n public function testFetchOpportunityPipelineStages(): void\n {\n /**\n * @TODO: The class CollectionResponsePipeline will be deprecated in the next Hubspot version\n */\n $this->pipelinesApiMock\n ->method('getAll')\n ->with('deals')\n ->willReturn(new CollectionResponsePipeline([\n 'results' => [$this->generatePipeline()],\n ]))\n ;\n\n $this->assertEquals(\n [\n ['id' => 'foo', 'label' => 'bar'],\n ['id' => 'baz', 'label' => 'qux'],\n ],\n $this->client->fetchOpportunityPipelineStages()\n );\n }\n\n public function testFetchOpportunityPipelineStagesErrorResponse(): void\n {\n $this->pipelinesApiMock\n ->method('getAll')\n ->with('deals')\n ->willReturn(new Error(['message' => 'test error']))\n ;\n\n $this->assertEmpty($this->client->fetchOpportunityPipelineStages());\n }\n\n #[\\PHPUnit\\Framework\\Attributes\\DataProvider('meetingOutcomeFieldProvider')]\n public function testFetchMeetingOutcomeFieldOptions(string $fieldId, string $expectedEndpoint): void\n {\n $field = new Field(['crm_provider_id' => $fieldId]);\n\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->with('GET', $expectedEndpoint)\n ->willReturn($this->generateHubSpotResponse(\n [\n 'options' => [\n ['value' => 'option_1', 'label' => 'Option 1', 'displayOrder' => 0],\n ['value' => 'option_2', 'label' => 'Option 2', 'displayOrder' => 1],\n ['value' => 'option_3', 'label' => 'Option 3', 'displayOrder' => 2],\n ],\n ]\n ))\n ;\n\n $this->assertEquals(\n [\n ['id' => 'option_1', 'value' => 'option_1', 'label' => 'Option 1', 'display_order' => 0],\n ['id' => 'option_2', 'value' => 'option_2', 'label' => 'Option 2', 'display_order' => 1],\n ['id' => 'option_3', 'value' => 'option_3', 'label' => 'Option 3', 'display_order' => 2],\n ],\n $this->client->fetchMeetingOutcomeFieldOptions($field)\n );\n }\n\n public static function meetingOutcomeFieldProvider(): array\n {\n return [\n 'meeting outcome field' => [\n 'meetingOutcome',\n 'https://api.hubapi.com/crm/v3/properties/meeting/hs_meeting_outcome',\n ],\n 'activity type field' => [\n 'foobar',\n 'https://api.hubapi.com/crm/v3/properties/call/hs_activity_type',\n ],\n ];\n }\n\n #[\\PHPUnit\\Framework\\Attributes\\DataProvider('opportunityFieldOptionsProvider')]\n public function testFetchOpportunityFieldOptions(Field $field, string $type): void\n {\n $responses = [\n self::RESPONSE_TYPE_STAGE_FIELD => [\n ['id' => 'foo', 'label' => 'bar'],\n ['id' => 'baz', 'label' => 'qux'],\n ],\n self::RESPONSE_TYPE_PIPELINE_FIELD => [\n ['id' => '123', 'label' => 'Sales'],\n ['id' => 'default', 'label' => 'CS'],\n ],\n self::RESPONSE_TYPE_REGULAR_FIELD => [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n ];\n\n $field = $this->createMock(Field::class);\n\n if ($type === self::RESPONSE_TYPE_REGULAR_FIELD) {\n $field->method('isStageField')->willReturn(false);\n $field->method('isPipelineField')->willReturn(false);\n $propertyResponse = $this->generateProperty();\n $this->coreApiMock->method('getByName')->willReturn($propertyResponse);\n }\n\n if ($type === self::RESPONSE_TYPE_STAGE_FIELD) {\n $field->method('isStageField')->willReturn(true);\n /**\n * @TODO: The class CollectionResponsePipeline will be deprecated in the next Hubspot version\n */\n\n $pipelineStagesResponse = new CollectionResponsePipeline([\n 'results' => [$this->generatePipeline()],\n ]);\n $this->pipelinesApiMock\n ->method('getAll')\n ->willReturn($pipelineStagesResponse);\n }\n\n if ($type === self::RESPONSE_TYPE_PIPELINE_FIELD) {\n $field->method('isStageField')->willReturn(false);\n $field->method('isPipelineField')->willReturn(true);\n $pipelineResponse = $this->generateHubSpotResponse([\n 'results' => [\n ['id' => '123', 'label' => 'Sales'],\n ['id' => 'default', 'label' => 'CS'],\n ],\n ]);\n $this->client\n ->method('makeRequest')\n ->with('/crm/v3/pipelines/deals')\n ->willReturn($pipelineResponse);\n }\n\n $this->assertEquals(\n $responses[$type],\n $this->client->fetchOpportunityFieldOptions($field)\n );\n }\n\n public static function opportunityFieldOptionsProvider(): array\n {\n return [\n 'stage field' => [\n 'field' => new Field(['crm_provider_id' => 'dealstage']),\n 'type' => self::RESPONSE_TYPE_STAGE_FIELD,\n ],\n 'pipeline field' => [\n 'field' => new Field(['crm_provider_id' => 'pipeline']),\n 'type' => self::RESPONSE_TYPE_PIPELINE_FIELD,\n ],\n 'regular field' => [\n 'field' => new Field(['crm_provider_id' => 'some_property']),\n 'type' => self::RESPONSE_TYPE_REGULAR_FIELD,\n ],\n ];\n }\n\n private function generateHubSpotResponse(array $data): HubspotResponse\n {\n return new HubspotResponse(new Response(200, [], json_encode($data)));\n }\n\n private function generateProperty(): Property\n {\n return new Property([\n 'name' => 'some_property',\n 'options' => [\n [\n 'label' => 'label_1',\n 'value' => 'value_1',\n ],\n [\n 'label' => 'label_2',\n 'value' => 'value_2',\n ],\n ],\n ]);\n }\n\n private function generatePipeline(): Pipeline\n {\n return new Pipeline(['stages' => [\n new PipelineStage(['id' => 'foo', 'label' => 'bar']),\n new PipelineStage(['id' => 'baz', 'label' => 'qux']),\n ]]);\n }\n\n public function testFetchOpportunityPipelines(): void\n {\n $this->client\n ->method('makeRequest')\n ->with('/crm/v3/pipelines/deals')\n ->willReturn($this->generateHubSpotResponse([\n 'results' => [\n ['id' => 'id_1', 'label' => 'Option 1', 'displayOrder' => 0],\n ['id' => 'id_2', 'label' => 'Option 2', 'displayOrder' => 1],\n ['id' => 'id_3', 'label' => 'Option 3', 'displayOrder' => 2],\n ],\n ]));\n\n $this->assertEquals(\n [\n ['id' => 'id_1', 'label' => 'Option 1'],\n ['id' => 'id_2', 'label' => 'Option 2'],\n ['id' => 'id_3', 'label' => 'Option 3'],\n ],\n $this->client->fetchOpportunityPipelines()\n );\n }\n\n public function testGetPaginatedData(): void\n {\n $expectedResults = [\n ['id' => 'id_1', 'properties' => []],\n ['id' => 'id_2', 'properties' => []],\n ['id' => 'id_3', 'properties' => []],\n ];\n\n // Mock the pagination service to return a generator and modify reference parameters\n $this->paginationServiceMock\n ->expects($this->once())\n ->method('getPaginatedDataGenerator')\n ->with(\n $this->client,\n ['payload_key' => 'payload_value'],\n 'foobar',\n 0,\n $this->anything(),\n $this->anything()\n )\n ->willReturnCallback(function ($client, $payload, $type, $offset, &$total, &$lastRecordId) use ($expectedResults) {\n $total = 3;\n $lastRecordId = 'id_3';\n foreach ($expectedResults as $result) {\n yield $result;\n }\n });\n\n $this->assertEquals(\n [\n 'results' => $expectedResults,\n 'total' => 3,\n 'last_record' => 'id_3',\n ],\n $this->client->getPaginatedData(['payload_key' => 'payload_value'], 'foobar')\n );\n }\n\n public function testGetAssociationsData(): void\n {\n $ids = ['1', '2', '3'];\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $responseResults = [];\n foreach ($ids as $id) {\n $from = new PublicObjectId();\n $from->setId($id);\n\n $to1 = new PublicObjectId();\n $to1->setId('contact_' . $id . '_1');\n $to2 = new PublicObjectId();\n $to2->setId('contact_' . $id . '_2');\n\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to1, $to2]);\n\n $responseResults[] = $result;\n }\n\n $batchResponse = new BatchResponsePublicAssociationMulti();\n $batchResponse->setResults($responseResults);\n\n $this->associationsBatchApiMock->expects($this->once())\n ->method('read')\n ->with(\n $this->equalTo($fromObject),\n $this->equalTo($toObject),\n $this->callback(function (BatchInputPublicObjectId $batchInput) use ($ids) {\n $inputIds = array_map(\n fn ($input) => $input->getId(),\n $batchInput->getInputs()\n );\n\n return $inputIds === $ids;\n })\n )\n ->willReturn($batchResponse);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $expectedResult = [\n '1' => ['contact_1_1', 'contact_1_2'],\n '2' => ['contact_2_1', 'contact_2_2'],\n '3' => ['contact_3_1', 'contact_3_2'],\n ];\n\n $this->assertEquals($expectedResult, $result);\n }\n\n public function testGetAssociationsDataHandlesException(): void\n {\n $ids = ['1', '2', '3'];\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $exception = new \\Exception('API Error');\n\n $this->associationsBatchApiMock->expects($this->once())\n ->method('read')\n ->with(\n $this->equalTo($fromObject),\n $this->equalTo($toObject),\n $this->callback(function ($batchInput) use ($ids) {\n return $batchInput instanceof \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId\n && count($batchInput->getInputs()) === count($ids);\n })\n )\n ->willThrowException($exception);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with(\n '[Hubspot] Failed to fetch associations',\n [\n 'from_object' => $fromObject,\n 'to_object' => $toObject,\n 'reason' => 'API Error',\n ]\n );\n\n $this->client->setLogger($loggerMock);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $this->assertEmpty($result);\n $this->assertIsArray($result);\n }\n\n public function testGetAssociationsDataWithLargeDataSet(): void\n {\n $ids = array_map(fn ($i) => (string) $i, range(1, 2500)); // More than 1000 items\n $fromObject = 'deals';\n $toObject = 'contacts';\n\n $firstBatchResponse = new BatchResponsePublicAssociationMulti();\n $firstBatchResults = array_map(function ($id) {\n $from = new PublicObjectId();\n $from->setId($id);\n $to = new PublicObjectId();\n $to->setId('contact_' . $id);\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to]);\n\n return $result;\n }, array_slice($ids, 0, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));\n $firstBatchResponse->setResults($firstBatchResults);\n\n $secondBatchResponse = new BatchResponsePublicAssociationMulti();\n $secondBatchResults = array_map(function ($id) {\n $from = new PublicObjectId();\n $from->setId($id);\n $to = new PublicObjectId();\n $to->setId('contact_' . $id);\n $result = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicAssociationMulti();\n $result->setFrom($from);\n $result->setTo([$to]);\n\n return $result;\n }, array_slice($ids, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));\n $secondBatchResponse->setResults($secondBatchResults);\n\n $this->associationsBatchApiMock->expects($this->exactly(3))\n ->method('read')\n ->willReturnOnConsecutiveCalls($firstBatchResponse, $secondBatchResponse);\n\n $result = $this->client->getAssociationsData($ids, $fromObject, $toObject);\n\n $this->assertCount(2500, $result);\n $this->assertArrayHasKey('1', $result);\n $this->assertArrayHasKey('2500', $result);\n $this->assertEquals(['contact_1'], $result['1']);\n $this->assertEquals(['contact_2500'], $result['2500']);\n }\n\n public function testGetContactByEmailSuccess(): void\n {\n $email = 'test@example.com';\n $fields = ['firstname', 'lastname', 'email'];\n\n $contactMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $contactMock->method('getId')->willReturn('12345');\n $contactMock->method('getProperties')->willReturn([\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n 'email' => 'test@example.com',\n ]);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, 'firstname,lastname,email', null, false, 'email')\n ->willReturn($contactMock);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new NullLogger());\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([\n 'id' => '12345',\n 'properties' => [\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n 'email' => 'test@example.com',\n ],\n ], $result);\n }\n\n public function testGetContactByEmailWithEmptyFields(): void\n {\n $email = 'test@example.com';\n $fields = [];\n\n $contactMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $contactMock->method('getId')->willReturn('12345');\n $contactMock->method('getProperties')->willReturn(['email' => 'test@example.com']);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, '', null, false, 'email')\n ->willReturn($contactMock);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new NullLogger());\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([\n 'id' => '12345',\n 'properties' => ['email' => 'test@example.com'],\n ], $result);\n }\n\n public function testGetContactByEmailApiException(): void\n {\n $email = 'nonexistent@example.com';\n $fields = ['firstname', 'lastname'];\n\n $exception = new \\HubSpot\\Client\\Crm\\Contacts\\ApiException('Contact not found', 404);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($email, 'firstname,lastname', null, false, 'email')\n ->willThrowException($exception);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('info')\n ->with(\n '[Hubspot] Failed to fetch contact',\n [\n 'email' => $email,\n 'reason' => 'Contact not found',\n ]\n );\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger($loggerMock);\n\n $result = $client->getContactByEmail($email, $fields);\n\n $this->assertEquals([], $result);\n }\n\n public function testGetOpportunityById(): void\n {\n $opportunityId = '12345';\n $expectedProperties = [\n 'dealname' => 'Test Opportunity',\n 'amount' => '1000.00',\n 'closedate' => '2024-12-31T23:59:59.999Z',\n 'dealstage' => 'presentationscheduled',\n 'pipeline' => 'default',\n ];\n\n $mockHubspotOpportunity = $this->createMock(DealWithAssociations::class);\n $mockHubspotOpportunity->method('getProperties')->willReturn((object) $expectedProperties);\n $mockHubspotOpportunity->method('getId')->willReturn($opportunityId);\n $now = new \\DateTimeImmutable();\n $mockHubspotOpportunity->method('getCreatedAt')->willReturn($now);\n $mockHubspotOpportunity->method('getUpdatedAt')->willReturn($now);\n $mockHubspotOpportunity->method('getArchived')->willReturn(false);\n\n $this->dealsBasicApiMock\n ->expects($this->once())\n ->method('getById')\n ->willReturn($mockHubspotOpportunity);\n\n // Assuming Client::getOpportunityById processes the SimplePublicObject and returns an associative array.\n // The structure might be like: ['id' => ..., 'properties' => [...], 'createdAt' => ..., ...]\n // Adjust assertions below based on the actual return structure of your method.\n $result = $this->client->getOpportunityById($opportunityId, ['test', 'test']);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertEquals($opportunityId, $result['id']);\n }\n\n public function testGetContactById(): void\n {\n $crmId = 'contact-123';\n $fields = ['firstname', 'lastname'];\n $expectedProperties = ['firstname' => 'John', 'lastname' => 'Doe'];\n\n $mockContact = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations::class);\n $mockContact->method('getId')->willReturn($crmId);\n $mockContact->method('getProperties')->willReturn((object) $expectedProperties);\n\n $contactsApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Contacts\\Api\\BasicApi::class);\n $contactsApiMock->expects($this->once())\n ->method('getById')\n ->with($crmId, 'firstname,lastname')\n ->willReturn($mockContact);\n\n $contactsDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Contacts\\Discovery::class);\n $contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {\n if ($name === 'contacts') {\n return $contactsDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new \\Psr\\Log\\NullLogger());\n\n $result = $client->getContactById($crmId, $fields);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertArrayHasKey('properties', $result);\n $this->assertEquals($crmId, $result['id']);\n $this->assertEquals((object) $expectedProperties, $result['properties']);\n }\n\n public function testGetAccountById(): void\n {\n $crmId = 'account-123';\n $fields = ['name', 'industry'];\n $expectedProperties = ['name' => 'Acme Corp', 'industry' => 'Technology'];\n\n $mockCompany = $this->createMock(\\HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectWithAssociations::class);\n $mockCompany->method('getId')->willReturn($crmId);\n $mockCompany->method('getProperties')->willReturn((object) $expectedProperties);\n\n $companiesApiMock = $this->createMock(\\HubSpot\\Client\\Crm\\Companies\\Api\\BasicApi::class);\n $companiesApiMock->expects($this->once())\n ->method('getById')\n ->with($crmId, 'name,industry')\n ->willReturn($mockCompany);\n\n $companiesDiscoveryMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Companies\\Discovery::class);\n $companiesDiscoveryMock->method('__call')->with('basicApi')->willReturn($companiesApiMock);\n\n $crmMock = $this->createMock(\\HubSpot\\Discovery\\Crm\\Discovery::class);\n $crmMock->method('__call')->willReturnCallback(function ($name) use ($companiesDiscoveryMock) {\n if ($name === 'companies') {\n return $companiesDiscoveryMock;\n }\n\n return $this->createMock(\\HubSpot\\Discovery\\Crm\\Properties\\Discovery::class);\n });\n\n $discoveryMock = $this->createMock(\\HubSpot\\Discovery\\Discovery::class);\n $discoveryMock->method('__call')->with('crm')->willReturn($crmMock);\n\n $client = $this->createPartialMock(Client::class, ['getNewInstance']);\n $client->method('getNewInstance')->willReturn($discoveryMock);\n $client->setLogger(new \\Psr\\Log\\NullLogger());\n\n $result = $client->getAccountById($crmId, $fields);\n\n $this->assertIsArray($result);\n $this->assertArrayHasKey('id', $result);\n $this->assertArrayHasKey('properties', $result);\n $this->assertEquals($crmId, $result['id']);\n $this->assertEquals((object) $expectedProperties, $result['properties']);\n }\n\n public function testEnsureValidTokenWithNoTokenUpdate(): void\n {\n $socialAccountMock = $this->createMock(SocialAccount::class);\n\n // Set up OAuth account\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, $socialAccountMock);\n\n $originalToken = 'original_token';\n $accessTokenProperty = $reflection->getProperty('accessToken');\n $accessTokenProperty->setAccessible(true);\n $accessTokenProperty->setValue($this->client, $originalToken);\n\n // Mock token manager to return null (no refresh needed)\n $this->tokenManagerMock\n ->expects($this->once())\n ->method('ensureValidToken')\n ->with($socialAccountMock)\n ->willReturn(null);\n\n // Call ensureValidToken\n $this->client->ensureValidToken();\n\n // Verify access token was not changed\n $this->assertEquals($originalToken, $accessTokenProperty->getValue($this->client));\n }\n\n\n\n\n\n\n public function testGetPaginatedDataGeneratorDelegatesToPaginationService(): void\n {\n $payload = ['filters' => []];\n $type = 'contacts';\n $offset = 0;\n $total = 0;\n $lastRecordId = null;\n\n $expectedResults = [\n ['id' => 'id_1', 'properties' => []],\n ['id' => 'id_2', 'properties' => []],\n ];\n\n // Mock the pagination service to return a generator\n $this->paginationServiceMock\n ->expects($this->once())\n ->method('getPaginatedDataGenerator')\n ->with(\n $this->client,\n $payload,\n $type,\n $offset,\n $this->anything(),\n $this->anything()\n )\n ->willReturnCallback(function () use ($expectedResults) {\n foreach ($expectedResults as $result) {\n yield $result;\n }\n });\n\n // Execute the pagination\n $results = [];\n foreach ($this->client->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastRecordId) as $result) {\n $results[] = $result;\n }\n\n $this->assertCount(2, $results);\n $this->assertEquals('id_1', $results[0]['id']);\n $this->assertEquals('id_2', $results[1]['id']);\n }\n\n public function testEnsureValidTokenDelegatesToTokenManager(): void\n {\n $socialAccountMock = $this->createMock(SocialAccount::class);\n\n // Set up OAuth account\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, $socialAccountMock);\n\n // Mock token manager to return new token\n $this->tokenManagerMock\n ->expects($this->once())\n ->method('ensureValidToken')\n ->with($socialAccountMock)\n ->willReturn('new_access_token');\n\n // Call ensureValidToken\n $this->client->ensureValidToken();\n\n // Verify access token was updated\n $accessTokenProperty = $reflection->getProperty('accessToken');\n $accessTokenProperty->setAccessible(true);\n $this->assertEquals('new_access_token', $accessTokenProperty->getValue($this->client));\n }\n\n public function testGetOwnersArchivedWithValidResponse(): void\n {\n $responseData = [\n 'results' => [\n [\n 'id' => '123',\n 'email' => 'owner1@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'John',\n 'lastName' => 'Doe',\n 'userId' => 456,\n 'userIdIncludingInactive' => 789,\n 'createdAt' => '2023-01-01T12:00:00Z',\n 'updatedAt' => '2023-01-02T12:00:00Z',\n 'archived' => true,\n ],\n ],\n ];\n\n // Create a mock response object\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn($responseData);\n\n // Set up the client to return our test data\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=true'\n )\n ->willReturn($response);\n\n // Call the method\n $result = $this->client->getOwnersArchived(true);\n\n // Assert the results\n $this->assertCount(1, $result);\n $this->assertEquals('123', $result[0]->getId());\n $this->assertEquals('owner1@example.com', $result[0]->getEmail());\n $this->assertEquals('John Doe', $result[0]->getFullName());\n $this->assertTrue($result[0]->isArchived());\n }\n\n public function testGetOwnersArchivedWithEmptyResponse(): void\n {\n // Create a mock response object with empty results\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn(['results' => []]);\n\n // Set up the client to return empty results\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=false'\n )\n ->willReturn($response);\n\n // Call the method\n $result = $this->client->getOwnersArchived(false);\n\n // Assert the results\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithInvalidResponse(): void\n {\n // Create a mock response that will throw an exception when toArray is called\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willThrowException(new \\InvalidArgumentException('Invalid JSON'));\n\n // Set up the client to return the problematic response\n $this->client->method('makeRequest')\n ->willReturn($response);\n\n // Mock the logger to expect an error message\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with($this->stringContains('Failed to fetch owners'));\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n // Call the method and expect an empty array on error\n $result = $this->client->getOwnersArchived(true);\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithHttpError(): void\n {\n // Set up the client to throw an exception\n $this->client->method('makeRequest')\n ->willThrowException(new \\Exception('HTTP Error'));\n\n // Mock the logger to expect an error message\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with($this->stringContains('Failed to fetch owners'));\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n // Call the method and expect an empty array on error\n $result = $this->client->getOwnersArchived(false);\n $this->assertIsArray($result);\n $this->assertEmpty($result);\n }\n\n public function testGetOwnersArchivedWithOwnerCreationException(): void\n {\n $responseData = [\n 'results' => [\n [\n 'id' => '123',\n 'email' => 'valid@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'John',\n 'lastName' => 'Doe',\n 'userId' => 456,\n 'userIdIncludingInactive' => 789,\n 'createdAt' => '2023-01-01T12:00:00Z',\n 'updatedAt' => '2023-01-02T12:00:00Z',\n 'archived' => false,\n ],\n [\n 'id' => '456',\n 'email' => 'invalid@example.com',\n 'type' => 'PERSON',\n 'createdAt' => 'invalid-date-format',\n ],\n [\n 'id' => '789',\n 'email' => 'another@example.com',\n 'type' => 'PERSON',\n 'firstName' => 'Jane',\n 'lastName' => 'Smith',\n 'userId' => 999,\n 'userIdIncludingInactive' => 888,\n 'createdAt' => '2023-01-03T12:00:00Z',\n 'updatedAt' => '2023-01-04T12:00:00Z',\n 'archived' => false,\n ],\n ],\n ];\n\n $response = $this->createMock(\\SevenShores\\Hubspot\\Http\\Response::class);\n $response->method('toArray')\n ->willReturn($responseData);\n\n $this->client->method('makeRequest')\n ->with(\n '/crm/v3/owners',\n 'GET',\n [],\n 'archived=false'\n )\n ->willReturn($response);\n\n $loggerMock = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $loggerMock->expects($this->once())\n ->method('error')\n ->with(\n '[HubSpot] Failed to process owner data',\n $this->callback(function ($context) {\n return isset($context['result']) &&\n isset($context['error']) &&\n $context['result']['id'] === '456' &&\n $context['result']['email'] === 'invalid@example.com' &&\n str_contains($context['error'], 'invalid-date-format');\n })\n );\n\n $reflection = new \\ReflectionClass($this->client);\n $loggerProperty = $reflection->getProperty('log');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($this->client, $loggerMock);\n\n $result = $this->client->getOwnersArchived(false);\n\n $this->assertIsArray($result);\n $this->assertCount(2, $result);\n $this->assertEquals('123', $result[0]->getId());\n $this->assertEquals('valid@example.com', $result[0]->getEmail());\n $this->assertEquals('789', $result[1]->getId());\n $this->assertEquals('another@example.com', $result[1]->getEmail());\n }\n\n public function testMakeRequestWithGetMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'GET',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n [],\n null,\n true\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'GET');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithGetMethodAndQueryString(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'GET',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n [],\n 'limit=10&offset=0',\n true\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'GET', [], 'limit=10&offset=0');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPostMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'firstname' => 'John',\n 'lastname' => 'Doe',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'POST',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'POST', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPutMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'firstname' => 'Jane',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345', 'updated' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'PUT',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'PUT', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithPatchMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $payload = [\n 'properties' => [\n 'email' => 'newemail@example.com',\n ],\n ];\n\n $psrResponse = new Response(200, [], json_encode(['id' => '12345', 'patched' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'PATCH',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => $payload]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'PATCH', $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithDeleteMethod(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['deleted' => true]));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'DELETE',\n 'https://api.hubapi.com/crm/v3/objects/contacts/12345',\n ['json' => []]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts/12345', 'DELETE');\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestWithEmptyPayload(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'ok']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n 'POST',\n 'https://api.hubapi.com/crm/v3/objects/contacts',\n ['json' => []]\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $result = $client->makeRequest('/crm/v3/objects/contacts', 'POST', []);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testMakeRequestPrependsBaseUrl(): void\n {\n $socialAccountService = $this->createMock(SocialAccountService::class);\n $paginationService = $this->createMock(HubspotPaginationService::class);\n $tokenManager = $this->createMock(HubspotTokenManager::class);\n\n $psrResponse = new Response(200, [], json_encode(['status' => 'success']));\n $expectedResponse = new HubspotResponse($psrResponse);\n\n $hubspotClientMock = $this->createMock(HubspotClient::class);\n $hubspotClientMock->expects($this->once())\n ->method('request')\n ->with(\n $this->anything(),\n 'https://api.hubapi.com/test/endpoint',\n $this->anything()\n )\n ->willReturn($expectedResponse);\n\n $factoryMock = $this->createMock(Factory::class);\n $factoryMock->method('getClient')->willReturn($hubspotClientMock);\n\n $client = $this->getMockBuilder(Client::class)\n ->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])\n ->onlyMethods(['getInstance'])\n ->getMock();\n\n $client->method('getInstance')->willReturn($factoryMock);\n $client->setAccessToken(new AccessToken(['access_token' => 'test_token']));\n\n $client->makeRequest('/test/endpoint', 'GET');\n }\n\n public function testGetOpportunitiesByIds(): void\n {\n $crmIds = ['deal1', 'deal2', 'deal3'];\n $fields = ['dealname', 'amount'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getOpportunitiesByIds'));\n }\n\n public function testGetCompaniesByIds(): void\n {\n $crmIds = ['company1', 'company2'];\n $fields = ['name', 'industry'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getCompaniesByIds'));\n }\n\n public function testGetContactsByIds(): void\n {\n $crmIds = ['contact1', 'contact2'];\n $fields = ['firstname', 'lastname'];\n\n // Test basic functionality - method exists and returns array\n $this->assertTrue(method_exists($this->client, 'getContactsByIds'));\n }\n\n public function testBatchReadObjectsEmptyIds(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $result = $method->invokeArgs($this->client, ['deals', [], ['dealname']]);\n\n $this->assertEquals([], $result);\n }\n\n public function testBatchReadObjectsExceedsBatchSize(): void\n {\n $crmIds = array_fill(0, 101, 'deal_id'); // 101 IDs, exceeds limit of 100\n\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $this->expectException(\\InvalidArgumentException::class);\n $this->expectExceptionMessage('Batch size cannot exceed 100 deals');\n\n $method->invokeArgs($this->client, ['deals', $crmIds, ['dealname']]);\n }\n\n public function testBatchReadObjectsUnsupportedObjectType(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $method = $reflection->getMethod('batchReadObjects');\n $method->setAccessible(true);\n\n $this->expectException(\\Jiminny\\Exceptions\\CrmException::class);\n $this->expectExceptionMessage('Failed to batch fetch unsupported');\n\n $method->invokeArgs($this->client, ['unsupported', ['id1'], ['field1']]);\n }\n\n public function testIsUnauthorizedExceptionWithBadRequest401(): void\n {\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\BadRequest('Unauthorized', 401);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithBadRequestNon401(): void\n {\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\BadRequest('Bad Request', 400);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertFalse($result);\n }\n\n public function testIsUnauthorizedExceptionWithGuzzleRequestException(): void\n {\n $response = new Response(401, [], 'Unauthorized');\n $exception = new \\GuzzleHttp\\Exception\\RequestException('Unauthorized', new \\GuzzleHttp\\Psr7\\Request('GET', 'test'), $response);\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithGuzzleClientException(): void\n {\n $exception = new \\GuzzleHttp\\Exception\\ClientException('Unauthorized', new \\GuzzleHttp\\Psr7\\Request('GET', 'test'), new Response(401));\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithStringMatching(): void\n {\n $exception = new \\Exception('HTTP 401 Unauthorized error occurred');\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertTrue($result);\n }\n\n public function testIsUnauthorizedExceptionWithNonUnauthorized(): void\n {\n $exception = new \\Exception('Some other error');\n\n $result = $this->client->isUnauthorizedException($exception);\n\n $this->assertFalse($result);\n }\n\n public function testEnsureValidTokenWithNullOauthAccount(): void\n {\n $reflection = new \\ReflectionClass($this->client);\n $oauthAccountProperty = $reflection->getProperty('oauthAccount');\n $oauthAccountProperty->setAccessible(true);\n $oauthAccountProperty->setValue($this->client, null);\n\n // Should not throw exception and not call token manager\n $this->tokenManagerMock->expects($this->never())->method('ensureValidToken');\n\n $this->client->ensureValidToken();\n\n $this->assertTrue(true); // Test passes if no exception is thrown\n }\n\n public function testGetConfig(): void\n {\n $result = $this->client->getConfig();\n\n $this->assertSame($this->config, $result);\n }\n\n public function testGetOwners(): void\n {\n // Test that the method exists and can be called\n $this->assertTrue(method_exists($this->client, 'getOwners'));\n\n // Since getOwners has complex HubSpot API dependencies,\n // we'll just verify the method exists for now\n $this->assertIsCallable([$this->client, 'getOwners']);\n }\n\n public function testCreateMeeting(): void\n {\n $payload = [\n 'properties' => [\n 'hs_meeting_title' => 'Test Meeting',\n 'hs_meeting_start_time' => '2024-01-01T10:00:00Z',\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['id' => 'meeting123']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with('/crm/v3/objects/meetings', 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->createMeeting($payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testUpdateMeeting(): void\n {\n $meetingId = 'meeting123';\n $payload = [\n 'properties' => [\n 'hs_meeting_title' => 'Updated Meeting',\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['id' => $meetingId, 'updated' => true]);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v3/objects/meetings/{$meetingId}\", 'PATCH', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->updateMeeting($meetingId, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testAddAssociations(): void\n {\n $objectType = 'deals';\n $associationType = 'contacts';\n $payload = [\n 'inputs' => [\n ['from' => ['id' => 'deal1'], 'to' => ['id' => 'contact1']],\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['status' => 'success']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v4/associations/{$objectType}/{$associationType}/batch/create\", 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->addAssociations($objectType, $associationType, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n public function testRemoveAssociations(): void\n {\n $objectType = 'deals';\n $associationType = 'contacts';\n $payload = [\n 'inputs' => [\n ['from' => ['id' => 'deal1'], 'to' => ['id' => 'contact1']],\n ],\n ];\n\n $expectedResponse = $this->generateHubSpotResponse(['status' => 'success']);\n\n $this->client->expects($this->once())\n ->method('makeRequest')\n ->with(\"/crm/v4/associations/{$objectType}/{$associationType}/batch/archive\", 'POST', $payload)\n ->willReturn($expectedResponse);\n\n $result = $this->client->removeAssociations($objectType, $associationType, $payload);\n\n $this->assertSame($expectedResponse, $result);\n }\n\n // -------------------------------------------------------------------------\n // search() / executeRequest() tests\n // -------------------------------------------------------------------------\n\n public function testSearchReturnsDecodedArrayOnSuccess(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n\n $this->config->method('getId')->willReturn(42);\n\n $payload = ['filters' => []];\n $responseData = ['results' => [['id' => '1']], 'total' => 1, 'paging' => []];\n $hubspotResponse = new HubspotResponse(new Response(200, [], json_encode($responseData)));\n\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->with('POST', Client::BASE_URL . '/crm/v3/objects/contacts/search', ['json' => $payload])\n ->willReturn($hubspotResponse);\n\n $result = $this->client->search('contacts', $payload);\n\n $this->assertSame($responseData, $result);\n\n Mockery::close();\n }\n\n public function testSearchThrowsRateLimitExceptionWhenCircuitBreakerActive(): void\n {\n $futureTimestamp = (string) (time() + 30);\n\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn($futureTimestamp);\n\n $this->config->method('getId')->willReturn(42);\n\n $this->hubspotClientMock->expects($this->never())->method('request');\n\n $this->expectException(RateLimitException::class);\n $this->expectExceptionMessage('Hubspot rate limit (cached circuit-breaker)');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchThrowsRateLimitExceptionAndSetsNxOnFresh429(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n // NX + TTL option array — exact TTL depends on parseRetryAfter, verified separately\n $redisMock->shouldReceive('set')\n ->once()\n ->with(\n 'hubspot:ratelimit:config:42',\n Mockery::type('string'),\n Mockery::on(fn ($opts) => is_array($opts) && in_array('nx', $opts, true))\n );\n\n $this->config->method('getId')->willReturn(42);\n\n $badRequest = new BadRequest('Rate limit exceeded', 429);\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->willThrowException($badRequest);\n\n $this->expectException(RateLimitException::class);\n $this->expectExceptionMessage('Hubspot returned 429');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchPropagatesNonRateLimitException(): void\n {\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn(null);\n\n $this->config->method('getId')->willReturn(42);\n\n $exception = new \\SevenShores\\Hubspot\\Exceptions\\HubspotException('Server error', 500);\n $this->hubspotClientMock\n ->expects($this->once())\n ->method('request')\n ->willThrowException($exception);\n\n $this->expectException(\\SevenShores\\Hubspot\\Exceptions\\HubspotException::class);\n $this->expectExceptionMessage('Server error');\n\n try {\n $this->client->search('contacts', []);\n } finally {\n Mockery::close();\n }\n }\n\n public function testSearchCircuitBreakerRetryAfterComputedFromStoredTimestamp(): void\n {\n $remaining = 45;\n $futureTimestamp = (string) (time() + $remaining);\n\n $redisMock = Mockery::mock('alias:Illuminate\\Support\\Facades\\Redis');\n $redisMock->shouldReceive('get')->once()->andReturn($futureTimestamp);\n\n $this->config->method('getId')->willReturn(1);\n\n try {\n $this->client->search('deals', []);\n $this->fail('Expected RateLimitException');\n } catch (RateLimitException $e) {\n $this->assertGreaterThanOrEqual(1, $e->getRetryAfter());\n $this->assertLessThanOrEqual($remaining, $e->getRetryAfter());\n } finally {\n Mockery::close();\n }\n }\n\n // -------------------------------------------------------------------------\n // isHubspotRateLimit() tests (private method — tested via reflection)\n // -------------------------------------------------------------------------\n\n private function callIsHubspotRateLimit(\\Throwable $e): bool\n {\n $method = new \\ReflectionMethod($this->client, 'isHubspotRateLimit');\n $method->setAccessible(true);\n\n return $method->invoke($this->client, $e);\n }\n\n public function testIsHubspotRateLimitReturnsTrueForBadRequest429(): void\n {\n $e = new BadRequest('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForDealApiException429(): void\n {\n $e = new DealApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForContactApiException429(): void\n {\n $e = new ContactApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForCompanyApiException429(): void\n {\n $e = new CompanyApiException('Too Many Requests', 429);\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsTrueForGuzzleRequestException429(): void\n {\n $request = new \\GuzzleHttp\\Psr7\\Request('POST', 'https://api.hubapi.com/test');\n $response = new \\GuzzleHttp\\Psr7\\Response(429);\n $e = new \\GuzzleHttp\\Exception\\RequestException('Too Many Requests', $request, $response);\n\n $this->assertTrue($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsFalseForBadRequestNon429(): void\n {\n $e = new BadRequest('Bad Request', 400);\n $this->assertFalse($this->callIsHubspotRateLimit($e));\n }\n\n public function testIsHubspotRateLimitReturnsFalseForGenericException(): void\n {\n $e = new \\RuntimeException('Something went wrong');\n $this->assertFalse($this->callIsHubspotRateLimit($e));\n }\n\n // -------------------------------------------------------------------------\n // parseRetryAfter() tests (private method — tested via reflection)\n // -------------------------------------------------------------------------\n\n private function callParseRetryAfter(\\Throwable $e): int\n {\n $method = new \\ReflectionMethod($this->client, 'parseRetryAfter');\n $method->setAccessible(true);\n\n return $method->invoke($this->client, $e);\n }\n\n public function testParseRetryAfterReadsRetryAfterHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => ['30']]);\n $this->assertSame(30, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReadsLowercaseRetryAfterHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['retry-after' => ['15']]);\n $this->assertSame(15, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterHandlesScalarHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => '60']);\n $this->assertSame(60, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsDailyLimitDelay(): void\n {\n $e = new BadRequest('Daily rate limit exceeded');\n $this->assertSame(600, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsTenSecondlyDelay(): void\n {\n $e = new BadRequest('Ten secondly rate limit exceeded');\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsSecondlyDelay(): void\n {\n $e = new BadRequest('Secondly rate limit exceeded');\n $this->assertSame(1, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterReturnsDefaultWhenNoHint(): void\n {\n $e = new BadRequest('Rate limit exceeded');\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n\n public function testParseRetryAfterIgnoresNonNumericHeader(): void\n {\n $e = new DealApiException('Too Many Requests', 429, ['Retry-After' => ['not-a-number']]);\n // Falls through to message parsing; \"Too Many Requests\" has no known keyword → default 10\n $this->assertSame(10, $this->callParseRetryAfter($e));\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.96276593,"top":0.07581804,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.9740692,"top":0.074221864,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.98138297,"top":0.074221864,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
4682894321548166980
|
4446428687123393012
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
ClientTest
Run 'ClientTest'
Debug 'ClientTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
144
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Hubspot;
use GuzzleHttp\Psr7\Response;
use HubSpot\Client\Crm\Associations\Api\BatchApi;
use HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId;
use HubSpot\Client\Crm\Associations\Model\BatchResponsePublicAssociationMulti;
use HubSpot\Client\Crm\Associations\Model\PublicObjectId;
use HubSpot\Client\Crm\Companies\ApiException as CompanyApiException;
use HubSpot\Client\Crm\Contacts\ApiException as ContactApiException;
use HubSpot\Client\Crm\Deals\Api\BasicApi as DealsBasicApi;
use HubSpot\Client\Crm\Deals\ApiException as DealApiException;
use HubSpot\Client\Crm\Deals\Model\SimplePublicObjectWithAssociations as DealWithAssociations;
use HubSpot\Client\Crm\Pipelines\Api\PipelinesApi;
use HubSpot\Client\Crm\Pipelines\Model\CollectionResponsePipeline;
use HubSpot\Client\Crm\Pipelines\Model\Error;
use HubSpot\Client\Crm\Pipelines\Model\Pipeline;
use HubSpot\Client\Crm\Pipelines\Model\PipelineStage;
use HubSpot\Client\Crm\Properties\Api\CoreApi;
use HubSpot\Client\Crm\Properties\Model\Property;
use HubSpot\Discovery as HubSpotDiscovery;
use HubSpot\Discovery\Crm\Deals\Discovery as DealsDiscovery;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\SocialAccount;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\HubspotTokenManager;
use Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService;
use Jiminny\Services\SocialAccountService;
use League\OAuth2\Client\Token\AccessToken;
use Mockery;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use SevenShores\Hubspot\Endpoints\Engagements;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Factory;
use SevenShores\Hubspot\Http\Client as HubspotClient;
use SevenShores\Hubspot\Http\Response as HubspotResponse;
/**
* @runTestsInSeparateProcesses
*
* @preserveGlobalState disabled
*/
class ClientTest extends TestCase
{
private const string RESPONSE_TYPE_STAGE_FIELD = 'stage';
private const string RESPONSE_TYPE_PIPELINE_FIELD = 'pipeline';
private const string RESPONSE_TYPE_REGULAR_FIELD = 'regular';
/**
* @var Client&MockObject
*/
private Client $client;
private Configuration $config;
/**
* @var SocialAccountService&MockObject
*/
private SocialAccountService $socialAccountServiceMock;
/**
* @var HubspotPaginationService&MockObject
*/
private HubspotPaginationService $paginationServiceMock;
/**
* @var HubspotTokenManager&MockObject
*/
private HubspotTokenManager $tokenManagerMock;
/**
* @var CoreApi&MockObject
*/
private CoreApi $coreApiMock;
/**
* @var PipelinesApi&MockObject
*/
private PipelinesApi $pipelinesApiMock;
/**
* @var BatchApi&MockObject
*/
private BatchApi $associationsBatchApiMock;
/**
* @var DealsBasicApi&MockObject
*/
private DealsBasicApi $dealsBasicApiMock;
/**
* @var Engagements&MockObject
*/
private $engagementsMock;
/**
* @var HubspotClient&MockObject
*/
private HubspotClient $hubspotClientMock;
protected function setUp(): void
{
// Create mocks for dependencies
$this->socialAccountServiceMock = $this->createMock(SocialAccountService::class);
$this->paginationServiceMock = $this->createMock(HubspotPaginationService::class);
$this->tokenManagerMock = $this->createMock(HubspotTokenManager::class);
// Create a real Client instance with mocked dependencies
// Create a partial mock only for the methods we need to mock
$client = $this->createPartialMock(Client::class, ['getInstance', 'getNewInstance', 'makeRequest']);
$client->setLogger(new NullLogger());
// Inject the real dependencies using reflection
$reflection = new \ReflectionClass(Client::class);
$accountServiceProperty = $reflection->getProperty('accountService');
$accountServiceProperty->setAccessible(true);
$accountServiceProperty->setValue($client, $this->socialAccountServiceMock);
$paginationServiceProperty = $reflection->getProperty('paginationService');
$paginationServiceProperty->setAccessible(true);
$paginationServiceProperty->setValue($client, $this->paginationServiceMock);
$tokenManagerProperty = $reflection->getProperty('tokenManager');
$tokenManagerProperty->setAccessible(true);
$tokenManagerProperty->setValue($client, $this->tokenManagerMock);
$factoryMock = $this->createMock(Factory::class);
$hubspotClientMock = $this->createMock(HubspotClient::class);
$engagementsMock = $this->createMock(\SevenShores\Hubspot\Endpoints\Engagements::class);
$factoryMock->method('getClient')->willReturn($hubspotClientMock);
$factoryMock->method('__call')->with('engagements')->willReturn($engagementsMock);
$client->method('getInstance')->willReturn($factoryMock);
$discoveryMock = $this->createMock(HubSpotDiscovery\Discovery::class);
$crmMock = $this->createMock(HubSpotDiscovery\Crm\Discovery::class);
$associationsMock = $this->createMock(HubSpotDiscovery\Crm\Associations\Discovery::class);
$propertiesMock = $this->createMock(HubSpotDiscovery\Crm\Properties\Discovery::class);
$pipelinesMock = $this->createMock(HubSpotDiscovery\Crm\Pipelines\Discovery::class);
$coreApiMock = $this->createMock(CoreApi::class);
$pipelinesApiMock = $this->createMock(PipelinesApi::class);
$associationsBatchApiMock = $this->createMock(BatchApi::class);
$dealsBasicApiMock = $this->createMock(DealsBasicApi::class);
$propertiesMock->method('__call')->with('coreApi')->willReturn($coreApiMock);
$pipelinesMock->method('__call')->with('pipelinesApi')->willReturn($pipelinesApiMock);
$associationsMock->method('__call')->with('batchApi')->willReturn($associationsBatchApiMock);
$dealsDiscoveryMock = $this->createMock(DealsDiscovery::class);
$dealsDiscoveryMock->method('__call')->with('basicApi')->willReturn($dealsBasicApiMock);
$returnMap = ['properties' => $propertiesMock, 'pipelines' => $pipelinesMock, 'associations' => $associationsMock, 'deals' => $dealsDiscoveryMock];
$crmMock->method('__call')
->willReturnCallback(static fn (string $name) => $returnMap[$name])
;
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client->method('getNewInstance')->willReturn($discoveryMock);
$this->client = $client;
$this->config = $this->createMock(Configuration::class);
$this->client->setConfiguration($this->config);
$this->coreApiMock = $coreApiMock;
$this->pipelinesApiMock = $pipelinesApiMock;
$this->associationsBatchApiMock = $associationsBatchApiMock;
$this->dealsBasicApiMock = $dealsBasicApiMock;
$this->engagementsMock = $engagementsMock;
$this->hubspotClientMock = $hubspotClientMock;
}
public function testGetMinimumApiVersion(): void
{
$this->assertIsString($this->client->getMinimumApiVersion());
}
public function testGetInstance(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManager = $this->createMock(HubspotTokenManager::class);
$client = new Client($socialAccountService, $paginationService, $tokenManager);
$client->setBaseUrl('[URL_WITH_CREDENTIALS] The class CollectionResponsePipeline will be deprecated in the next Hubspot version
*/
$this->pipelinesApiMock
->method('getAll')
->with('deals')
->willReturn(new CollectionResponsePipeline([
'results' => [$this->generatePipeline()],
]))
;
$this->assertEquals(
[
['id' => 'foo', 'label' => 'bar'],
['id' => 'baz', 'label' => 'qux'],
],
$this->client->fetchOpportunityPipelineStages()
);
}
public function testFetchOpportunityPipelineStagesErrorResponse(): void
{
$this->pipelinesApiMock
->method('getAll')
->with('deals')
->willReturn(new Error(['message' => 'test error']))
;
$this->assertEmpty($this->client->fetchOpportunityPipelineStages());
}
#[\PHPUnit\Framework\Attributes\DataProvider('meetingOutcomeFieldProvider')]
public function testFetchMeetingOutcomeFieldOptions(string $fieldId, string $expectedEndpoint): void
{
$field = new Field(['crm_provider_id' => $fieldId]);
$this->hubspotClientMock
->expects($this->once())
->method('request')
->with('GET', $expectedEndpoint)
->willReturn($this->generateHubSpotResponse(
[
'options' => [
['value' => 'option_1', 'label' => 'Option 1', 'displayOrder' => 0],
['value' => 'option_2', 'label' => 'Option 2', 'displayOrder' => 1],
['value' => 'option_3', 'label' => 'Option 3', 'displayOrder' => 2],
],
]
))
;
$this->assertEquals(
[
['id' => 'option_1', 'value' => 'option_1', 'label' => 'Option 1', 'display_order' => 0],
['id' => 'option_2', 'value' => 'option_2', 'label' => 'Option 2', 'display_order' => 1],
['id' => 'option_3', 'value' => 'option_3', 'label' => 'Option 3', 'display_order' => 2],
],
$this->client->fetchMeetingOutcomeFieldOptions($field)
);
}
public static function meetingOutcomeFieldProvider(): array
{
return [
'meeting outcome field' => [
'meetingOutcome',
'[URL_WITH_CREDENTIALS] The class CollectionResponsePipeline will be deprecated in the next Hubspot version
*/
$pipelineStagesResponse = new CollectionResponsePipeline([
'results' => [$this->generatePipeline()],
]);
$this->pipelinesApiMock
->method('getAll')
->willReturn($pipelineStagesResponse);
}
if ($type === self::RESPONSE_TYPE_PIPELINE_FIELD) {
$field->method('isStageField')->willReturn(false);
$field->method('isPipelineField')->willReturn(true);
$pipelineResponse = $this->generateHubSpotResponse([
'results' => [
['id' => '123', 'label' => 'Sales'],
['id' => 'default', 'label' => 'CS'],
],
]);
$this->client
->method('makeRequest')
->with('/crm/v3/pipelines/deals')
->willReturn($pipelineResponse);
}
$this->assertEquals(
$responses[$type],
$this->client->fetchOpportunityFieldOptions($field)
);
}
public static function opportunityFieldOptionsProvider(): array
{
return [
'stage field' => [
'field' => new Field(['crm_provider_id' => 'dealstage']),
'type' => self::RESPONSE_TYPE_STAGE_FIELD,
],
'pipeline field' => [
'field' => new Field(['crm_provider_id' => 'pipeline']),
'type' => self::RESPONSE_TYPE_PIPELINE_FIELD,
],
'regular field' => [
'field' => new Field(['crm_provider_id' => 'some_property']),
'type' => self::RESPONSE_TYPE_REGULAR_FIELD,
],
];
}
private function generateHubSpotResponse(array $data): HubspotResponse
{
return new HubspotResponse(new Response(200, [], json_encode($data)));
}
private function generateProperty(): Property
{
return new Property([
'name' => 'some_property',
'options' => [
[
'label' => 'label_1',
'value' => 'value_1',
],
[
'label' => 'label_2',
'value' => 'value_2',
],
],
]);
}
private function generatePipeline(): Pipeline
{
return new Pipeline(['stages' => [
new PipelineStage(['id' => 'foo', 'label' => 'bar']),
new PipelineStage(['id' => 'baz', 'label' => 'qux']),
]]);
}
public function testFetchOpportunityPipelines(): void
{
$this->client
->method('makeRequest')
->with('/crm/v3/pipelines/deals')
->willReturn($this->generateHubSpotResponse([
'results' => [
['id' => 'id_1', 'label' => 'Option 1', 'displayOrder' => 0],
['id' => 'id_2', 'label' => 'Option 2', 'displayOrder' => 1],
['id' => 'id_3', 'label' => 'Option 3', 'displayOrder' => 2],
],
]));
$this->assertEquals(
[
['id' => 'id_1', 'label' => 'Option 1'],
['id' => 'id_2', 'label' => 'Option 2'],
['id' => 'id_3', 'label' => 'Option 3'],
],
$this->client->fetchOpportunityPipelines()
);
}
public function testGetPaginatedData(): void
{
$expectedResults = [
['id' => 'id_1', 'properties' => []],
['id' => 'id_2', 'properties' => []],
['id' => 'id_3', 'properties' => []],
];
// Mock the pagination service to return a generator and modify reference parameters
$this->paginationServiceMock
->expects($this->once())
->method('getPaginatedDataGenerator')
->with(
$this->client,
['payload_key' => 'payload_value'],
'foobar',
0,
$this->anything(),
$this->anything()
)
->willReturnCallback(function ($client, $payload, $type, $offset, &$total, &$lastRecordId) use ($expectedResults) {
$total = 3;
$lastRecordId = 'id_3';
foreach ($expectedResults as $result) {
yield $result;
}
});
$this->assertEquals(
[
'results' => $expectedResults,
'total' => 3,
'last_record' => 'id_3',
],
$this->client->getPaginatedData(['payload_key' => 'payload_value'], 'foobar')
);
}
public function testGetAssociationsData(): void
{
$ids = ['1', '2', '3'];
$fromObject = 'deals';
$toObject = 'contacts';
$responseResults = [];
foreach ($ids as $id) {
$from = new PublicObjectId();
$from->setId($id);
$to1 = new PublicObjectId();
$to1->setId('contact_' . $id . '_1');
$to2 = new PublicObjectId();
$to2->setId('contact_' . $id . '_2');
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to1, $to2]);
$responseResults[] = $result;
}
$batchResponse = new BatchResponsePublicAssociationMulti();
$batchResponse->setResults($responseResults);
$this->associationsBatchApiMock->expects($this->once())
->method('read')
->with(
$this->equalTo($fromObject),
$this->equalTo($toObject),
$this->callback(function (BatchInputPublicObjectId $batchInput) use ($ids) {
$inputIds = array_map(
fn ($input) => $input->getId(),
$batchInput->getInputs()
);
return $inputIds === $ids;
})
)
->willReturn($batchResponse);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$expectedResult = [
'1' => ['contact_1_1', 'contact_1_2'],
'2' => ['contact_2_1', 'contact_2_2'],
'3' => ['contact_3_1', 'contact_3_2'],
];
$this->assertEquals($expectedResult, $result);
}
public function testGetAssociationsDataHandlesException(): void
{
$ids = ['1', '2', '3'];
$fromObject = 'deals';
$toObject = 'contacts';
$exception = new \Exception('API Error');
$this->associationsBatchApiMock->expects($this->once())
->method('read')
->with(
$this->equalTo($fromObject),
$this->equalTo($toObject),
$this->callback(function ($batchInput) use ($ids) {
return $batchInput instanceof \HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId
&& count($batchInput->getInputs()) === count($ids);
})
)
->willThrowException($exception);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with(
'[Hubspot] Failed to fetch associations',
[
'from_object' => $fromObject,
'to_object' => $toObject,
'reason' => 'API Error',
]
);
$this->client->setLogger($loggerMock);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$this->assertEmpty($result);
$this->assertIsArray($result);
}
public function testGetAssociationsDataWithLargeDataSet(): void
{
$ids = array_map(fn ($i) => (string) $i, range(1, 2500)); // More than 1000 items
$fromObject = 'deals';
$toObject = 'contacts';
$firstBatchResponse = new BatchResponsePublicAssociationMulti();
$firstBatchResults = array_map(function ($id) {
$from = new PublicObjectId();
$from->setId($id);
$to = new PublicObjectId();
$to->setId('contact_' . $id);
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to]);
return $result;
}, array_slice($ids, 0, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));
$firstBatchResponse->setResults($firstBatchResults);
$secondBatchResponse = new BatchResponsePublicAssociationMulti();
$secondBatchResults = array_map(function ($id) {
$from = new PublicObjectId();
$from->setId($id);
$to = new PublicObjectId();
$to->setId('contact_' . $id);
$result = new \HubSpot\Client\Crm\Associations\Model\PublicAssociationMulti();
$result->setFrom($from);
$result->setTo([$to]);
return $result;
}, array_slice($ids, Client::ASSOCIATIONS_BATCH_SIZE_LIMIT));
$secondBatchResponse->setResults($secondBatchResults);
$this->associationsBatchApiMock->expects($this->exactly(3))
->method('read')
->willReturnOnConsecutiveCalls($firstBatchResponse, $secondBatchResponse);
$result = $this->client->getAssociationsData($ids, $fromObject, $toObject);
$this->assertCount(2500, $result);
$this->assertArrayHasKey('1', $result);
$this->assertArrayHasKey('2500', $result);
$this->assertEquals(['contact_1'], $result['1']);
$this->assertEquals(['contact_2500'], $result['2500']);
}
public function testGetContactByEmailSuccess(): void
{
$email = '[EMAIL]';
$fields = ['firstname', 'lastname', 'email'];
$contactMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$contactMock->method('getId')->willReturn('12345');
$contactMock->method('getProperties')->willReturn([
'firstname' => 'John',
'lastname' => 'Doe',
'email' => '[EMAIL]',
]);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, 'firstname,lastname,email', null, false, 'email')
->willReturn($contactMock);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new NullLogger());
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([
'id' => '12345',
'properties' => [
'firstname' => 'John',
'lastname' => 'Doe',
'email' => '[EMAIL]',
],
], $result);
}
public function testGetContactByEmailWithEmptyFields(): void
{
$email = '[EMAIL]';
$fields = [];
$contactMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$contactMock->method('getId')->willReturn('12345');
$contactMock->method('getProperties')->willReturn(['email' => '[EMAIL]']);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, '', null, false, 'email')
->willReturn($contactMock);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new NullLogger());
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([
'id' => '12345',
'properties' => ['email' => '[EMAIL]'],
], $result);
}
public function testGetContactByEmailApiException(): void
{
$email = '[EMAIL]';
$fields = ['firstname', 'lastname'];
$exception = new \HubSpot\Client\Crm\Contacts\ApiException('Contact not found', 404);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($email, 'firstname,lastname', null, false, 'email')
->willThrowException($exception);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('info')
->with(
'[Hubspot] Failed to fetch contact',
[
'email' => $email,
'reason' => 'Contact not found',
]
);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger($loggerMock);
$result = $client->getContactByEmail($email, $fields);
$this->assertEquals([], $result);
}
public function testGetOpportunityById(): void
{
$opportunityId = '12345';
$expectedProperties = [
'dealname' => 'Test Opportunity',
'amount' => '1000.00',
'closedate' => '2024-12-31T23:59:59.999Z',
'dealstage' => 'presentationscheduled',
'pipeline' => 'default',
];
$mockHubspotOpportunity = $this->createMock(DealWithAssociations::class);
$mockHubspotOpportunity->method('getProperties')->willReturn((object) $expectedProperties);
$mockHubspotOpportunity->method('getId')->willReturn($opportunityId);
$now = new \DateTimeImmutable();
$mockHubspotOpportunity->method('getCreatedAt')->willReturn($now);
$mockHubspotOpportunity->method('getUpdatedAt')->willReturn($now);
$mockHubspotOpportunity->method('getArchived')->willReturn(false);
$this->dealsBasicApiMock
->expects($this->once())
->method('getById')
->willReturn($mockHubspotOpportunity);
// Assuming Client::getOpportunityById processes the SimplePublicObject and returns an associative array.
// The structure might be like: ['id' => ..., 'properties' => [...], 'createdAt' => ..., ...]
// Adjust assertions below based on the actual return structure of your method.
$result = $this->client->getOpportunityById($opportunityId, ['test', 'test']);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertEquals($opportunityId, $result['id']);
}
public function testGetContactById(): void
{
$crmId = 'contact-123';
$fields = ['firstname', 'lastname'];
$expectedProperties = ['firstname' => 'John', 'lastname' => 'Doe'];
$mockContact = $this->createMock(\HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations::class);
$mockContact->method('getId')->willReturn($crmId);
$mockContact->method('getProperties')->willReturn((object) $expectedProperties);
$contactsApiMock = $this->createMock(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class);
$contactsApiMock->expects($this->once())
->method('getById')
->with($crmId, 'firstname,lastname')
->willReturn($mockContact);
$contactsDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Contacts\Discovery::class);
$contactsDiscoveryMock->method('__call')->with('basicApi')->willReturn($contactsApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($contactsDiscoveryMock) {
if ($name === 'contacts') {
return $contactsDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new \Psr\Log\NullLogger());
$result = $client->getContactById($crmId, $fields);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('properties', $result);
$this->assertEquals($crmId, $result['id']);
$this->assertEquals((object) $expectedProperties, $result['properties']);
}
public function testGetAccountById(): void
{
$crmId = 'account-123';
$fields = ['name', 'industry'];
$expectedProperties = ['name' => 'Acme Corp', 'industry' => 'Technology'];
$mockCompany = $this->createMock(\HubSpot\Client\Crm\Companies\Model\SimplePublicObjectWithAssociations::class);
$mockCompany->method('getId')->willReturn($crmId);
$mockCompany->method('getProperties')->willReturn((object) $expectedProperties);
$companiesApiMock = $this->createMock(\HubSpot\Client\Crm\Companies\Api\BasicApi::class);
$companiesApiMock->expects($this->once())
->method('getById')
->with($crmId, 'name,industry')
->willReturn($mockCompany);
$companiesDiscoveryMock = $this->createMock(\HubSpot\Discovery\Crm\Companies\Discovery::class);
$companiesDiscoveryMock->method('__call')->with('basicApi')->willReturn($companiesApiMock);
$crmMock = $this->createMock(\HubSpot\Discovery\Crm\Discovery::class);
$crmMock->method('__call')->willReturnCallback(function ($name) use ($companiesDiscoveryMock) {
if ($name === 'companies') {
return $companiesDiscoveryMock;
}
return $this->createMock(\HubSpot\Discovery\Crm\Properties\Discovery::class);
});
$discoveryMock = $this->createMock(\HubSpot\Discovery\Discovery::class);
$discoveryMock->method('__call')->with('crm')->willReturn($crmMock);
$client = $this->createPartialMock(Client::class, ['getNewInstance']);
$client->method('getNewInstance')->willReturn($discoveryMock);
$client->setLogger(new \Psr\Log\NullLogger());
$result = $client->getAccountById($crmId, $fields);
$this->assertIsArray($result);
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('properties', $result);
$this->assertEquals($crmId, $result['id']);
$this->assertEquals((object) $expectedProperties, $result['properties']);
}
public function testEnsureValidTokenWithNoTokenUpdate(): void
{
$socialAccountMock = $this->createMock(SocialAccount::class);
// Set up OAuth account
$reflection = new \ReflectionClass($this->client);
$oauthAccountProperty = $reflection->getProperty('oauthAccount');
$oauthAccountProperty->setAccessible(true);
$oauthAccountProperty->setValue($this->client, $socialAccountMock);
$originalToken = 'original_token';
$accessTokenProperty = $reflection->getProperty('accessToken');
$accessTokenProperty->setAccessible(true);
$accessTokenProperty->setValue($this->client, $originalToken);
// Mock token manager to return null (no refresh needed)
$this->tokenManagerMock
->expects($this->once())
->method('ensureValidToken')
->with($socialAccountMock)
->willReturn(null);
// Call ensureValidToken
$this->client->ensureValidToken();
// Verify access token was not changed
$this->assertEquals($originalToken, $accessTokenProperty->getValue($this->client));
}
public function testGetPaginatedDataGeneratorDelegatesToPaginationService(): void
{
$payload = ['filters' => []];
$type = 'contacts';
$offset = 0;
$total = 0;
$lastRecordId = null;
$expectedResults = [
['id' => 'id_1', 'properties' => []],
['id' => 'id_2', 'properties' => []],
];
// Mock the pagination service to return a generator
$this->paginationServiceMock
->expects($this->once())
->method('getPaginatedDataGenerator')
->with(
$this->client,
$payload,
$type,
$offset,
$this->anything(),
$this->anything()
)
->willReturnCallback(function () use ($expectedResults) {
foreach ($expectedResults as $result) {
yield $result;
}
});
// Execute the pagination
$results = [];
foreach ($this->client->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastRecordId) as $result) {
$results[] = $result;
}
$this->assertCount(2, $results);
$this->assertEquals('id_1', $results[0]['id']);
$this->assertEquals('id_2', $results[1]['id']);
}
public function testEnsureValidTokenDelegatesToTokenManager(): void
{
$socialAccountMock = $this->createMock(SocialAccount::class);
// Set up OAuth account
$reflection = new \ReflectionClass($this->client);
$oauthAccountProperty = $reflection->getProperty('oauthAccount');
$oauthAccountProperty->setAccessible(true);
$oauthAccountProperty->setValue($this->client, $socialAccountMock);
// Mock token manager to return new token
$this->tokenManagerMock
->expects($this->once())
->method('ensureValidToken')
->with($socialAccountMock)
->willReturn('new_access_token');
// Call ensureValidToken
$this->client->ensureValidToken();
// Verify access token was updated
$accessTokenProperty = $reflection->getProperty('accessToken');
$accessTokenProperty->setAccessible(true);
$this->assertEquals('new_access_token', $accessTokenProperty->getValue($this->client));
}
public function testGetOwnersArchivedWithValidResponse(): void
{
$responseData = [
'results' => [
[
'id' => '123',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'John',
'lastName' => 'Doe',
'userId' => 456,
'userIdIncludingInactive' => 789,
'createdAt' => '2023-01-01T12:00:00Z',
'updatedAt' => '2023-01-02T12:00:00Z',
'archived' => true,
],
],
];
// Create a mock response object
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn($responseData);
// Set up the client to return our test data
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=true'
)
->willReturn($response);
// Call the method
$result = $this->client->getOwnersArchived(true);
// Assert the results
$this->assertCount(1, $result);
$this->assertEquals('123', $result[0]->getId());
$this->assertEquals('[EMAIL]', $result[0]->getEmail());
$this->assertEquals('John Doe', $result[0]->getFullName());
$this->assertTrue($result[0]->isArchived());
}
public function testGetOwnersArchivedWithEmptyResponse(): void
{
// Create a mock response object with empty results
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn(['results' => []]);
// Set up the client to return empty results
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=false'
)
->willReturn($response);
// Call the method
$result = $this->client->getOwnersArchived(false);
// Assert the results
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithInvalidResponse(): void
{
// Create a mock response that will throw an exception when toArray is called
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willThrowException(new \InvalidArgumentException('Invalid JSON'));
// Set up the client to return the problematic response
$this->client->method('makeRequest')
->willReturn($response);
// Mock the logger to expect an error message
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with($this->stringContains('Failed to fetch owners'));
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
// Call the method and expect an empty array on error
$result = $this->client->getOwnersArchived(true);
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithHttpError(): void
{
// Set up the client to throw an exception
$this->client->method('makeRequest')
->willThrowException(new \Exception('HTTP Error'));
// Mock the logger to expect an error message
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with($this->stringContains('Failed to fetch owners'));
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
// Call the method and expect an empty array on error
$result = $this->client->getOwnersArchived(false);
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testGetOwnersArchivedWithOwnerCreationException(): void
{
$responseData = [
'results' => [
[
'id' => '123',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'John',
'lastName' => 'Doe',
'userId' => 456,
'userIdIncludingInactive' => 789,
'createdAt' => '2023-01-01T12:00:00Z',
'updatedAt' => '2023-01-02T12:00:00Z',
'archived' => false,
],
[
'id' => '456',
'email' => '[EMAIL]',
'type' => 'PERSON',
'createdAt' => 'invalid-date-format',
],
[
'id' => '789',
'email' => '[EMAIL]',
'type' => 'PERSON',
'firstName' => 'Jane',
'lastName' => 'Smith',
'userId' => 999,
'userIdIncludingInactive' => 888,
'createdAt' => '2023-01-03T12:00:00Z',
'updatedAt' => '2023-01-04T12:00:00Z',
'archived' => false,
],
],
];
$response = $this->createMock(\SevenShores\Hubspot\Http\Response::class);
$response->method('toArray')
->willReturn($responseData);
$this->client->method('makeRequest')
->with(
'/crm/v3/owners',
'GET',
[],
'archived=false'
)
->willReturn($response);
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error')
->with(
'[HubSpot] Failed to process owner data',
$this->callback(function ($context) {
return isset($context['result']) &&
isset($context['error']) &&
$context['result']['id'] === '456' &&
$context['result']['email'] === '[EMAIL]' &&
str_contains($context['error'], 'invalid-date-format');
})
);
$reflection = new \ReflectionClass($this->client);
$loggerProperty = $reflection->getProperty('log');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($this->client, $loggerMock);
$result = $this->client->getOwnersArchived(false);
$this->assertIsArray($result);
$this->assertCount(2, $result);
$this->assertEquals('123', $result[0]->getId());
$this->assertEquals('[EMAIL]', $result[0]->getEmail());
$this->assertEquals('789', $result[1]->getId());
$this->assertEquals('[EMAIL]', $result[1]->getEmail());
}
public function testMakeRequestWithGetMethod(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManager = $this->createMock(HubspotTokenManager::class);
$psrResponse = new Response(200, [], json_encode(['status' => 'success']));
$expectedResponse = new HubspotResponse($psrResponse);
$hubspotClientMock = $this->createMock(HubspotClient::class);
$hubspotClientMock->expects($this->once())
->method('request')
->with(
'GET',
'https://api.hubapi.com/crm/v3/objects/contacts',
[],
null,
true
)
->willReturn($expectedResponse);
$factoryMock = $this->createMock(Factory::class);
$factoryMock->method('getClient')->willReturn($hubspotClientMock);
$client = $this->getMockBuilder(Client::class)
->setConstructorArgs([$socialAccountService, $paginationService, $tokenManager])
->onlyMethods(['getInstance'])
->getMock();
$client->method('getInstance')->willReturn($factoryMock);
$client->setAccessToken(new AccessToken(['access_token' => 'test_token']));
$result = $client->makeRequest('/crm/v3/objects/contacts', 'GET');
$this->assertSame($expectedResponse, $result);
}
public function testMakeRequestWithGetMethodAndQueryString(): void
{
$socialAccountService = $this->createMock(SocialAccountService::class);
$paginationService = $this->createMock(HubspotPaginationService::class);
$tokenManag...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20574
|
893
|
12
|
2026-05-11T15:53:19.070670+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778514799070_m2.jpg...
|
PhpStorm
|
faVsco.js – HandleHubspotRateLimitTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Rerun 'PHPUnit: HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.82413566,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.8394282,"top":0.019952115,"width":0.076130316,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Rerun 'PHPUnit: HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-7185761907823364110
|
2028042940272544211
|
visual_change
|
hybrid
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Rerun 'PHPUnit: HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
PhostormVIewINavigareCodeLaravelKeractorWindowmelpFV faVsco.js?9 JY-20725-handle-HS-search-rate-limitProiectG RematchActivityOnCrmObjectDetach.php© HubspotPaginationService.php>@ Mailbo›v D Middleware©) HandleHubspotRat>@ Streaming>D TeamC) UserAutomatedReportscontroller.ong© Hubspot/Service.phpOhubspot/service.pnpT SvncCrmEntities Trait.onp© MatchActivityCrmData.php© RateLimitException.phpC) HandlerubspotkateLimit.phpC) Client.php_ lelepnony> DUserc) ImportRecallAlRecordik?phpdeclarelstrict tyoessi:c) SasVisibilitycontrolTe:v W Listeners> @ Activitiesnamespace Tests Unit Jobs MiddLeware:>D Audio• AutomatedReportsAutoscore› use ...17 Crm18 VM DealRisks#[CoversClass(HandleHubspotRateLimit::class)]class HandleHubspotRateLimitTest extends TestCase17 ElasticSearchlGrouosprivate HandleHubspotRateLimit $middleware;Import• M MailbosCancola yLocal ChangesLog X• Chanaes & files+ → a Side-by-side viewer +Do not ignore Highlight words= env.local aor.8 02d5214b tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php© Client.php app/Services/Crm/Hubspotc ClientTect nhn tectc/Unit/Services/Crm/Hubsnot© HandleHubspotRateLimitTest.php tests/Unit/Jobs/Middleware© JiminnyDebugCommand.php app/Console/Commands# Coversulass HandLeHubspotkateLim1t::class)class HandleHubspotRateLimitTest extends TestCasepip loeeine.one contie© MatchActivityCrmData.php app/Jobs/Crm© RateLimitException.php app/ExceptionsUnversioned Files 9 filesE.env.nikilocal appE.env.other app© CanAccessAiReportsTest.php tests/Unit/Policies© CreateMockAskJiminnyReportResultCommand.php app/Console/Commands/Rprivate const int MAX RETRY DELAY = 600:orivate const int MIN RETRY DELAY = 1:private const int JITTER SECONDS = 5;orivate HandleHubspotRateLimit Smiddleware:protected function setUp(): voidi tavicon.ico public=ids.txt apdHubsootRateLimitTest MAAAAAAAATa raw sal querv.sal app© SimulateWebhooksCommand.php app/Console/Commands/Crm/Hubspotpublic static function delayClampingProvider: arrayM.WEBTOOK FILTERING IMPLEMENTATION.mo a00return l'short retry uses retry_after as floor' => ['retryAfter' => 1,'expectedMin' => self::MIN_RETRY_DELAY,'expectedMax' => self::MIN_RETRY_DELAY + self::JITT'medium retry passes through' => ['retryAfter' => 30,'expectedMin' => 30.'expectedMax' => 30 + self::JITTER SECONDS(C) TrackAutomatedReportGenerateaevent.onpC) CachedCrmServiceDecorator.ong© CheckAndRetryRemoteMatch.php© ClientTest.phpC) Kernel.php201+22100% Lz• Mon 11 May 18:53:19HandleHubsnotPatel imitTect= laravel.logA SF [jiminny@localhost]4 HS_local [jiminny@localhost]console [pRODlA console [EUiconsole [STAGINGI"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEs0kXi =s07-May-26 14:51:15 GMT: domain=.hubapi.com:Http0nly: Secure: SameSite=None"]"кeрoгс-1о"."?"endpoints":"url\":"https:|VlWa.nel.cloudflare.com\/report\Vv4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEqZ1zoYdxI%2BIxVpHmsKn30%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTz06FM4%2("group"; \"cf-nell","max age":604800*"]"NEL":["fRateLimitExceptionTest >HandleHubspotRateLimittest Xv %= Instantiating tests.[docker-compose://[/Users/Lukas/jiminny/infrastructure/dev/docker/docker-compose.yml]:lamp/]:php ./vendor/bin/phpunit --configuration phpunit.xml --filter Tests|\Unit|\Jobs\\MiddTesting started at 18:53 ...WARN (0000]/Users/lukas/jiminny/infrastructure/dev/docker/docker-compose.yml: the attribute 'version' is obsolete, it will be ignored, please remove it to avoid potential confus6 differencesAAAAAA'retrvAfter' => 86400'expectedMin' => 600Tacts naccod: 80 12 minutes addW Windcurf Teame 11 UTE-R A A cnanae...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20590
|
895
|
5
|
2026-05-11T15:57:02.750983+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515022750_m2.jpg...
|
PhpStorm
|
faVsco.js – HandleHubspotRateLimitTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
declare(strict_types=1);
namespace Tests\Unit\Jobs\Middleware;
use Exception;
use Illuminate\Contracts\Queue\Job;
use Illuminate\Support\Facades\Log;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
#[CoversClass(HandleHubspotRateLimit::class)]
class HandleHubspotRateLimitTest extends TestCase
{
private HandleHubspotRateLimit $middleware;
protected function setUp(): void
{
parent::setUp();
$this->middleware = new HandleHubspotRateLimit();
}
public function testPassesThroughWhenNoExceptionThrown(): void
{
$job = $this->createMock(Job::class);
$job->expects($this->never())->method('release');
$called = false;
$next = function (object $passed) use ($job, &$called): void {
$this->assertSame($job, $passed);
$called = true;
};
$this->middleware->handle($job, $next);
$this->assertTrue($called);
}
public function testPropagatesNonRateLimitExceptions(): void
{
$job = $this->createMock(Job::class);
$job->expects($this->never())->method('release');
$next = static function (): void {
throw new Exception('Database is down');
};
$this->expectException(Exception::class);
$this->expectExceptionMessage('Database is down');
$this->middleware->handle($job, $next);
}
/**
* @return array<string, array{retryAfter: int, expectedMin: int, expectedMax: int}>
*/
public static function delayClampingProvider(): array
{
return [
'short retry passes through' => [
'retryAfter' => 1,
'expectedMin' => 1,
'expectedMax' => 6, // 1 + 5 jitter
],
'medium retry passes through' => [
'retryAfter' => 30,
'expectedMin' => 30,
'expectedMax' => 35, // 30 + 5 jitter
],
'large retry clamped to 600s max' => [
'retryAfter' => 86400,
'expectedMin' => 600,
'expectedMax' => 605, // 600 + 5 jitter
],
];
}
#[DataProvider('delayClampingProvider')]
public function testReleasesJobWithClampedDelay(int $retryAfter, int $expectedMin, int $expectedMax): void
{
Log::shouldReceive('info')->zeroOrMoreTimes();
/** @var Job&MockObject $job */
$job = $this->createMock(Job::class);
$job->method('attempts')->willReturn(1);
$job->expects($this->once())
->method('release')
->with($this->callback(static function (int $delay) use ($expectedMin, $expectedMax): bool {
return $delay >= $expectedMin && $delay <= $expectedMax;
}));
$next = static function () use ($retryAfter): void {
throw new RateLimitException('rate limited', $retryAfter);
};
$this->middleware->handle($job, $next);
}
/**
* @return array<string, array{attempts: int, shouldLog: bool}>
*/
public static function logSamplingProvider(): array
{
return [
'first attempt logs' => ['attempts' => 1, 'shouldLog' => true],
'second attempt logs' => ['attempts' => 2, 'shouldLog' => true],
'third attempt logs' => ['attempts' => 3, 'shouldLog' => true],
'fourth attempt skipped' => ['attempts' => 4, 'shouldLog' => false],
'ninth attempt skipped' => ['attempts' => 9, 'shouldLog' => false],
'tenth attempt logs (multiple of 10)' => ['attempts' => 10, 'shouldLog' => true],
'eleventh attempt skipped' => ['attempts' => 11, 'shouldLog' => false],
'twentieth attempt logs' => ['attempts' => 20, 'shouldLog' => true],
];
}
#[DataProvider('logSamplingProvider')]
public function testLogSampling(int $attempts, bool $shouldLog): void
{
if ($shouldLog) {
Log::shouldReceive('info')
->once()
->with(
'[HandleHubspotRateLimit] Rate limit caught, releasing job with delay',
$this->callback(static function (array $context) use ($attempts): bool {
return $context['attempts'] === $attempts
&& $context['retry_after'] === 1
&& isset($context['delay']);
})
);
} else {
Log::shouldReceive('info')->never();
}
/** @var Job&MockObject $job */
$job = $this->createMock(Job::class);
$job->method('attempts')->willReturn($attempts);
$job->expects($this->once())->method('release');
$next = static function (): void {
throw new RateLimitException('rate limited', 1);
};
$this->middleware->handle($job, $next);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.82413566,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.8394282,"top":0.019952115,"width":0.076130316,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Jobs\\Middleware;\n\nuse Exception;\nuse Illuminate\\Contracts\\Queue\\Job;\nuse Illuminate\\Support\\Facades\\Log;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Jobs\\Middleware\\HandleHubspotRateLimit;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse PHPUnit\\Framework\\Attributes\\DataProvider;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\n\n#[CoversClass(HandleHubspotRateLimit::class)]\nclass HandleHubspotRateLimitTest extends TestCase\n{\n private HandleHubspotRateLimit $middleware;\n\n protected function setUp(): void\n {\n parent::setUp();\n\n $this->middleware = new HandleHubspotRateLimit();\n }\n\n public function testPassesThroughWhenNoExceptionThrown(): void\n {\n $job = $this->createMock(Job::class);\n $job->expects($this->never())->method('release');\n\n $called = false;\n $next = function (object $passed) use ($job, &$called): void {\n $this->assertSame($job, $passed);\n $called = true;\n };\n\n $this->middleware->handle($job, $next);\n\n $this->assertTrue($called);\n }\n\n public function testPropagatesNonRateLimitExceptions(): void\n {\n $job = $this->createMock(Job::class);\n $job->expects($this->never())->method('release');\n\n $next = static function (): void {\n throw new Exception('Database is down');\n };\n\n $this->expectException(Exception::class);\n $this->expectExceptionMessage('Database is down');\n\n $this->middleware->handle($job, $next);\n }\n\n /**\n * @return array<string, array{retryAfter: int, expectedMin: int, expectedMax: int}>\n */\n public static function delayClampingProvider(): array\n {\n return [\n 'short retry passes through' => [\n 'retryAfter' => 1,\n 'expectedMin' => 1,\n 'expectedMax' => 6, // 1 + 5 jitter\n ],\n 'medium retry passes through' => [\n 'retryAfter' => 30,\n 'expectedMin' => 30,\n 'expectedMax' => 35, // 30 + 5 jitter\n ],\n 'large retry clamped to 600s max' => [\n 'retryAfter' => 86400,\n 'expectedMin' => 600,\n 'expectedMax' => 605, // 600 + 5 jitter\n ],\n ];\n }\n\n #[DataProvider('delayClampingProvider')]\n public function testReleasesJobWithClampedDelay(int $retryAfter, int $expectedMin, int $expectedMax): void\n {\n Log::shouldReceive('info')->zeroOrMoreTimes();\n\n /** @var Job&MockObject $job */\n $job = $this->createMock(Job::class);\n $job->method('attempts')->willReturn(1);\n $job->expects($this->once())\n ->method('release')\n ->with($this->callback(static function (int $delay) use ($expectedMin, $expectedMax): bool {\n return $delay >= $expectedMin && $delay <= $expectedMax;\n }));\n\n $next = static function () use ($retryAfter): void {\n throw new RateLimitException('rate limited', $retryAfter);\n };\n\n $this->middleware->handle($job, $next);\n }\n\n /**\n * @return array<string, array{attempts: int, shouldLog: bool}>\n */\n public static function logSamplingProvider(): array\n {\n return [\n 'first attempt logs' => ['attempts' => 1, 'shouldLog' => true],\n 'second attempt logs' => ['attempts' => 2, 'shouldLog' => true],\n 'third attempt logs' => ['attempts' => 3, 'shouldLog' => true],\n 'fourth attempt skipped' => ['attempts' => 4, 'shouldLog' => false],\n 'ninth attempt skipped' => ['attempts' => 9, 'shouldLog' => false],\n 'tenth attempt logs (multiple of 10)' => ['attempts' => 10, 'shouldLog' => true],\n 'eleventh attempt skipped' => ['attempts' => 11, 'shouldLog' => false],\n 'twentieth attempt logs' => ['attempts' => 20, 'shouldLog' => true],\n ];\n }\n\n #[DataProvider('logSamplingProvider')]\n public function testLogSampling(int $attempts, bool $shouldLog): void\n {\n if ($shouldLog) {\n Log::shouldReceive('info')\n ->once()\n ->with(\n '[HandleHubspotRateLimit] Rate limit caught, releasing job with delay',\n $this->callback(static function (array $context) use ($attempts): bool {\n return $context['attempts'] === $attempts\n && $context['retry_after'] === 1\n && isset($context['delay']);\n })\n );\n } else {\n Log::shouldReceive('info')->never();\n }\n\n /** @var Job&MockObject $job */\n $job = $this->createMock(Job::class);\n $job->method('attempts')->willReturn($attempts);\n $job->expects($this->once())->method('release');\n\n $next = static function (): void {\n throw new RateLimitException('rate limited', 1);\n };\n\n $this->middleware->handle($job, $next);\n }\n}","depth":4,"bounds":{"left":0.122340426,"top":0.17158818,"width":0.43085107,"height":0.8284118},"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Jobs\\Middleware;\n\nuse Exception;\nuse Illuminate\\Contracts\\Queue\\Job;\nuse Illuminate\\Support\\Facades\\Log;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Jobs\\Middleware\\HandleHubspotRateLimit;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse PHPUnit\\Framework\\Attributes\\DataProvider;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\n\n#[CoversClass(HandleHubspotRateLimit::class)]\nclass HandleHubspotRateLimitTest extends TestCase\n{\n private HandleHubspotRateLimit $middleware;\n\n protected function setUp(): void\n {\n parent::setUp();\n\n $this->middleware = new HandleHubspotRateLimit();\n }\n\n public function testPassesThroughWhenNoExceptionThrown(): void\n {\n $job = $this->createMock(Job::class);\n $job->expects($this->never())->method('release');\n\n $called = false;\n $next = function (object $passed) use ($job, &$called): void {\n $this->assertSame($job, $passed);\n $called = true;\n };\n\n $this->middleware->handle($job, $next);\n\n $this->assertTrue($called);\n }\n\n public function testPropagatesNonRateLimitExceptions(): void\n {\n $job = $this->createMock(Job::class);\n $job->expects($this->never())->method('release');\n\n $next = static function (): void {\n throw new Exception('Database is down');\n };\n\n $this->expectException(Exception::class);\n $this->expectExceptionMessage('Database is down');\n\n $this->middleware->handle($job, $next);\n }\n\n /**\n * @return array<string, array{retryAfter: int, expectedMin: int, expectedMax: int}>\n */\n public static function delayClampingProvider(): array\n {\n return [\n 'short retry passes through' => [\n 'retryAfter' => 1,\n 'expectedMin' => 1,\n 'expectedMax' => 6, // 1 + 5 jitter\n ],\n 'medium retry passes through' => [\n 'retryAfter' => 30,\n 'expectedMin' => 30,\n 'expectedMax' => 35, // 30 + 5 jitter\n ],\n 'large retry clamped to 600s max' => [\n 'retryAfter' => 86400,\n 'expectedMin' => 600,\n 'expectedMax' => 605, // 600 + 5 jitter\n ],\n ];\n }\n\n #[DataProvider('delayClampingProvider')]\n public function testReleasesJobWithClampedDelay(int $retryAfter, int $expectedMin, int $expectedMax): void\n {\n Log::shouldReceive('info')->zeroOrMoreTimes();\n\n /** @var Job&MockObject $job */\n $job = $this->createMock(Job::class);\n $job->method('attempts')->willReturn(1);\n $job->expects($this->once())\n ->method('release')\n ->with($this->callback(static function (int $delay) use ($expectedMin, $expectedMax): bool {\n return $delay >= $expectedMin && $delay <= $expectedMax;\n }));\n\n $next = static function () use ($retryAfter): void {\n throw new RateLimitException('rate limited', $retryAfter);\n };\n\n $this->middleware->handle($job, $next);\n }\n\n /**\n * @return array<string, array{attempts: int, shouldLog: bool}>\n */\n public static function logSamplingProvider(): array\n {\n return [\n 'first attempt logs' => ['attempts' => 1, 'shouldLog' => true],\n 'second attempt logs' => ['attempts' => 2, 'shouldLog' => true],\n 'third attempt logs' => ['attempts' => 3, 'shouldLog' => true],\n 'fourth attempt skipped' => ['attempts' => 4, 'shouldLog' => false],\n 'ninth attempt skipped' => ['attempts' => 9, 'shouldLog' => false],\n 'tenth attempt logs (multiple of 10)' => ['attempts' => 10, 'shouldLog' => true],\n 'eleventh attempt skipped' => ['attempts' => 11, 'shouldLog' => false],\n 'twentieth attempt logs' => ['attempts' => 20, 'shouldLog' => true],\n ];\n }\n\n #[DataProvider('logSamplingProvider')]\n public function testLogSampling(int $attempts, bool $shouldLog): void\n {\n if ($shouldLog) {\n Log::shouldReceive('info')\n ->once()\n ->with(\n '[HandleHubspotRateLimit] Rate limit caught, releasing job with delay',\n $this->callback(static function (array $context) use ($attempts): bool {\n return $context['attempts'] === $attempts\n && $context['retry_after'] === 1\n && isset($context['delay']);\n })\n );\n } else {\n Log::shouldReceive('info')->never();\n }\n\n /** @var Job&MockObject $job */\n $job = $this->createMock(Job::class);\n $job->method('attempts')->willReturn($attempts);\n $job->expects($this->once())->method('release');\n\n $next = static function (): void {\n throw new RateLimitException('rate limited', 1);\n };\n\n $this->middleware->handle($job, $next);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.96276593,"top":0.07581804,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.9740692,"top":0.074221864,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.98138297,"top":0.074221864,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-434899306801221671
|
-7141563512795342200
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
declare(strict_types=1);
namespace Tests\Unit\Jobs\Middleware;
use Exception;
use Illuminate\Contracts\Queue\Job;
use Illuminate\Support\Facades\Log;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
#[CoversClass(HandleHubspotRateLimit::class)]
class HandleHubspotRateLimitTest extends TestCase
{
private HandleHubspotRateLimit $middleware;
protected function setUp(): void
{
parent::setUp();
$this->middleware = new HandleHubspotRateLimit();
}
public function testPassesThroughWhenNoExceptionThrown(): void
{
$job = $this->createMock(Job::class);
$job->expects($this->never())->method('release');
$called = false;
$next = function (object $passed) use ($job, &$called): void {
$this->assertSame($job, $passed);
$called = true;
};
$this->middleware->handle($job, $next);
$this->assertTrue($called);
}
public function testPropagatesNonRateLimitExceptions(): void
{
$job = $this->createMock(Job::class);
$job->expects($this->never())->method('release');
$next = static function (): void {
throw new Exception('Database is down');
};
$this->expectException(Exception::class);
$this->expectExceptionMessage('Database is down');
$this->middleware->handle($job, $next);
}
/**
* @return array<string, array{retryAfter: int, expectedMin: int, expectedMax: int}>
*/
public static function delayClampingProvider(): array
{
return [
'short retry passes through' => [
'retryAfter' => 1,
'expectedMin' => 1,
'expectedMax' => 6, // 1 + 5 jitter
],
'medium retry passes through' => [
'retryAfter' => 30,
'expectedMin' => 30,
'expectedMax' => 35, // 30 + 5 jitter
],
'large retry clamped to 600s max' => [
'retryAfter' => 86400,
'expectedMin' => 600,
'expectedMax' => 605, // 600 + 5 jitter
],
];
}
#[DataProvider('delayClampingProvider')]
public function testReleasesJobWithClampedDelay(int $retryAfter, int $expectedMin, int $expectedMax): void
{
Log::shouldReceive('info')->zeroOrMoreTimes();
/** @var Job&MockObject $job */
$job = $this->createMock(Job::class);
$job->method('attempts')->willReturn(1);
$job->expects($this->once())
->method('release')
->with($this->callback(static function (int $delay) use ($expectedMin, $expectedMax): bool {
return $delay >= $expectedMin && $delay <= $expectedMax;
}));
$next = static function () use ($retryAfter): void {
throw new RateLimitException('rate limited', $retryAfter);
};
$this->middleware->handle($job, $next);
}
/**
* @return array<string, array{attempts: int, shouldLog: bool}>
*/
public static function logSamplingProvider(): array
{
return [
'first attempt logs' => ['attempts' => 1, 'shouldLog' => true],
'second attempt logs' => ['attempts' => 2, 'shouldLog' => true],
'third attempt logs' => ['attempts' => 3, 'shouldLog' => true],
'fourth attempt skipped' => ['attempts' => 4, 'shouldLog' => false],
'ninth attempt skipped' => ['attempts' => 9, 'shouldLog' => false],
'tenth attempt logs (multiple of 10)' => ['attempts' => 10, 'shouldLog' => true],
'eleventh attempt skipped' => ['attempts' => 11, 'shouldLog' => false],
'twentieth attempt logs' => ['attempts' => 20, 'shouldLog' => true],
];
}
#[DataProvider('logSamplingProvider')]
public function testLogSampling(int $attempts, bool $shouldLog): void
{
if ($shouldLog) {
Log::shouldReceive('info')
->once()
->with(
'[HandleHubspotRateLimit] Rate limit caught, releasing job with delay',
$this->callback(static function (array $context) use ($attempts): bool {
return $context['attempts'] === $attempts
&& $context['retry_after'] === 1
&& isset($context['delay']);
})
);
} else {
Log::shouldReceive('info')->never();
}
/** @var Job&MockObject $job */
$job = $this->createMock(Job::class);
$job->method('attempts')->willReturn($attempts);
$job->expects($this->once())->method('release');
$next = static function (): void {
throw new RateLimitException('rate limited', 1);
};
$this->middleware->handle($job, $next);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20594
|
895
|
7
|
2026-05-11T15:57:22.769806+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515042769_m2.jpg...
|
PhpStorm
|
faVsco.js – HandleHubspotRateLimitTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.82413566,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-817699417600123759
|
-7159315621853853246
|
visual_change
|
hybrid
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
PhostormVIewINavigareCodeLaravelKeractorWindowmelpFV faVsco.js?9 JY-20725-handle-HS-search-rate-limitProiectRematchActivityOnCrmObjectDetach.php© HubspotPaginationService.php(C) TrackAutomatedReportGenerateaevent.onp>@ Mailbo›C) UserAutomatedReportscontroller.onghuospot/service.onpOhubspot/service.pnp© SyncCrmEntitiesTrait.phpC) CachedCrmServiceDecorator.ongv D Middleware© CheckAndRetryRemoteMatch.phpo handleruospotkal>@ Streaming© MatchActivityCrmData.php© RateLimitException.phpC) HandlerubspotkateLimit.php© ClientTest.phpC) Kernel.php>D Team_ lelepnony> DUserc) ImportRecallAlRecordi<?ohpdeclarelstrict tyoess.o:c) SasVisibilitycontrolTe:v W Listenersnamespace Tests Unit Jobs MiddLeware:> @ Activities>D Audio• AutomatedReports› use ...Autoscore17 Crm18 VAM DealRisks#[CoversClass(HandLeHubspotRateLimit::class)]class HandleHubspotRateLimitTest extends TestCase17 ElasticSearchlGrouosprivace nanolenuospotkaceLimlt smlddleware.Import• M MailbosCancola yLocal ChangesLog x• Chanaes & files+ → E Side-by-side viewer -Do not ignoreHighlight wordsXBB?= env.local aor.© Client.php app/Services/Crm/Hubspot.02d5011h toctc/llnit/Corvicoc/Crm/Lubcnot/CliontToct.nhrc ClientTect nhn tectc/Unit/Services/Crm/Huhsnot// NX + TTL option array -- exact TTL denends on narseRetrvAfter verified senaratelvSredisMock->shouldReceive('set')© HandleHubspotRateLimitTest.php tests/Unit/Jobs/Middleware© JiminnyDebugCommand.php app/Console/Commands->onceophp logging.php config© MatchActivityCrmData.php app/Jobs/Crm© RateLimitException.php app/Exceptionshuhsnot•natelimit.nontal•4211LInversioned Files 9 tllesMockery::type('string'),Mockery::on(fn (Sopts) => is_array(Sopts) && in_array('nx', $opts, true))E.env.nikilocal appE.env.other app© CanAccessAiReportsTest.php tests/Unit/Policies© CreateMockAskJiminnyReportResultCommand.php app/Console/Commands/Ri tavicon.ico public=ids.txt apdTa raw sal querv.sal app© SimulateWebhooksCommand.php app/Console/Commands/Crm/HubspotM.WEBTOOK FILTERING IMPLEMENTATION.mo a00III1shuospockaceLimico cescspublic function testIsHubspotRateLimitReturnsTrueForBadRequest429O: voidSe = new BadRequest('Too Manv Requests', 429),sthis->assertirue(sthis->culent->1s.uospotrateuimttse)):public function testIsHubspotRateLimitReturnsTrueForDealApiException429O: void$e = new DealApiException('Too Many Requests', 429):Sthic-saccenttrue(Sthis->client->1cluhsnntPatelimit/Se)l•100% L2P• Mon 11 May 18:57:22HandleHubsnotPatel imitTectA SF [jiminny@localhost]4 HS_local [jiminny@localhost]&console [pRODI& console lEulconsole [STAGINGI"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEs0kXi =s07-May-26 14:51:15 GMT: domain=.hubapi.com: Http0nly: Secure: SameSite=None"].w19A"кeрoгс-1о"."?N"urL\":1"https:(VNVa.net.cloudf Lare.com\V/report\V/v4?s=NYALsVTPotYm52qrSDJxYE4sd2RwRq15p5wHsmd=g<Lz@YdxLx2B1XVpHmsKnS0%2BKVA5mF1J2m/YRECD65nx2BW2LYT206FM4%2l v("group"; \"cf-nell","max age":604800,"J,"success traction".0.olg"max ade":6048002"."Serven":"cloudflare"?>4"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab","trace id":"c7ab8365-903f-46d4-9403-0e5b551e3545"%20 differencesTeirront vorcian// NX + TTL ontion arnav - exact TTL denends on narseRetrvAfter. verified senaratelvSredisMock->shouldReceive('set')->once(->withdhuhsnot•natelimit.confia.4211Mockery::type('string'),Mockery: :on(fn (Sopts) => is_array(Sopts) && in_array('nx', $opts, true))pockaceLimico cescs orivace mechoprivate function callIsHubspotRateLimit(\Throwable $e): boolSmethod= newReflectionmethodsthts->culentotRateLimit:Smethod->serAccesstole(true.return Smethod->invoke(Sthis->cLient. Se):public function testIsHubspotRateLimitReturnsTrueForBadRequest4290: voidW Windsurf Teams 1:1 UTF-8 P 4 spaces ®...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20597
|
895
|
9
|
2026-05-11T15:57:25.803317+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515045803_m2.jpg...
|
PhpStorm
|
faVsco.js – HandleHubspotRateLimitTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
declare(strict_types=1);
namespace Tests\Unit\Jobs\Middleware;
use Exception;
use Illuminate\Contracts\Queue\Job;
use Illuminate\Support\Facades\Log;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
#[CoversClass(HandleHubspotRateLimit::class)]
class HandleHubspotRateLimitTest extends TestCase
{
private HandleHubspotRateLimit $middleware;
protected function setUp(): void
{
parent::setUp();
$this->middleware = new HandleHubspotRateLimit();
}
public function testPassesThroughWhenNoExceptionThrown(): void
{
$job = $this->createMock(Job::class);
$job->expects($this->never())->method('release');
$called = false;
$next = function (object $passed) use ($job, &$called): void {
$this->assertSame($job, $passed);
$called = true;
};
$this->middleware->handle($job, $next);
$this->assertTrue($called);
}
public function testPropagatesNonRateLimitExceptions(): void
{
$job = $this->createMock(Job::class);
$job->expects($this->never())->method('release');
$next = static function (): void {
throw new Exception('Database is down');
};
$this->expectException(Exception::class);
$this->expectExceptionMessage('Database is down');
$this->middleware->handle($job, $next);
}
/**
* @return array<string, array{retryAfter: int, expectedMin: int, expectedMax: int}>
*/
public static function delayClampingProvider(): array
{
return [
'short retry passes through' => [
'retryAfter' => 1,
'expectedMin' => 1,
'expectedMax' => 6, // 1 + 5 jitter
],
'medium retry passes through' => [
'retryAfter' => 30,
'expectedMin' => 30,
'expectedMax' => 35, // 30 + 5 jitter
],
'large retry clamped to 600s max' => [
'retryAfter' => 86400,
'expectedMin' => 600,
'expectedMax' => 605, // 600 + 5 jitter
],
];
}
#[DataProvider('delayClampingProvider')]
public function testReleasesJobWithClampedDelay(int $retryAfter, int $expectedMin, int $expectedMax): void
{
Log::shouldReceive('info')->zeroOrMoreTimes();
/** @var Job&MockObject $job */
$job = $this->createMock(Job::class);
$job->method('attempts')->willReturn(1);
$job->expects($this->once())
->method('release')
->with($this->callback(static function (int $delay) use ($expectedMin, $expectedMax): bool {
return $delay >= $expectedMin && $delay <= $expectedMax;
}));
$next = static function () use ($retryAfter): void {
throw new RateLimitException('rate limited', $retryAfter);
};
$this->middleware->handle($job, $next);
}
/**
* @return array<string, array{attempts: int, shouldLog: bool}>
*/
public static function logSamplingProvider(): array
{
return [
'first attempt logs' => ['attempts' => 1, 'shouldLog' => true],
'second attempt logs' => ['attempts' => 2, 'shouldLog' => true],
'third attempt logs' => ['attempts' => 3, 'shouldLog' => true],
'fourth attempt skipped' => ['attempts' => 4, 'shouldLog' => false],
'ninth attempt skipped' => ['attempts' => 9, 'shouldLog' => false],
'tenth attempt logs (multiple of 10)' => ['attempts' => 10, 'shouldLog' => true],
'eleventh attempt skipped' => ['attempts' => 11, 'shouldLog' => false],
'twentieth attempt logs' => ['attempts' => 20, 'shouldLog' => true],
];
}
#[DataProvider('logSamplingProvider')]
public function testLogSampling(int $attempts, bool $shouldLog): void
{
if ($shouldLog) {
Log::shouldReceive('info')
->once()
->with(
'[HandleHubspotRateLimit] Rate limit caught, releasing job with delay',
$this->callback(static function (array $context) use ($attempts): bool {
return $context['attempts'] === $attempts
&& $context['retry_after'] === 1
&& isset($context['delay']);
})
);
} else {
Log::shouldReceive('info')->never();
}
/** @var Job&MockObject $job */
$job = $this->createMock(Job::class);
$job->method('attempts')->willReturn($attempts);
$job->expects($this->once())->method('release');
$next = static function (): void {
throw new RateLimitException('rate limited', 1);
};
$this->middleware->handle($job, $next);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.82413566,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.8394282,"top":0.019952115,"width":0.076130316,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Jobs\\Middleware;\n\nuse Exception;\nuse Illuminate\\Contracts\\Queue\\Job;\nuse Illuminate\\Support\\Facades\\Log;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Jobs\\Middleware\\HandleHubspotRateLimit;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse PHPUnit\\Framework\\Attributes\\DataProvider;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\n\n#[CoversClass(HandleHubspotRateLimit::class)]\nclass HandleHubspotRateLimitTest extends TestCase\n{\n private HandleHubspotRateLimit $middleware;\n\n protected function setUp(): void\n {\n parent::setUp();\n\n $this->middleware = new HandleHubspotRateLimit();\n }\n\n public function testPassesThroughWhenNoExceptionThrown(): void\n {\n $job = $this->createMock(Job::class);\n $job->expects($this->never())->method('release');\n\n $called = false;\n $next = function (object $passed) use ($job, &$called): void {\n $this->assertSame($job, $passed);\n $called = true;\n };\n\n $this->middleware->handle($job, $next);\n\n $this->assertTrue($called);\n }\n\n public function testPropagatesNonRateLimitExceptions(): void\n {\n $job = $this->createMock(Job::class);\n $job->expects($this->never())->method('release');\n\n $next = static function (): void {\n throw new Exception('Database is down');\n };\n\n $this->expectException(Exception::class);\n $this->expectExceptionMessage('Database is down');\n\n $this->middleware->handle($job, $next);\n }\n\n /**\n * @return array<string, array{retryAfter: int, expectedMin: int, expectedMax: int}>\n */\n public static function delayClampingProvider(): array\n {\n return [\n 'short retry passes through' => [\n 'retryAfter' => 1,\n 'expectedMin' => 1,\n 'expectedMax' => 6, // 1 + 5 jitter\n ],\n 'medium retry passes through' => [\n 'retryAfter' => 30,\n 'expectedMin' => 30,\n 'expectedMax' => 35, // 30 + 5 jitter\n ],\n 'large retry clamped to 600s max' => [\n 'retryAfter' => 86400,\n 'expectedMin' => 600,\n 'expectedMax' => 605, // 600 + 5 jitter\n ],\n ];\n }\n\n #[DataProvider('delayClampingProvider')]\n public function testReleasesJobWithClampedDelay(int $retryAfter, int $expectedMin, int $expectedMax): void\n {\n Log::shouldReceive('info')->zeroOrMoreTimes();\n\n /** @var Job&MockObject $job */\n $job = $this->createMock(Job::class);\n $job->method('attempts')->willReturn(1);\n $job->expects($this->once())\n ->method('release')\n ->with($this->callback(static function (int $delay) use ($expectedMin, $expectedMax): bool {\n return $delay >= $expectedMin && $delay <= $expectedMax;\n }));\n\n $next = static function () use ($retryAfter): void {\n throw new RateLimitException('rate limited', $retryAfter);\n };\n\n $this->middleware->handle($job, $next);\n }\n\n /**\n * @return array<string, array{attempts: int, shouldLog: bool}>\n */\n public static function logSamplingProvider(): array\n {\n return [\n 'first attempt logs' => ['attempts' => 1, 'shouldLog' => true],\n 'second attempt logs' => ['attempts' => 2, 'shouldLog' => true],\n 'third attempt logs' => ['attempts' => 3, 'shouldLog' => true],\n 'fourth attempt skipped' => ['attempts' => 4, 'shouldLog' => false],\n 'ninth attempt skipped' => ['attempts' => 9, 'shouldLog' => false],\n 'tenth attempt logs (multiple of 10)' => ['attempts' => 10, 'shouldLog' => true],\n 'eleventh attempt skipped' => ['attempts' => 11, 'shouldLog' => false],\n 'twentieth attempt logs' => ['attempts' => 20, 'shouldLog' => true],\n ];\n }\n\n #[DataProvider('logSamplingProvider')]\n public function testLogSampling(int $attempts, bool $shouldLog): void\n {\n if ($shouldLog) {\n Log::shouldReceive('info')\n ->once()\n ->with(\n '[HandleHubspotRateLimit] Rate limit caught, releasing job with delay',\n $this->callback(static function (array $context) use ($attempts): bool {\n return $context['attempts'] === $attempts\n && $context['retry_after'] === 1\n && isset($context['delay']);\n })\n );\n } else {\n Log::shouldReceive('info')->never();\n }\n\n /** @var Job&MockObject $job */\n $job = $this->createMock(Job::class);\n $job->method('attempts')->willReturn($attempts);\n $job->expects($this->once())->method('release');\n\n $next = static function (): void {\n throw new RateLimitException('rate limited', 1);\n };\n\n $this->middleware->handle($job, $next);\n }\n}","depth":4,"bounds":{"left":0.122340426,"top":0.17158818,"width":0.43085107,"height":0.8284118},"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Jobs\\Middleware;\n\nuse Exception;\nuse Illuminate\\Contracts\\Queue\\Job;\nuse Illuminate\\Support\\Facades\\Log;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Jobs\\Middleware\\HandleHubspotRateLimit;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse PHPUnit\\Framework\\Attributes\\DataProvider;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\n\n#[CoversClass(HandleHubspotRateLimit::class)]\nclass HandleHubspotRateLimitTest extends TestCase\n{\n private HandleHubspotRateLimit $middleware;\n\n protected function setUp(): void\n {\n parent::setUp();\n\n $this->middleware = new HandleHubspotRateLimit();\n }\n\n public function testPassesThroughWhenNoExceptionThrown(): void\n {\n $job = $this->createMock(Job::class);\n $job->expects($this->never())->method('release');\n\n $called = false;\n $next = function (object $passed) use ($job, &$called): void {\n $this->assertSame($job, $passed);\n $called = true;\n };\n\n $this->middleware->handle($job, $next);\n\n $this->assertTrue($called);\n }\n\n public function testPropagatesNonRateLimitExceptions(): void\n {\n $job = $this->createMock(Job::class);\n $job->expects($this->never())->method('release');\n\n $next = static function (): void {\n throw new Exception('Database is down');\n };\n\n $this->expectException(Exception::class);\n $this->expectExceptionMessage('Database is down');\n\n $this->middleware->handle($job, $next);\n }\n\n /**\n * @return array<string, array{retryAfter: int, expectedMin: int, expectedMax: int}>\n */\n public static function delayClampingProvider(): array\n {\n return [\n 'short retry passes through' => [\n 'retryAfter' => 1,\n 'expectedMin' => 1,\n 'expectedMax' => 6, // 1 + 5 jitter\n ],\n 'medium retry passes through' => [\n 'retryAfter' => 30,\n 'expectedMin' => 30,\n 'expectedMax' => 35, // 30 + 5 jitter\n ],\n 'large retry clamped to 600s max' => [\n 'retryAfter' => 86400,\n 'expectedMin' => 600,\n 'expectedMax' => 605, // 600 + 5 jitter\n ],\n ];\n }\n\n #[DataProvider('delayClampingProvider')]\n public function testReleasesJobWithClampedDelay(int $retryAfter, int $expectedMin, int $expectedMax): void\n {\n Log::shouldReceive('info')->zeroOrMoreTimes();\n\n /** @var Job&MockObject $job */\n $job = $this->createMock(Job::class);\n $job->method('attempts')->willReturn(1);\n $job->expects($this->once())\n ->method('release')\n ->with($this->callback(static function (int $delay) use ($expectedMin, $expectedMax): bool {\n return $delay >= $expectedMin && $delay <= $expectedMax;\n }));\n\n $next = static function () use ($retryAfter): void {\n throw new RateLimitException('rate limited', $retryAfter);\n };\n\n $this->middleware->handle($job, $next);\n }\n\n /**\n * @return array<string, array{attempts: int, shouldLog: bool}>\n */\n public static function logSamplingProvider(): array\n {\n return [\n 'first attempt logs' => ['attempts' => 1, 'shouldLog' => true],\n 'second attempt logs' => ['attempts' => 2, 'shouldLog' => true],\n 'third attempt logs' => ['attempts' => 3, 'shouldLog' => true],\n 'fourth attempt skipped' => ['attempts' => 4, 'shouldLog' => false],\n 'ninth attempt skipped' => ['attempts' => 9, 'shouldLog' => false],\n 'tenth attempt logs (multiple of 10)' => ['attempts' => 10, 'shouldLog' => true],\n 'eleventh attempt skipped' => ['attempts' => 11, 'shouldLog' => false],\n 'twentieth attempt logs' => ['attempts' => 20, 'shouldLog' => true],\n ];\n }\n\n #[DataProvider('logSamplingProvider')]\n public function testLogSampling(int $attempts, bool $shouldLog): void\n {\n if ($shouldLog) {\n Log::shouldReceive('info')\n ->once()\n ->with(\n '[HandleHubspotRateLimit] Rate limit caught, releasing job with delay',\n $this->callback(static function (array $context) use ($attempts): bool {\n return $context['attempts'] === $attempts\n && $context['retry_after'] === 1\n && isset($context['delay']);\n })\n );\n } else {\n Log::shouldReceive('info')->never();\n }\n\n /** @var Job&MockObject $job */\n $job = $this->createMock(Job::class);\n $job->method('attempts')->willReturn($attempts);\n $job->expects($this->once())->method('release');\n\n $next = static function (): void {\n throw new RateLimitException('rate limited', 1);\n };\n\n $this->middleware->handle($job, $next);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.96276593,"top":0.07581804,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.9740692,"top":0.074221864,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.98138297,"top":0.074221864,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-434899306801221671
|
-7141563512795342200
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
declare(strict_types=1);
namespace Tests\Unit\Jobs\Middleware;
use Exception;
use Illuminate\Contracts\Queue\Job;
use Illuminate\Support\Facades\Log;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
#[CoversClass(HandleHubspotRateLimit::class)]
class HandleHubspotRateLimitTest extends TestCase
{
private HandleHubspotRateLimit $middleware;
protected function setUp(): void
{
parent::setUp();
$this->middleware = new HandleHubspotRateLimit();
}
public function testPassesThroughWhenNoExceptionThrown(): void
{
$job = $this->createMock(Job::class);
$job->expects($this->never())->method('release');
$called = false;
$next = function (object $passed) use ($job, &$called): void {
$this->assertSame($job, $passed);
$called = true;
};
$this->middleware->handle($job, $next);
$this->assertTrue($called);
}
public function testPropagatesNonRateLimitExceptions(): void
{
$job = $this->createMock(Job::class);
$job->expects($this->never())->method('release');
$next = static function (): void {
throw new Exception('Database is down');
};
$this->expectException(Exception::class);
$this->expectExceptionMessage('Database is down');
$this->middleware->handle($job, $next);
}
/**
* @return array<string, array{retryAfter: int, expectedMin: int, expectedMax: int}>
*/
public static function delayClampingProvider(): array
{
return [
'short retry passes through' => [
'retryAfter' => 1,
'expectedMin' => 1,
'expectedMax' => 6, // 1 + 5 jitter
],
'medium retry passes through' => [
'retryAfter' => 30,
'expectedMin' => 30,
'expectedMax' => 35, // 30 + 5 jitter
],
'large retry clamped to 600s max' => [
'retryAfter' => 86400,
'expectedMin' => 600,
'expectedMax' => 605, // 600 + 5 jitter
],
];
}
#[DataProvider('delayClampingProvider')]
public function testReleasesJobWithClampedDelay(int $retryAfter, int $expectedMin, int $expectedMax): void
{
Log::shouldReceive('info')->zeroOrMoreTimes();
/** @var Job&MockObject $job */
$job = $this->createMock(Job::class);
$job->method('attempts')->willReturn(1);
$job->expects($this->once())
->method('release')
->with($this->callback(static function (int $delay) use ($expectedMin, $expectedMax): bool {
return $delay >= $expectedMin && $delay <= $expectedMax;
}));
$next = static function () use ($retryAfter): void {
throw new RateLimitException('rate limited', $retryAfter);
};
$this->middleware->handle($job, $next);
}
/**
* @return array<string, array{attempts: int, shouldLog: bool}>
*/
public static function logSamplingProvider(): array
{
return [
'first attempt logs' => ['attempts' => 1, 'shouldLog' => true],
'second attempt logs' => ['attempts' => 2, 'shouldLog' => true],
'third attempt logs' => ['attempts' => 3, 'shouldLog' => true],
'fourth attempt skipped' => ['attempts' => 4, 'shouldLog' => false],
'ninth attempt skipped' => ['attempts' => 9, 'shouldLog' => false],
'tenth attempt logs (multiple of 10)' => ['attempts' => 10, 'shouldLog' => true],
'eleventh attempt skipped' => ['attempts' => 11, 'shouldLog' => false],
'twentieth attempt logs' => ['attempts' => 20, 'shouldLog' => true],
];
}
#[DataProvider('logSamplingProvider')]
public function testLogSampling(int $attempts, bool $shouldLog): void
{
if ($shouldLog) {
Log::shouldReceive('info')
->once()
->with(
'[HandleHubspotRateLimit] Rate limit caught, releasing job with delay',
$this->callback(static function (array $context) use ($attempts): bool {
return $context['attempts'] === $attempts
&& $context['retry_after'] === 1
&& isset($context['delay']);
})
);
} else {
Log::shouldReceive('info')->never();
}
/** @var Job&MockObject $job */
$job = $this->createMock(Job::class);
$job->method('attempts')->willReturn($attempts);
$job->expects($this->once())->method('release');
$next = static function (): void {
throw new RateLimitException('rate limited', 1);
};
$this->middleware->handle($job, $next);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20602
|
895
|
13
|
2026-05-11T15:58:05.578330+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515085578_m2.jpg...
|
PhpStorm
|
Commit Changes
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Diff
Changelist:
Changelist:
Changes
Show Diff
Rol Diff
Changelist:
Changelist:
Changes
Show Diff
Rollback...
Refresh
Group By
Expand All
Collapse All
8 files, folder partially checked
app 4 files, folder partially checked
Console/Commands 1 file, folder not checked
Exceptions 1 file, folder checked
RateLimitException.php, exception class checked
Jobs/Crm 1 file, folder checked
MatchActivityCrmData.php, class checked
Services/Crm/Hubspot 1 file, folder checked
Client.php, class checked
config 1 file, folder not checked
tests/Unit 2 files, folder checked
Jobs/Middleware 1 file, folder checked
HandleHubspotRateLimitTest.php, class checked
Services/Crm/Hubspot 1 file, folder checked
ClientTest.php, class checked
.env.local not checked
app 4 files, folder partially checked
Console/Commands 1 file, folder not checked
Exceptions 1 file, folder checked
RateLimitException.php, exception class checked
Jobs/Crm 1 file, folder checked
MatchActivityCrmData.php, class checked
Services/Crm/Hubspot 1 file, folder checked
Client.php, class checked
Console/Commands 1 file, folder not checked
Exceptions 1 file, folder checked
RateLimitException.php, exception class checked
RateLimitException.php, exception class checked
Jobs/Crm 1 file, folder checked
MatchActivityCrmData.php, class checked
MatchActivityCrmData.php, class checked
Services/Crm/Hubspot 1 file, folder checked
Client.php, class checked
Client.php, class checked
config 1 file, folder not checked
tests/Unit 2 files, folder checked
Jobs/Middleware 1 file, folder checked
HandleHubspotRateLimitTest.php, class checked
Services/Crm/Hubspot 1 file, folder checked
ClientTest.php, class checked
Jobs/Middleware 1 file, folder checked
HandleHubspotRateLimitTest.php, class checked
HandleHubspotRateLimitTest.php, class checked
Services/Crm/Hubspot 1 file, folder checked
ClientTest.php, class checked
ClientTest.php, class checked
.env.local not checked
Unversioned Files 9 files not checked
5 modified
Analyzing…
JY-20725 add more tests
Commit Message
Commit Message History
Git
Author:
Author:
Amend commit
Sign-off commit
Create extra commit with file movements
Commit Checks
Update copyright...
|
[{"role":"AXStaticText","text& [{"role":"AXStaticText","text":"Diff","depth":1,"bounds":{"left":0.25531915,"top":0.39185953,"width":0.013962766,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Changelist:","depth":1,"bounds":{"left":0.38231382,"top":0.15802075,"width":0.022938829,"height":0.027134877},"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"Changelist:","depth":1,"bounds":{"left":0.40658244,"top":0.15802075,"width":0.042220745,"height":0.027134877},"on_screen":true,"role_description":"pop up button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Changes","depth":6,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Show Diff","depth":2,"bounds":{"left":0.25664893,"top":0.16201118,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Rollback...","depth":2,"bounds":{"left":0.26529256,"top":0.16201118,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Refresh","depth":2,"bounds":{"left":0.27393618,"top":0.16201118,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Group By","depth":2,"bounds":{"left":0.28490692,"top":0.16201118,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand All","depth":2,"bounds":{"left":0.3617021,"top":0.16201118,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":2,"bounds":{"left":0.37034574,"top":0.16201118,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8 files, folder partially checked","depth":4,"bounds":{"left":0.26462767,"top":0.13328013,"width":0.03025266,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app 4 files, folder partially checked","depth":5,"bounds":{"left":0.27094415,"top":0.15083799,"width":0.038231384,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Console/Commands 1 file, folder not checked","depth":6,"bounds":{"left":0.27726063,"top":0.16839585,"width":0.06881649,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Exceptions 1 file, folder checked","depth":6,"bounds":{"left":0.27726063,"top":0.1859537,"width":0.049867023,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException.php, exception class checked","depth":7,"bounds":{"left":0.2835771,"top":0.20351157,"width":0.064494684,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Jobs/Crm 1 file, folder checked","depth":6,"bounds":{"left":0.27726063,"top":0.22106944,"width":0.047539894,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"MatchActivityCrmData.php, class checked","depth":7,"bounds":{"left":0.2835771,"top":0.2386273,"width":0.071476065,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Services/Crm/Hubspot 1 file, folder checked","depth":6,"bounds":{"left":0.27726063,"top":0.25618514,"width":0.07413564,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Client.php, class checked","depth":7,"bounds":{"left":0.2835771,"top":0.273743,"width":0.036901597,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"config 1 file, folder not checked","depth":5,"bounds":{"left":0.27094415,"top":0.29130086,"width":0.040226065,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit 2 files, folder checked","depth":5,"bounds":{"left":0.27094415,"top":0.30885875,"width":0.050531916,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Jobs/Middleware 1 file, folder checked","depth":6,"bounds":{"left":0.27726063,"top":0.3264166,"width":0.062832445,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php, class checked","depth":7,"bounds":{"left":0.2835771,"top":0.34397447,"width":0.08510638,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Services/Crm/Hubspot 1 file, folder checked","depth":6,"bounds":{"left":0.27726063,"top":0.36153233,"width":0.07413564,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClientTest.php, class checked","depth":7,"bounds":{"left":0.2835771,"top":0.3790902,"width":0.046210106,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.local not checked","depth":5,"bounds":{"left":0.27094415,"top":0.39664805,"width":0.03557181,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app 4 files, folder partially checked","depth":4,"bounds":{"left":0.27094415,"top":0.15083799,"width":0.038231384,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Console/Commands 1 file, folder not checked","depth":5,"bounds":{"left":0.27726063,"top":0.16839585,"width":0.06881649,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Exceptions 1 file, folder checked","depth":5,"bounds":{"left":0.27726063,"top":0.1859537,"width":0.049867023,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException.php, exception class checked","depth":6,"bounds":{"left":0.2835771,"top":0.20351157,"width":0.064494684,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Jobs/Crm 1 file, folder checked","depth":5,"bounds":{"left":0.27726063,"top":0.22106944,"width":0.047539894,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"MatchActivityCrmData.php, class checked","depth":6,"bounds":{"left":0.2835771,"top":0.2386273,"width":0.071476065,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Services/Crm/Hubspot 1 file, folder checked","depth":5,"bounds":{"left":0.27726063,"top":0.25618514,"width":0.07413564,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Client.php, class checked","depth":6,"bounds":{"left":0.2835771,"top":0.273743,"width":0.036901597,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Console/Commands 1 file, folder not checked","depth":4,"bounds":{"left":0.27726063,"top":0.16839585,"width":0.06881649,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Exceptions 1 file, folder checked","depth":4,"bounds":{"left":0.27726063,"top":0.1859537,"width":0.049867023,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException.php, exception class checked","depth":5,"bounds":{"left":0.2835771,"top":0.20351157,"width":0.064494684,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException.php, exception class checked","depth":4,"bounds":{"left":0.2835771,"top":0.20351157,"width":0.064494684,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Jobs/Crm 1 file, folder checked","depth":4,"bounds":{"left":0.27726063,"top":0.22106944,"width":0.047539894,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"MatchActivityCrmData.php, class checked","depth":5,"bounds":{"left":0.2835771,"top":0.2386273,"width":0.071476065,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"MatchActivityCrmData.php, class checked","depth":4,"bounds":{"left":0.2835771,"top":0.2386273,"width":0.071476065,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Services/Crm/Hubspot 1 file, folder checked","depth":4,"bounds":{"left":0.27726063,"top":0.25618514,"width":0.07413564,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Client.php, class checked","depth":5,"bounds":{"left":0.2835771,"top":0.273743,"width":0.036901597,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Client.php, class checked","depth":4,"bounds":{"left":0.2835771,"top":0.273743,"width":0.036901597,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"config 1 file, folder not checked","depth":4,"bounds":{"left":0.27094415,"top":0.29130086,"width":0.040226065,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit 2 files, folder checked","depth":4,"bounds":{"left":0.27094415,"top":0.30885875,"width":0.050531916,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Jobs/Middleware 1 file, folder checked","depth":5,"bounds":{"left":0.27726063,"top":0.3264166,"width":0.062832445,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php, class checked","depth":6,"bounds":{"left":0.2835771,"top":0.34397447,"width":0.08510638,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Services/Crm/Hubspot 1 file, folder checked","depth":5,"bounds":{"left":0.27726063,"top":0.36153233,"width":0.07413564,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClientTest.php, class checked","depth":6,"bounds":{"left":0.2835771,"top":0.3790902,"width":0.046210106,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Jobs/Middleware 1 file, folder checked","depth":4,"bounds":{"left":0.27726063,"top":0.3264166,"width":0.062832445,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php, class checked","depth":5,"bounds":{"left":0.2835771,"top":0.34397447,"width":0.08510638,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php, class checked","depth":4,"bounds":{"left":0.2835771,"top":0.34397447,"width":0.08510638,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Services/Crm/Hubspot 1 file, folder checked","depth":4,"bounds":{"left":0.27726063,"top":0.36153233,"width":0.07413564,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClientTest.php, class checked","depth":5,"bounds":{"left":0.2835771,"top":0.3790902,"width":0.046210106,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClientTest.php, class checked","depth":4,"bounds":{"left":0.2835771,"top":0.3790902,"width":0.046210106,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.local not checked","depth":4,"bounds":{"left":0.27094415,"top":0.39664805,"width":0.03557181,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Unversioned Files 9 files not checked","depth":4,"bounds":{"left":0.26462767,"top":0.4142059,"width":0.060837764,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"5 modified","depth":1,"bounds":{"left":0.25531915,"top":0.254589,"width":0.19348404,"height":0.016759777},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Analyzing…","depth":3,"bounds":{"left":0.27027926,"top":1.0,"width":0.019946808,"height":0.0},"on_screen":false,"role_description":"text"},{"role":"AXTextArea","text":"JY-20725 add more tests","depth":2,"bounds":{"left":0.2556516,"top":0.3008779,"width":0.19281915,"height":0.087789305},"on_screen":true,"value":"JY-20725 add more tests","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Commit Message","depth":1,"bounds":{"left":0.25531915,"top":0.28252193,"width":0.03557181,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Commit Message History","depth":2,"bounds":{"left":0.4401596,"top":0.27853152,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Git","depth":2,"bounds":{"left":0.45212767,"top":0.16280925,"width":0.0056515955,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Author:","depth":2,"bounds":{"left":0.46010637,"top":0.18834797,"width":0.014960106,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"Author:","depth":3,"bounds":{"left":0.4787234,"top":0.18834797,"width":0.09840426,"height":0.013567438},"on_screen":true,"role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Amend commit","depth":2,"bounds":{"left":0.4587766,"top":0.21069433,"width":0.039893616,"height":0.019952115},"on_screen":true,"help_text":"Merge this commit with the previous one","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Sign-off commit","depth":2,"bounds":{"left":0.4587766,"top":0.23383878,"width":0.042220745,"height":0.019952115},"on_screen":true,"help_text":"<html>Adds the following line at the end of the commit message:<br/>Signed-off by: Lukas Kovalik <kovaliklukas@gmail.com></html>","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Create extra commit with file movements","depth":2,"on_screen":false,"role_description":"checkbox","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Commit Checks","depth":2,"bounds":{"left":0.45212767,"top":0.27613726,"width":0.032247342,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"Update copyright","depth":2,"bounds":{"left":0.4587766,"top":0.29608938,"width":0.04488032,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7831640914639698295
|
8802892914018493772
|
visual_change
|
accessibility
|
NULL
|
Diff
Changelist:
Changelist:
Changes
Show Diff
Rol Diff
Changelist:
Changelist:
Changes
Show Diff
Rollback...
Refresh
Group By
Expand All
Collapse All
8 files, folder partially checked
app 4 files, folder partially checked
Console/Commands 1 file, folder not checked
Exceptions 1 file, folder checked
RateLimitException.php, exception class checked
Jobs/Crm 1 file, folder checked
MatchActivityCrmData.php, class checked
Services/Crm/Hubspot 1 file, folder checked
Client.php, class checked
config 1 file, folder not checked
tests/Unit 2 files, folder checked
Jobs/Middleware 1 file, folder checked
HandleHubspotRateLimitTest.php, class checked
Services/Crm/Hubspot 1 file, folder checked
ClientTest.php, class checked
.env.local not checked
app 4 files, folder partially checked
Console/Commands 1 file, folder not checked
Exceptions 1 file, folder checked
RateLimitException.php, exception class checked
Jobs/Crm 1 file, folder checked
MatchActivityCrmData.php, class checked
Services/Crm/Hubspot 1 file, folder checked
Client.php, class checked
Console/Commands 1 file, folder not checked
Exceptions 1 file, folder checked
RateLimitException.php, exception class checked
RateLimitException.php, exception class checked
Jobs/Crm 1 file, folder checked
MatchActivityCrmData.php, class checked
MatchActivityCrmData.php, class checked
Services/Crm/Hubspot 1 file, folder checked
Client.php, class checked
Client.php, class checked
config 1 file, folder not checked
tests/Unit 2 files, folder checked
Jobs/Middleware 1 file, folder checked
HandleHubspotRateLimitTest.php, class checked
Services/Crm/Hubspot 1 file, folder checked
ClientTest.php, class checked
Jobs/Middleware 1 file, folder checked
HandleHubspotRateLimitTest.php, class checked
HandleHubspotRateLimitTest.php, class checked
Services/Crm/Hubspot 1 file, folder checked
ClientTest.php, class checked
ClientTest.php, class checked
.env.local not checked
Unversioned Files 9 files not checked
5 modified
Analyzing…
JY-20725 add more tests
Commit Message
Commit Message History
Git
Author:
Author:
Amend commit
Sign-off commit
Create extra commit with file movements
Commit Checks
Update copyright...
|
20599
|
NULL
|
NULL
|
NULL
|
|
20633
|
897
|
9
|
2026-05-11T16:02:51.462159+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515371462_m2.jpg...
|
PhpStorm
|
Push Commits to app
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
JY-20725-handle-HS-search-rate-limit → origin : JY JY-20725-handle-HS-search-rate-limit → origin : JY-20725-handle-HS-search-rate-limit
JY-20725 code review suggestions
Show Diff
Group By
Jump to Source
Show Details
Expand All
Collapse All
5 files, folder
app 3 files, folder
Exceptions 1 file, folder
RateLimitException.php, exception class
Jobs/Crm 1 file, folder
MatchActivityCrmData.php, class
Services/Crm/Hubspot 1 file, folder
Client.php, class
tests/Unit 2 files, folder
Jobs/Middleware 1 file, folder
HandleHubspotRateLimitTest.php, class
Services/Crm/Hubspot 1 file, folder
ClientTest.php, class
app 3 files, folder
Exceptions 1 file, folder
RateLimitException.php, exception class
Jobs/Crm 1 file, folder
MatchActivityCrmData.php, class
Services/Crm/Hubspot 1 file, folder
Client.php, class
Exceptions 1 file, folder
RateLimitException.php, exception class
RateLimitException.php, exception class
Jobs/Crm 1 file, folder
MatchActivityCrmData.php, class
MatchActivityCrmData.php, class
Services/Crm/Hubspot 1 file, folder
Client.php, class
Client.php, class
tests/Unit 2 files, folder
Jobs/Middleware 1 file, folder
HandleHubspotRateLimitTest.php, class
Services/Crm/Hubspot 1 file, folder
ClientTest.php, class
Jobs/Middleware 1 file, folder
HandleHubspotRateLimitTest.php, class
HandleHubspotRateLimitTest.php, class
Services/Crm/Hubspot 1 file, folder
ClientTest.php, class
ClientTest.php, class
For Commit and Push to non-protected branches, preview commits before push
Help
Push tags:
All
Current Branch
Cancel
Push
Push...
|
[{"role":"AXStaticText","text& [{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit → origin : JY-20725-handle-HS-search-rate-limit","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.12699468,"height":0.0},"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 code review suggestions","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.12699468,"height":0.0},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Show Diff","depth":2,"bounds":{"left":0.61269945,"top":0.31524342,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Group By","depth":2,"bounds":{"left":0.6236702,"top":0.31524342,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Jump to Source","depth":2,"bounds":{"left":0.63231385,"top":0.31524342,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Show Details","depth":2,"bounds":{"left":0.64328456,"top":0.31524342,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand All","depth":2,"bounds":{"left":0.72539896,"top":0.31524342,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":2,"bounds":{"left":0.7340425,"top":0.31524342,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5 files, folder","depth":4,"bounds":{"left":0.6203458,"top":0.33838788,"width":0.022273935,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app 3 files, folder","depth":5,"bounds":{"left":0.62666225,"top":0.35594574,"width":0.03025266,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Exceptions 1 file, folder","depth":6,"bounds":{"left":0.63297874,"top":0.3735036,"width":0.041888297,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException.php, exception class","depth":7,"bounds":{"left":0.6392952,"top":0.39106146,"width":0.05651596,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Jobs/Crm 1 file, folder","depth":6,"bounds":{"left":0.63297874,"top":0.4086193,"width":0.03956117,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"MatchActivityCrmData.php, class","depth":7,"bounds":{"left":0.6392952,"top":0.42617717,"width":0.06349734,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Services/Crm/Hubspot 1 file, folder","depth":6,"bounds":{"left":0.63297874,"top":0.44373503,"width":0.06615692,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Client.php, class","depth":7,"bounds":{"left":0.6392952,"top":0.4612929,"width":0.028922873,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit 2 files, folder","depth":5,"bounds":{"left":0.62666225,"top":0.47885075,"width":0.04255319,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Jobs/Middleware 1 file, folder","depth":6,"bounds":{"left":0.63297874,"top":0.4964086,"width":0.054853722,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php, class","depth":7,"bounds":{"left":0.6392952,"top":0.5139665,"width":0.07712766,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Services/Crm/Hubspot 1 file, folder","depth":6,"bounds":{"left":0.63297874,"top":0.53152436,"width":0.06615692,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClientTest.php, class","depth":7,"bounds":{"left":0.6392952,"top":0.5490822,"width":0.038231384,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app 3 files, folder","depth":4,"bounds":{"left":0.62666225,"top":0.35594574,"width":0.03025266,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Exceptions 1 file, folder","depth":5,"bounds":{"left":0.63297874,"top":0.3735036,"width":0.041888297,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException.php, exception class","depth":6,"bounds":{"left":0.6392952,"top":0.39106146,"width":0.05651596,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Jobs/Crm 1 file, folder","depth":5,"bounds":{"left":0.63297874,"top":0.4086193,"width":0.03956117,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"MatchActivityCrmData.php, class","depth":6,"bounds":{"left":0.6392952,"top":0.42617717,"width":0.06349734,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Services/Crm/Hubspot 1 file, folder","depth":5,"bounds":{"left":0.63297874,"top":0.44373503,"width":0.06615692,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Client.php, class","depth":6,"bounds":{"left":0.6392952,"top":0.4612929,"width":0.028922873,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Exceptions 1 file, folder","depth":4,"bounds":{"left":0.63297874,"top":0.3735036,"width":0.041888297,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException.php, exception class","depth":5,"bounds":{"left":0.6392952,"top":0.39106146,"width":0.05651596,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException.php, exception class","depth":4,"bounds":{"left":0.6392952,"top":0.39106146,"width":0.05651596,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Jobs/Crm 1 file, folder","depth":4,"bounds":{"left":0.63297874,"top":0.4086193,"width":0.03956117,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"MatchActivityCrmData.php, class","depth":5,"bounds":{"left":0.6392952,"top":0.42617717,"width":0.06349734,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"MatchActivityCrmData.php, class","depth":4,"bounds":{"left":0.6392952,"top":0.42617717,"width":0.06349734,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Services/Crm/Hubspot 1 file, folder","depth":4,"bounds":{"left":0.63297874,"top":0.44373503,"width":0.06615692,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Client.php, class","depth":5,"bounds":{"left":0.6392952,"top":0.4612929,"width":0.028922873,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Client.php, class","depth":4,"bounds":{"left":0.6392952,"top":0.4612929,"width":0.028922873,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit 2 files, folder","depth":4,"bounds":{"left":0.62666225,"top":0.47885075,"width":0.04255319,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Jobs/Middleware 1 file, folder","depth":5,"bounds":{"left":0.63297874,"top":0.4964086,"width":0.054853722,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php, class","depth":6,"bounds":{"left":0.6392952,"top":0.5139665,"width":0.07712766,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Services/Crm/Hubspot 1 file, folder","depth":5,"bounds":{"left":0.63297874,"top":0.53152436,"width":0.06615692,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClientTest.php, class","depth":6,"bounds":{"left":0.6392952,"top":0.5490822,"width":0.038231384,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Jobs/Middleware 1 file, folder","depth":4,"bounds":{"left":0.63297874,"top":0.4964086,"width":0.054853722,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php, class","depth":5,"bounds":{"left":0.6392952,"top":0.5139665,"width":0.07712766,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php, class","depth":4,"bounds":{"left":0.6392952,"top":0.5139665,"width":0.07712766,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Services/Crm/Hubspot 1 file, folder","depth":4,"bounds":{"left":0.63297874,"top":0.53152436,"width":0.06615692,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClientTest.php, class","depth":5,"bounds":{"left":0.6392952,"top":0.5490822,"width":0.038231384,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClientTest.php, class","depth":4,"bounds":{"left":0.6392952,"top":0.5490822,"width":0.038231384,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"For Commit and Push to non-protected branches, preview commits before push","depth":1,"bounds":{"left":0.48304522,"top":0.6783719,"width":0.17287233,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Help","depth":1,"bounds":{"left":0.4820479,"top":0.7047087,"width":0.00930851,"height":0.027134877},"on_screen":true,"help_text":"Show help contents","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Push tags:","depth":1,"bounds":{"left":0.49800533,"top":0.70790106,"width":0.031914894,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"All","depth":6,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Current Branch","depth":6,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Cancel","depth":1,"bounds":{"left":0.67586434,"top":0.7047087,"width":0.025930852,"height":0.027134877},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Push","depth":1,"bounds":{"left":0.7037899,"top":0.7047087,"width":0.036236703,"height":0.027134877},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Push","depth":2,"bounds":{"left":0.7037899,"top":0.7047087,"width":0.025930852,"height":0.027134877},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
908261819055477944
|
7714149861232519316
|
visual_change
|
accessibility
|
NULL
|
JY-20725-handle-HS-search-rate-limit → origin : JY JY-20725-handle-HS-search-rate-limit → origin : JY-20725-handle-HS-search-rate-limit
JY-20725 code review suggestions
Show Diff
Group By
Jump to Source
Show Details
Expand All
Collapse All
5 files, folder
app 3 files, folder
Exceptions 1 file, folder
RateLimitException.php, exception class
Jobs/Crm 1 file, folder
MatchActivityCrmData.php, class
Services/Crm/Hubspot 1 file, folder
Client.php, class
tests/Unit 2 files, folder
Jobs/Middleware 1 file, folder
HandleHubspotRateLimitTest.php, class
Services/Crm/Hubspot 1 file, folder
ClientTest.php, class
app 3 files, folder
Exceptions 1 file, folder
RateLimitException.php, exception class
Jobs/Crm 1 file, folder
MatchActivityCrmData.php, class
Services/Crm/Hubspot 1 file, folder
Client.php, class
Exceptions 1 file, folder
RateLimitException.php, exception class
RateLimitException.php, exception class
Jobs/Crm 1 file, folder
MatchActivityCrmData.php, class
MatchActivityCrmData.php, class
Services/Crm/Hubspot 1 file, folder
Client.php, class
Client.php, class
tests/Unit 2 files, folder
Jobs/Middleware 1 file, folder
HandleHubspotRateLimitTest.php, class
Services/Crm/Hubspot 1 file, folder
ClientTest.php, class
Jobs/Middleware 1 file, folder
HandleHubspotRateLimitTest.php, class
HandleHubspotRateLimitTest.php, class
Services/Crm/Hubspot 1 file, folder
ClientTest.php, class
ClientTest.php, class
For Commit and Push to non-protected branches, preview commits before push
Help
Push tags:
All
Current Branch
Cancel
Push
Push...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20634
|
897
|
10
|
2026-05-11T16:02:54.479401+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515374479_m2.jpg...
|
PhpStorm
|
Push Commits to app
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
JY-20725-handle-HS-search-rate-limit → origin : JY JY-20725-handle-HS-search-rate-limit → origin : JY-20725-handle-HS-search-rate-limit
JY-20725 code review suggestions
Show Diff
Group By
Jump to Source
Show Details
Expand All
Collapse All
5 files, folder
app 3 files, folder
Exceptions 1 file, folder
RateLimitException.php, exception class
Jobs/Crm 1 file, folder
MatchActivityCrmData.php, class
Services/Crm/Hubspot 1 file, folder
Client.php, class
tests/Unit 2 files, folder
Jobs/Middleware 1 file, folder
HandleHubspotRateLimitTest.php, class
Services/Crm/Hubspot 1 file, folder
ClientTest.php, class
app 3 files, folder
Exceptions 1 file, folder
RateLimitException.php, exception class
Jobs/Crm 1 file, folder
MatchActivityCrmData.php, class
Services/Crm/Hubspot 1 file, folder
Client.php, class
Exceptions 1 file, folder
RateLimitException.php, exception class
RateLimitException.php, exception class
Jobs/Crm 1 file, folder
MatchActivityCrmData.php, class
MatchActivityCrmData.php, class
Services/Crm/Hubspot 1 file, folder
Client.php, class
Client.php, class
tests/Unit 2 files, folder
Jobs/Middleware 1 file, folder
HandleHubspotRateLimitTest.php, class
Services/Crm/Hubspot 1 file, folder
ClientTest.php, class
Jobs/Middleware 1 file, folder
HandleHubspotRateLimitTest.php, class
HandleHubspotRateLimitTest.php, class
Services/Crm/Hubspot 1 file, folder
ClientTest.php, class
ClientTest.php, class
For Commit and Push to non-protected branches, preview commits before push
Help
Push tags:
All
Current Branch
Cancel
Push
Push
Push Commits to app...
|
[{"role":"AXStaticText","text& [{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit → origin : JY-20725-handle-HS-search-rate-limit","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.12699468,"height":0.0},"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 code review suggestions","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.12699468,"height":0.0},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Show Diff","depth":2,"bounds":{"left":0.61269945,"top":0.31524342,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Group By","depth":2,"bounds":{"left":0.6236702,"top":0.31524342,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Jump to Source","depth":2,"bounds":{"left":0.63231385,"top":0.31524342,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Show Details","depth":2,"bounds":{"left":0.64328456,"top":0.31524342,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand All","depth":2,"bounds":{"left":0.72539896,"top":0.31524342,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":2,"bounds":{"left":0.7340425,"top":0.31524342,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5 files, folder","depth":4,"bounds":{"left":0.6203458,"top":0.33838788,"width":0.022273935,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app 3 files, folder","depth":5,"bounds":{"left":0.62666225,"top":0.35594574,"width":0.03025266,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Exceptions 1 file, folder","depth":6,"bounds":{"left":0.63297874,"top":0.3735036,"width":0.041888297,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException.php, exception class","depth":7,"bounds":{"left":0.6392952,"top":0.39106146,"width":0.05651596,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Jobs/Crm 1 file, folder","depth":6,"bounds":{"left":0.63297874,"top":0.4086193,"width":0.03956117,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"MatchActivityCrmData.php, class","depth":7,"bounds":{"left":0.6392952,"top":0.42617717,"width":0.06349734,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Services/Crm/Hubspot 1 file, folder","depth":6,"bounds":{"left":0.63297874,"top":0.44373503,"width":0.06615692,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Client.php, class","depth":7,"bounds":{"left":0.6392952,"top":0.4612929,"width":0.028922873,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit 2 files, folder","depth":5,"bounds":{"left":0.62666225,"top":0.47885075,"width":0.04255319,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Jobs/Middleware 1 file, folder","depth":6,"bounds":{"left":0.63297874,"top":0.4964086,"width":0.054853722,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php, class","depth":7,"bounds":{"left":0.6392952,"top":0.5139665,"width":0.07712766,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Services/Crm/Hubspot 1 file, folder","depth":6,"bounds":{"left":0.63297874,"top":0.53152436,"width":0.06615692,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClientTest.php, class","depth":7,"bounds":{"left":0.6392952,"top":0.5490822,"width":0.038231384,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app 3 files, folder","depth":4,"bounds":{"left":0.62666225,"top":0.35594574,"width":0.03025266,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Exceptions 1 file, folder","depth":5,"bounds":{"left":0.63297874,"top":0.3735036,"width":0.041888297,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException.php, exception class","depth":6,"bounds":{"left":0.6392952,"top":0.39106146,"width":0.05651596,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Jobs/Crm 1 file, folder","depth":5,"bounds":{"left":0.63297874,"top":0.4086193,"width":0.03956117,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"MatchActivityCrmData.php, class","depth":6,"bounds":{"left":0.6392952,"top":0.42617717,"width":0.06349734,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Services/Crm/Hubspot 1 file, folder","depth":5,"bounds":{"left":0.63297874,"top":0.44373503,"width":0.06615692,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Client.php, class","depth":6,"bounds":{"left":0.6392952,"top":0.4612929,"width":0.028922873,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Exceptions 1 file, folder","depth":4,"bounds":{"left":0.63297874,"top":0.3735036,"width":0.041888297,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException.php, exception class","depth":5,"bounds":{"left":0.6392952,"top":0.39106146,"width":0.05651596,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException.php, exception class","depth":4,"bounds":{"left":0.6392952,"top":0.39106146,"width":0.05651596,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Jobs/Crm 1 file, folder","depth":4,"bounds":{"left":0.63297874,"top":0.4086193,"width":0.03956117,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"MatchActivityCrmData.php, class","depth":5,"bounds":{"left":0.6392952,"top":0.42617717,"width":0.06349734,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"MatchActivityCrmData.php, class","depth":4,"bounds":{"left":0.6392952,"top":0.42617717,"width":0.06349734,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Services/Crm/Hubspot 1 file, folder","depth":4,"bounds":{"left":0.63297874,"top":0.44373503,"width":0.06615692,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Client.php, class","depth":5,"bounds":{"left":0.6392952,"top":0.4612929,"width":0.028922873,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Client.php, class","depth":4,"bounds":{"left":0.6392952,"top":0.4612929,"width":0.028922873,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit 2 files, folder","depth":4,"bounds":{"left":0.62666225,"top":0.47885075,"width":0.04255319,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Jobs/Middleware 1 file, folder","depth":5,"bounds":{"left":0.63297874,"top":0.4964086,"width":0.054853722,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php, class","depth":6,"bounds":{"left":0.6392952,"top":0.5139665,"width":0.07712766,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Services/Crm/Hubspot 1 file, folder","depth":5,"bounds":{"left":0.63297874,"top":0.53152436,"width":0.06615692,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClientTest.php, class","depth":6,"bounds":{"left":0.6392952,"top":0.5490822,"width":0.038231384,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Jobs/Middleware 1 file, folder","depth":4,"bounds":{"left":0.63297874,"top":0.4964086,"width":0.054853722,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php, class","depth":5,"bounds":{"left":0.6392952,"top":0.5139665,"width":0.07712766,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php, class","depth":4,"bounds":{"left":0.6392952,"top":0.5139665,"width":0.07712766,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Services/Crm/Hubspot 1 file, folder","depth":4,"bounds":{"left":0.63297874,"top":0.53152436,"width":0.06615692,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClientTest.php, class","depth":5,"bounds":{"left":0.6392952,"top":0.5490822,"width":0.038231384,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClientTest.php, class","depth":4,"bounds":{"left":0.6392952,"top":0.5490822,"width":0.038231384,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"For Commit and Push to non-protected branches, preview commits before push","depth":1,"bounds":{"left":0.48304522,"top":0.6783719,"width":0.17287233,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Help","depth":1,"bounds":{"left":0.4820479,"top":0.7047087,"width":0.00930851,"height":0.027134877},"on_screen":true,"help_text":"Show help contents","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Push tags:","depth":1,"bounds":{"left":0.49800533,"top":0.70790106,"width":0.031914894,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"All","depth":6,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Current Branch","depth":6,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Cancel","depth":1,"bounds":{"left":0.67586434,"top":0.7047087,"width":0.025930852,"height":0.027134877},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Push","depth":1,"bounds":{"left":0.7037899,"top":0.7047087,"width":0.036236703,"height":0.027134877},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Push","depth":2,"bounds":{"left":0.7037899,"top":0.7047087,"width":0.025930852,"height":0.027134877},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Push Commits to app","depth":1,"bounds":{"left":0.5874335,"top":0.29449323,"width":0.04720745,"height":0.012769354},"on_screen":true,"role_description":"text"}]...
|
3774403039249210927
|
7714290598720874644
|
visual_change
|
accessibility
|
NULL
|
JY-20725-handle-HS-search-rate-limit → origin : JY JY-20725-handle-HS-search-rate-limit → origin : JY-20725-handle-HS-search-rate-limit
JY-20725 code review suggestions
Show Diff
Group By
Jump to Source
Show Details
Expand All
Collapse All
5 files, folder
app 3 files, folder
Exceptions 1 file, folder
RateLimitException.php, exception class
Jobs/Crm 1 file, folder
MatchActivityCrmData.php, class
Services/Crm/Hubspot 1 file, folder
Client.php, class
tests/Unit 2 files, folder
Jobs/Middleware 1 file, folder
HandleHubspotRateLimitTest.php, class
Services/Crm/Hubspot 1 file, folder
ClientTest.php, class
app 3 files, folder
Exceptions 1 file, folder
RateLimitException.php, exception class
Jobs/Crm 1 file, folder
MatchActivityCrmData.php, class
Services/Crm/Hubspot 1 file, folder
Client.php, class
Exceptions 1 file, folder
RateLimitException.php, exception class
RateLimitException.php, exception class
Jobs/Crm 1 file, folder
MatchActivityCrmData.php, class
MatchActivityCrmData.php, class
Services/Crm/Hubspot 1 file, folder
Client.php, class
Client.php, class
tests/Unit 2 files, folder
Jobs/Middleware 1 file, folder
HandleHubspotRateLimitTest.php, class
Services/Crm/Hubspot 1 file, folder
ClientTest.php, class
Jobs/Middleware 1 file, folder
HandleHubspotRateLimitTest.php, class
HandleHubspotRateLimitTest.php, class
Services/Crm/Hubspot 1 file, folder
ClientTest.php, class
ClientTest.php, class
For Commit and Push to non-protected branches, preview commits before push
Help
Push tags:
All
Current Branch
Cancel
Push
Push
Push Commits to app...
|
20633
|
NULL
|
NULL
|
NULL
|
|
20637
|
897
|
12
|
2026-05-11T16:03:25.834278+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515405834_m2.jpg...
|
PhpStorm
|
faVsco.js – HandleHubspotRateLimitTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Writing objects: 100% (21/21), 3.02 KiB | 3.02 MiB Writing objects: 100% (21/21), 3.02 KiB | 3.02 MiB/s, done.
5 files committed
JY-20725 code review suggestions
text/html
text/html
text/html
Edit Commit Message…
Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
declare(strict_types=1);
namespace Tests\Unit\Jobs\Middleware;
use Exception;
use Illuminate\Contracts\Queue\Job;
use Illuminate\Support\Facades\Log;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
#[CoversClass(HandleHubspotRateLimit::class)]
class HandleHubspotRateLimitTest extends TestCase
{
private HandleHubspotRateLimit $middleware;
protected function setUp(): void
{
parent::setUp();
$this->middleware = new HandleHubspotRateLimit();
}
public function testPassesThroughWhenNoExceptionThrown(): void
{
$job = $this->createMock(Job::class);
$job->expects($this->never())->method('release');
$called = false;
$next = function (object $passed) use ($job, &$called): void {
$this->assertSame($job, $passed);
$called = true;
};
$this->middleware->handle($job, $next);
$this->assertTrue($called);
}
public function testPropagatesNonRateLimitExceptions(): void
{
$job = $this->createMock(Job::class);
$job->expects($this->never())->method('release');
$next = static function (): void {
throw new Exception('Database is down');
};
$this->expectException(Exception::class);
$this->expectExceptionMessage('Database is down');
$this->middleware->handle($job, $next);
}
/**
* @return array<string, array{retryAfter: int, expectedMin: int, expectedMax: int}>
*/
public static function delayClampingProvider(): array
{
return [
'short retry passes through' => [
'retryAfter' => 1,
'expectedMin' => 1,
'expectedMax' => 6, // 1 + 5 jitter
],
'medium retry passes through' => [
'retryAfter' => 30,
'expectedMin' => 30,
'expectedMax' => 35, // 30 + 5 jitter
],
'large retry clamped to 600s max' => [
'retryAfter' => 86400,
'expectedMin' => 600,
'expectedMax' => 605, // 600 + 5 jitter
],
];
}
#[DataProvider('delayClampingProvider')]
public function testReleasesJobWithClampedDelay(int $retryAfter, int $expectedMin, int $expectedMax): void
{
Log::shouldReceive('info')->zeroOrMoreTimes();
/** @var Job&MockObject $job */
$job = $this->createMock(Job::class);
$job->method('attempts')->willReturn(1);
$job->expects($this->once())
->method('release')
->with($this->callback(static function (int $delay) use ($expectedMin, $expectedMax): bool {
return $delay >= $expectedMin && $delay <= $expectedMax;
}));
$next = static function () use ($retryAfter): void {
throw new RateLimitException('rate limited', $retryAfter);
};
$this->middleware->handle($job, $next);
}
/**
* @return array<string, array{attempts: int, shouldLog: bool}>
*/
public static function logSamplingProvider(): array
{
return [
'first attempt logs' => ['attempts' => 1, 'shouldLog' => true],
'second attempt logs' => ['attempts' => 2, 'shouldLog' => true],
'third attempt logs' => ['attempts' => 3, 'shouldLog' => true],
'fourth attempt skipped' => ['attempts' => 4, 'shouldLog' => false],
'ninth attempt skipped' => ['attempts' => 9, 'shouldLog' => false],
'tenth attempt logs (multiple of 10)' => ['attempts' => 10, 'shouldLog' => true],
'eleventh attempt skipped' => ['attempts' => 11, 'shouldLog' => false],
'twentieth attempt logs' => ['attempts' => 20, 'shouldLog' => true],
];
}
#[DataProvider('logSamplingProvider')]
public function testLogSampling(int $attempts, bool $shouldLog): void
{
if ($shouldLog) {
Log::shouldReceive('info')
->once()
->with(
'[HandleHubspotRateLimit] Rate limit caught, releasing job with delay',
$this->callback(static function (array $context) use ($attempts): bool {
return $context['attempts'] === $attempts
&& $context['retry_after'] === 1
&& isset($context['delay']);
})
);
} else {
Log::shouldReceive('info')->never();
}
/** @var Job&MockObject $job */
$job = $this->createMock(Job::class);
$job->method('attempts')->willReturn($attempts);
$job->expects($this->once())->method('release');
$next = static function (): void {
throw new RateLimitException('rate limited', 1);
};
$this->middleware->handle($job, $next);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXStaticText","text& [{"role":"AXStaticText","text":"","depth":2,"bounds":{"left":0.8976064,"top":0.36951315,"width":0.06948138,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Writing objects: 100% (21/21), 3.02 KiB | 3.02 MiB/s, done.","depth":2,"bounds":{"left":0.8976064,"top":0.40143654,"width":0.06948138,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"5 files committed","depth":2,"bounds":{"left":0.8753325,"top":0.91300875,"width":0.100398935,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXTextField","text":"JY-20725 code review suggestions","depth":3,"bounds":{"left":0.8753325,"top":0.92897046,"width":0.11037234,"height":0.013567438},"on_screen":true,"value":"JY-20725 code review suggestions","help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"bounds":{"left":0.8753325,"top":0.92897046,"width":0.07247341,"height":0.013567438},"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Edit Commit Message…","depth":2,"bounds":{"left":0.8753325,"top":0.9481245,"width":0.048204787,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.10139628,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit<br/>1 outgoing commit<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.82413566,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.8394282,"top":0.019952115,"width":0.076130316,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Jobs\\Middleware;\n\nuse Exception;\nuse Illuminate\\Contracts\\Queue\\Job;\nuse Illuminate\\Support\\Facades\\Log;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Jobs\\Middleware\\HandleHubspotRateLimit;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse PHPUnit\\Framework\\Attributes\\DataProvider;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\n\n#[CoversClass(HandleHubspotRateLimit::class)]\nclass HandleHubspotRateLimitTest extends TestCase\n{\n private HandleHubspotRateLimit $middleware;\n\n protected function setUp(): void\n {\n parent::setUp();\n\n $this->middleware = new HandleHubspotRateLimit();\n }\n\n public function testPassesThroughWhenNoExceptionThrown(): void\n {\n $job = $this->createMock(Job::class);\n $job->expects($this->never())->method('release');\n\n $called = false;\n $next = function (object $passed) use ($job, &$called): void {\n $this->assertSame($job, $passed);\n $called = true;\n };\n\n $this->middleware->handle($job, $next);\n\n $this->assertTrue($called);\n }\n\n public function testPropagatesNonRateLimitExceptions(): void\n {\n $job = $this->createMock(Job::class);\n $job->expects($this->never())->method('release');\n\n $next = static function (): void {\n throw new Exception('Database is down');\n };\n\n $this->expectException(Exception::class);\n $this->expectExceptionMessage('Database is down');\n\n $this->middleware->handle($job, $next);\n }\n\n /**\n * @return array<string, array{retryAfter: int, expectedMin: int, expectedMax: int}>\n */\n public static function delayClampingProvider(): array\n {\n return [\n 'short retry passes through' => [\n 'retryAfter' => 1,\n 'expectedMin' => 1,\n 'expectedMax' => 6, // 1 + 5 jitter\n ],\n 'medium retry passes through' => [\n 'retryAfter' => 30,\n 'expectedMin' => 30,\n 'expectedMax' => 35, // 30 + 5 jitter\n ],\n 'large retry clamped to 600s max' => [\n 'retryAfter' => 86400,\n 'expectedMin' => 600,\n 'expectedMax' => 605, // 600 + 5 jitter\n ],\n ];\n }\n\n #[DataProvider('delayClampingProvider')]\n public function testReleasesJobWithClampedDelay(int $retryAfter, int $expectedMin, int $expectedMax): void\n {\n Log::shouldReceive('info')->zeroOrMoreTimes();\n\n /** @var Job&MockObject $job */\n $job = $this->createMock(Job::class);\n $job->method('attempts')->willReturn(1);\n $job->expects($this->once())\n ->method('release')\n ->with($this->callback(static function (int $delay) use ($expectedMin, $expectedMax): bool {\n return $delay >= $expectedMin && $delay <= $expectedMax;\n }));\n\n $next = static function () use ($retryAfter): void {\n throw new RateLimitException('rate limited', $retryAfter);\n };\n\n $this->middleware->handle($job, $next);\n }\n\n /**\n * @return array<string, array{attempts: int, shouldLog: bool}>\n */\n public static function logSamplingProvider(): array\n {\n return [\n 'first attempt logs' => ['attempts' => 1, 'shouldLog' => true],\n 'second attempt logs' => ['attempts' => 2, 'shouldLog' => true],\n 'third attempt logs' => ['attempts' => 3, 'shouldLog' => true],\n 'fourth attempt skipped' => ['attempts' => 4, 'shouldLog' => false],\n 'ninth attempt skipped' => ['attempts' => 9, 'shouldLog' => false],\n 'tenth attempt logs (multiple of 10)' => ['attempts' => 10, 'shouldLog' => true],\n 'eleventh attempt skipped' => ['attempts' => 11, 'shouldLog' => false],\n 'twentieth attempt logs' => ['attempts' => 20, 'shouldLog' => true],\n ];\n }\n\n #[DataProvider('logSamplingProvider')]\n public function testLogSampling(int $attempts, bool $shouldLog): void\n {\n if ($shouldLog) {\n Log::shouldReceive('info')\n ->once()\n ->with(\n '[HandleHubspotRateLimit] Rate limit caught, releasing job with delay',\n $this->callback(static function (array $context) use ($attempts): bool {\n return $context['attempts'] === $attempts\n && $context['retry_after'] === 1\n && isset($context['delay']);\n })\n );\n } else {\n Log::shouldReceive('info')->never();\n }\n\n /** @var Job&MockObject $job */\n $job = $this->createMock(Job::class);\n $job->method('attempts')->willReturn($attempts);\n $job->expects($this->once())->method('release');\n\n $next = static function (): void {\n throw new RateLimitException('rate limited', 1);\n };\n\n $this->middleware->handle($job, $next);\n }\n}","depth":4,"bounds":{"left":0.122340426,"top":0.17158818,"width":0.43085107,"height":0.8284118},"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Jobs\\Middleware;\n\nuse Exception;\nuse Illuminate\\Contracts\\Queue\\Job;\nuse Illuminate\\Support\\Facades\\Log;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Jobs\\Middleware\\HandleHubspotRateLimit;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse PHPUnit\\Framework\\Attributes\\DataProvider;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\n\n#[CoversClass(HandleHubspotRateLimit::class)]\nclass HandleHubspotRateLimitTest extends TestCase\n{\n private HandleHubspotRateLimit $middleware;\n\n protected function setUp(): void\n {\n parent::setUp();\n\n $this->middleware = new HandleHubspotRateLimit();\n }\n\n public function testPassesThroughWhenNoExceptionThrown(): void\n {\n $job = $this->createMock(Job::class);\n $job->expects($this->never())->method('release');\n\n $called = false;\n $next = function (object $passed) use ($job, &$called): void {\n $this->assertSame($job, $passed);\n $called = true;\n };\n\n $this->middleware->handle($job, $next);\n\n $this->assertTrue($called);\n }\n\n public function testPropagatesNonRateLimitExceptions(): void\n {\n $job = $this->createMock(Job::class);\n $job->expects($this->never())->method('release');\n\n $next = static function (): void {\n throw new Exception('Database is down');\n };\n\n $this->expectException(Exception::class);\n $this->expectExceptionMessage('Database is down');\n\n $this->middleware->handle($job, $next);\n }\n\n /**\n * @return array<string, array{retryAfter: int, expectedMin: int, expectedMax: int}>\n */\n public static function delayClampingProvider(): array\n {\n return [\n 'short retry passes through' => [\n 'retryAfter' => 1,\n 'expectedMin' => 1,\n 'expectedMax' => 6, // 1 + 5 jitter\n ],\n 'medium retry passes through' => [\n 'retryAfter' => 30,\n 'expectedMin' => 30,\n 'expectedMax' => 35, // 30 + 5 jitter\n ],\n 'large retry clamped to 600s max' => [\n 'retryAfter' => 86400,\n 'expectedMin' => 600,\n 'expectedMax' => 605, // 600 + 5 jitter\n ],\n ];\n }\n\n #[DataProvider('delayClampingProvider')]\n public function testReleasesJobWithClampedDelay(int $retryAfter, int $expectedMin, int $expectedMax): void\n {\n Log::shouldReceive('info')->zeroOrMoreTimes();\n\n /** @var Job&MockObject $job */\n $job = $this->createMock(Job::class);\n $job->method('attempts')->willReturn(1);\n $job->expects($this->once())\n ->method('release')\n ->with($this->callback(static function (int $delay) use ($expectedMin, $expectedMax): bool {\n return $delay >= $expectedMin && $delay <= $expectedMax;\n }));\n\n $next = static function () use ($retryAfter): void {\n throw new RateLimitException('rate limited', $retryAfter);\n };\n\n $this->middleware->handle($job, $next);\n }\n\n /**\n * @return array<string, array{attempts: int, shouldLog: bool}>\n */\n public static function logSamplingProvider(): array\n {\n return [\n 'first attempt logs' => ['attempts' => 1, 'shouldLog' => true],\n 'second attempt logs' => ['attempts' => 2, 'shouldLog' => true],\n 'third attempt logs' => ['attempts' => 3, 'shouldLog' => true],\n 'fourth attempt skipped' => ['attempts' => 4, 'shouldLog' => false],\n 'ninth attempt skipped' => ['attempts' => 9, 'shouldLog' => false],\n 'tenth attempt logs (multiple of 10)' => ['attempts' => 10, 'shouldLog' => true],\n 'eleventh attempt skipped' => ['attempts' => 11, 'shouldLog' => false],\n 'twentieth attempt logs' => ['attempts' => 20, 'shouldLog' => true],\n ];\n }\n\n #[DataProvider('logSamplingProvider')]\n public function testLogSampling(int $attempts, bool $shouldLog): void\n {\n if ($shouldLog) {\n Log::shouldReceive('info')\n ->once()\n ->with(\n '[HandleHubspotRateLimit] Rate limit caught, releasing job with delay',\n $this->callback(static function (array $context) use ($attempts): bool {\n return $context['attempts'] === $attempts\n && $context['retry_after'] === 1\n && isset($context['delay']);\n })\n );\n } else {\n Log::shouldReceive('info')->never();\n }\n\n /** @var Job&MockObject $job */\n $job = $this->createMock(Job::class);\n $job->method('attempts')->willReturn($attempts);\n $job->expects($this->once())->method('release');\n\n $next = static function (): void {\n throw new RateLimitException('rate limited', 1);\n };\n\n $this->middleware->handle($job, $next);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.96276593,"top":0.07581804,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.9740692,"top":0.074221864,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.98138297,"top":0.074221864,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-2899521596097960954
|
-7141563580575294774
|
visual_change
|
accessibility
|
NULL
|
Writing objects: 100% (21/21), 3.02 KiB | 3.02 MiB Writing objects: 100% (21/21), 3.02 KiB | 3.02 MiB/s, done.
5 files committed
JY-20725 code review suggestions
text/html
text/html
text/html
Edit Commit Message…
Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
declare(strict_types=1);
namespace Tests\Unit\Jobs\Middleware;
use Exception;
use Illuminate\Contracts\Queue\Job;
use Illuminate\Support\Facades\Log;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
#[CoversClass(HandleHubspotRateLimit::class)]
class HandleHubspotRateLimitTest extends TestCase
{
private HandleHubspotRateLimit $middleware;
protected function setUp(): void
{
parent::setUp();
$this->middleware = new HandleHubspotRateLimit();
}
public function testPassesThroughWhenNoExceptionThrown(): void
{
$job = $this->createMock(Job::class);
$job->expects($this->never())->method('release');
$called = false;
$next = function (object $passed) use ($job, &$called): void {
$this->assertSame($job, $passed);
$called = true;
};
$this->middleware->handle($job, $next);
$this->assertTrue($called);
}
public function testPropagatesNonRateLimitExceptions(): void
{
$job = $this->createMock(Job::class);
$job->expects($this->never())->method('release');
$next = static function (): void {
throw new Exception('Database is down');
};
$this->expectException(Exception::class);
$this->expectExceptionMessage('Database is down');
$this->middleware->handle($job, $next);
}
/**
* @return array<string, array{retryAfter: int, expectedMin: int, expectedMax: int}>
*/
public static function delayClampingProvider(): array
{
return [
'short retry passes through' => [
'retryAfter' => 1,
'expectedMin' => 1,
'expectedMax' => 6, // 1 + 5 jitter
],
'medium retry passes through' => [
'retryAfter' => 30,
'expectedMin' => 30,
'expectedMax' => 35, // 30 + 5 jitter
],
'large retry clamped to 600s max' => [
'retryAfter' => 86400,
'expectedMin' => 600,
'expectedMax' => 605, // 600 + 5 jitter
],
];
}
#[DataProvider('delayClampingProvider')]
public function testReleasesJobWithClampedDelay(int $retryAfter, int $expectedMin, int $expectedMax): void
{
Log::shouldReceive('info')->zeroOrMoreTimes();
/** @var Job&MockObject $job */
$job = $this->createMock(Job::class);
$job->method('attempts')->willReturn(1);
$job->expects($this->once())
->method('release')
->with($this->callback(static function (int $delay) use ($expectedMin, $expectedMax): bool {
return $delay >= $expectedMin && $delay <= $expectedMax;
}));
$next = static function () use ($retryAfter): void {
throw new RateLimitException('rate limited', $retryAfter);
};
$this->middleware->handle($job, $next);
}
/**
* @return array<string, array{attempts: int, shouldLog: bool}>
*/
public static function logSamplingProvider(): array
{
return [
'first attempt logs' => ['attempts' => 1, 'shouldLog' => true],
'second attempt logs' => ['attempts' => 2, 'shouldLog' => true],
'third attempt logs' => ['attempts' => 3, 'shouldLog' => true],
'fourth attempt skipped' => ['attempts' => 4, 'shouldLog' => false],
'ninth attempt skipped' => ['attempts' => 9, 'shouldLog' => false],
'tenth attempt logs (multiple of 10)' => ['attempts' => 10, 'shouldLog' => true],
'eleventh attempt skipped' => ['attempts' => 11, 'shouldLog' => false],
'twentieth attempt logs' => ['attempts' => 20, 'shouldLog' => true],
];
}
#[DataProvider('logSamplingProvider')]
public function testLogSampling(int $attempts, bool $shouldLog): void
{
if ($shouldLog) {
Log::shouldReceive('info')
->once()
->with(
'[HandleHubspotRateLimit] Rate limit caught, releasing job with delay',
$this->callback(static function (array $context) use ($attempts): bool {
return $context['attempts'] === $attempts
&& $context['retry_after'] === 1
&& isset($context['delay']);
})
);
} else {
Log::shouldReceive('info')->never();
}
/** @var Job&MockObject $job */
$job = $this->createMock(Job::class);
$job->method('attempts')->willReturn($attempts);
$job->expects($this->once())->method('release');
$next = static function (): void {
throw new RateLimitException('rate limited', 1);
};
$this->middleware->handle($job, $next);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
20636
|
NULL
|
NULL
|
NULL
|
|
20646
|
898
|
1
|
2026-05-11T16:05:46.319068+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515546319_m1.jpg...
|
Firefox
|
JY-20725 add HS rate limit handling on activities JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app — Work...
|
1
|
github.com/jiminny/app/pull/12066
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
New Tab
New Tab
Jy 20820 es reindex stream model h New Tab
New Tab
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS]
Show options
LakyLak commented 3 hours ago
LakyLak
LakyLak
commented
3 hours ago
3 hours ago
JIRA: JY-20725
JIRA:
JY-20725
JY-20725
Changes:
Changes:
Adds
HandleHubspotRateLimit
middleware (release jobs with delay)
Adds
executeRequest
wrapper in Hubspot Client. It handles 429 errors and throws
RateLimitException
Add or remove reactions
LakyLak
LakyLak
added
2
commits
3 hours ago
3 hours ago
@LakyLak
JY-20725
JY-20725
add HS rate limit handling on activities rematching
add HS rate limit handling on activities rematching
8 / 10 checks OK
44d5678
44d5678
@LakyLak
JY-20725
JY-20725
add tests
add tests
11 / 12 checks OK
cbed2c9
cbed2c9
@sonarqubecloud
Show options
sonarqubecloud Bot commented 2 hours ago
sonarqubecloud
sonarqubecloud
Bot
commented
2 hours ago
2 hours ago
Quality Gate Failed Quality Gate failed
Quality Gate Failed
Quality Gate failed
Failed conditions
34.4% Coverage on New Code
34.4% Coverage on New Code
(required ≥ 70%)
See analysis details on SonarQube Cloud
See analysis details on SonarQube Cloud
Add or remove reactions
@LakyLak
Show options
Author
LakyLak commented 2 hours ago
LakyLak
LakyLak
commented
2 hours ago
2 hours ago
@claude
@claude
Add or remove reactions
react with eyes
👀
1
@claude
Show options
claude Bot commented 2 hours ago •
claude
claude
Bot
commented
2 hours ago
2 hours ago
•
edited
edited
Claude finished
@LakyLak
@LakyLak
's task in 1m 42s
——
View job
View job
Code Review...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"New Tab","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"New Tab","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"Pipelines - jiminny/app","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pipelines - jiminny/app","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"New Tab","depth":4,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Tabs from other devices","depth":6,"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open history (⇧⌘H)","depth":6,"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open bookmarks (⌘B)","depth":6,"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Skip to content","depth":6,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Skip to content","depth":7,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open menu","depth":10,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Homepage (g then d)","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"jiminny","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"jiminny","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"app","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"app","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Search or jump to…","depth":9,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Type","depth":12,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":12,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"to search","depth":12,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Chat with Copilot","depth":10,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXMenuButton","text":"Open Copilot…","depth":9,"on_screen":false,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXMenuButton","text":"Create new...","depth":9,"on_screen":false,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"All issues(g then i)","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"All pull requests","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"All repositories","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"You have unread notifications(g then n)","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Open user navigation menu","depth":9,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Repository navigation","depth":9,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Repository navigation","depth":10,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Code","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Code","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Pull requests (34)","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pull requests","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"34","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Agents","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Agents","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Actions","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Actions","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Wiki","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Wiki","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Security and quality (4)","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Security and quality","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"4","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Insights","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Insights","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Settings","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Settings","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Important update","depth":10,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Important update","depth":11,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"On April 24 we'll start using GitHub Copilot interaction data for AI model training unless you opt out.","depth":10,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Review this update","depth":10,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Review this update","depth":11,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"and manage your preferences in your","depth":10,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"GitHub account settings","depth":10,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"GitHub account settings","depth":11,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":10,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Dismiss banner","depth":9,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"JY-20725 add HS rate limit handling on activities rematching #12066 Edit title","depth":13,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"#","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"12066","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Edit title","depth":14,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Checks pending","depth":13,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Checks pending","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Code","depth":13,"on_screen":false,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Code","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Open","depth":13,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"LakyLak","depth":15,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"wants to merge 4 commits into","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"master","depth":15,"on_screen":false,"role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"master","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"from","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"JY-20725-handle-HS-search-rate-limit","depth":16,"on_screen":false,"role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy head branch name to clipboard","depth":16,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Lines changed: 757 additions & 249 deletions","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Conversation (3)","depth":16,"on_screen":false,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"Conversation","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"3","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Commits (4)","depth":16,"on_screen":false,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Commits","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"4","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Checks (2)","depth":16,"on_screen":false,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Checks","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"2","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Files changed (12)","depth":16,"on_screen":false,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Files changed","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"12","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Open","depth":14,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"JY-20725 add HS rate limit handling on activities rematching #12066 LakyLak wants to merge 4 commits into master from JY-20725-handle-HS-search-rate-limit Copy head branch name to clipboard","depth":14,"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"JY-20725 add HS rate limit handling on activities rematching","depth":16,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"#","depth":16,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"12066","depth":16,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"LakyLak","depth":18,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":19,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"wants to merge 4 commits into","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"master","depth":18,"on_screen":true,"role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"master","depth":19,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"from","depth":19,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"JY-20725-handle-HS-search-rate-limit","depth":19,"on_screen":true,"role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit","depth":20,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy head branch name to clipboard","depth":19,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Conversation","depth":12,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Conversation","depth":13,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@LakyLak","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":15,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"LakyLak commented 3 hours ago","depth":14,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"LakyLak","depth":16,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"3 hours ago","depth":15,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"3 hours ago","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"JIRA: JY-20725","depth":16,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"JIRA:","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"JY-20725","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Changes:","depth":16,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Changes:","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Adds","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"HandleHubspotRateLimit","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"middleware (release jobs with delay)","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Adds","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"executeRequest","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"wrapper in Hubspot Client. It handles 429 errors and throws","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"RateLimitException","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add or remove reactions","depth":16,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"LakyLak","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"added","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"2","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commits","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"3 hours ago","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"3 hours ago","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@LakyLak","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"JY-20725","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"add HS rate limit handling on activities rematching","depth":14,"on_screen":false,"help_text":"JY-20725 add HS rate limit handling on activities rematching","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"add HS rate limit handling on activities rematching","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"8 / 10 checks OK","depth":14,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"44d5678","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"44d5678","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@LakyLak","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"JY-20725","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"add tests","depth":14,"on_screen":false,"help_text":"JY-20725 add tests","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"add tests","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"11 / 12 checks OK","depth":14,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"cbed2c9","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"cbed2c9","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@sonarqubecloud","depth":13,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":14,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"sonarqubecloud Bot commented 2 hours ago","depth":13,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"sonarqubecloud","depth":15,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"sonarqubecloud","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Bot","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"2 hours ago","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"2 hours ago","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Quality Gate Failed Quality Gate failed","depth":16,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"Quality Gate Failed","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Quality Gate failed","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Failed conditions","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"34.4% Coverage on New Code","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"34.4% Coverage on New Code","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(required ≥ 70%)","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"See analysis details on SonarQube Cloud","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"See analysis details on SonarQube Cloud","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add or remove reactions","depth":15,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"@LakyLak","depth":13,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":14,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Author","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"LakyLak commented 2 hours ago","depth":13,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"LakyLak","depth":15,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"2 hours ago","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"2 hours ago","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@claude","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"@claude","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add or remove reactions","depth":15,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"react with eyes","depth":14,"on_screen":false,"role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"👀","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@claude","depth":13,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":14,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"claude Bot commented 2 hours ago •","depth":13,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"claude","depth":15,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"claude","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Bot","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"2 hours ago","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"2 hours ago","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"•","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"edited","depth":16,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"edited","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Claude finished","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@LakyLak","depth":18,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"@LakyLak","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'s task in 1m 42s","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"——","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"View job","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"View job","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Code Review","depth":16,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"}]...
|
-752945386737387967
|
6918530618140689796
|
visual_change
|
accessibility
|
NULL
|
New Tab
New Tab
Jy 20820 es reindex stream model h New Tab
New Tab
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS]
Show options
LakyLak commented 3 hours ago
LakyLak
LakyLak
commented
3 hours ago
3 hours ago
JIRA: JY-20725
JIRA:
JY-20725
JY-20725
Changes:
Changes:
Adds
HandleHubspotRateLimit
middleware (release jobs with delay)
Adds
executeRequest
wrapper in Hubspot Client. It handles 429 errors and throws
RateLimitException
Add or remove reactions
LakyLak
LakyLak
added
2
commits
3 hours ago
3 hours ago
@LakyLak
JY-20725
JY-20725
add HS rate limit handling on activities rematching
add HS rate limit handling on activities rematching
8 / 10 checks OK
44d5678
44d5678
@LakyLak
JY-20725
JY-20725
add tests
add tests
11 / 12 checks OK
cbed2c9
cbed2c9
@sonarqubecloud
Show options
sonarqubecloud Bot commented 2 hours ago
sonarqubecloud
sonarqubecloud
Bot
commented
2 hours ago
2 hours ago
Quality Gate Failed Quality Gate failed
Quality Gate Failed
Quality Gate failed
Failed conditions
34.4% Coverage on New Code
34.4% Coverage on New Code
(required ≥ 70%)
See analysis details on SonarQube Cloud
See analysis details on SonarQube Cloud
Add or remove reactions
@LakyLak
Show options
Author
LakyLak commented 2 hours ago
LakyLak
LakyLak
commented
2 hours ago
2 hours ago
@claude
@claude
Add or remove reactions
react with eyes
👀
1
@claude
Show options
claude Bot commented 2 hours ago •
claude
claude
Bot
commented
2 hours ago
2 hours ago
•
edited
edited
Claude finished
@LakyLak
@LakyLak
's task in 1m 42s
——
View job
View job
Code Review...
|
20640
|
NULL
|
NULL
|
NULL
|
|
20647
|
899
|
1
|
2026-05-11T16:05:47.790080+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515547790_m2.jpg...
|
Firefox
|
JY-20725 add HS rate limit handling on activities JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app — Work...
|
1
|
github.com/jiminny/app/pull/12066
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
New Tab
New Tab
Jy 20820 es reindex stream model h New Tab
New Tab
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS]
Show options
LakyLak commented 3 hours ago
LakyLak
LakyLak
commented
3 hours ago
3 hours ago
JIRA: JY-20725
JIRA:
JY-20725
JY-20725
Changes:
Changes:
Adds
HandleHubspotRateLimit
middleware (release jobs with delay)
Adds
executeRequest
wrapper in Hubspot Client. It handles 429 errors and throws
RateLimitException
Add or remove reactions
LakyLak
LakyLak
added
2
commits
3 hours ago
3 hours ago
@LakyLak
JY-20725
JY-20725
add HS rate limit handling on activities rematching
add HS rate limit handling on activities rematching
8 / 10 checks OK
44d5678
44d5678
@LakyLak
JY-20725
JY-20725
add tests
add tests
11 / 12 checks OK
cbed2c9
cbed2c9
@sonarqubecloud
Show options
sonarqubecloud Bot commented 2 hours ago
sonarqubecloud
sonarqubecloud
Bot
commented
2 hours ago
2 hours ago
Quality Gate Failed Quality Gate failed
Quality Gate Failed
Quality Gate failed
Failed conditions
34.4% Coverage on New Code
34.4% Coverage on New Code
(required ≥ 70%)
See analysis details on SonarQube Cloud
See analysis details on SonarQube Cloud
Add or remove reactions
@LakyLak
Show options
Author
LakyLak commented 2 hours ago
LakyLak
LakyLak
commented
2 hours ago
2 hours ago
@claude
@claude
Add or remove reactions
react with eyes
👀
1
@claude
Show options
claude Bot commented 2 hours ago •
claude
claude
Bot
commented
2 hours ago
2 hours ago
•
edited
edited
Claude finished
@LakyLak
@LakyLak
's task in 1m 42s
——
View job
View job
Code Review
Code Review
Completed task
Gathering context
Completed task
Reading changed files
Completed task
Analyzing code quality
Completed task
Posting review feedback
Overall this is a well-thought-out implementation. The circuit-breaker pattern using Redis NX, the jitter for thundering-herd prevention, and the log-sampling strategy are all solid. A few issues worth addressing before merge:
Issues
Issues
1. Batch API calls bypass rate-limit handling
batchReadObjects()
in
Client.php
(lines 296–319) calls the HubSpot batch APIs directly without going through
executeRequest()
. If the batch endpoints return 429s, those exceptions will not set the Redis circuit-breaker and will not be wrapped in
RateLimitException
, so the middleware won't catch them.
// batchReadObjects — no executeRequest() wrapping
$
response
=
$
batchConfig
[
'
api
'
]->
read
(
$
batchReadRequest
);
Copy
Given the search endpoints can also hit rate limits during pagination-heavy rematching, the batch calls could too. Consider wrapping the batch call similarly, or at least catching 429 from batch APIs and converting them to
RateLimitException
.
Fix this →
Fix this →
2. Circuit-breaker key is scoped to CRM config ID, not HubSpot portal
getRateLimitCacheKey()
(
Client.php:120
) returns
hubspot:ratelimit:portal:{configId}
, but
$this->config->getId()
is the CRM
configuration
record ID, not the HubSpot portal/account ID. If multiple CRM configuration records share the same HubSpot portal (e.g. re-connections), each will have its own independent circuit-breaker. A 429 received via config A will not suppress requests from config B even though they share the same quota.
If configs are always 1:1 with portals this is fine, but it's worth confirming or renaming the key to avoid misleading future readers:
// If config IDs == portal IDs, rename to make intent clear:
return
sprintf
(
'
hubspot:ratelimit:config:%d
'
,
$
this
->
config
->
getId
());
Copy
3.
isHubspotRateLimit()
and
parseRetryAfter()
are
public
but not in the interface
These two methods (
Client.php:123
,
137
) are an internal concern of the rate-limit handling, but they are
public
. Nothing outside
Client
needs to call them (the pagination service calls
isUnauthorizedException
, not these). Making them
private
or at least
protected
avoids callers depending on them in tests or subclasses. The only reason to keep them public would be if they're tested in isolation — but they currently have no direct tests at all.
4. Missing test coverage for
executeRequest()
,
isHubspotRateLimit()
,
parseRetryAfter()
SonarCloud is already flagging 34.4% coverage. The three methods above — which contain the core logic of this PR — have zero test coverage. In particular:
parseRetryAfter()
: the
Retry-After
header parsing, fallback to message keyword matching, and the default-10-second case
isHubspotRateLimit()
: each of the exception type branches
executeRequest()
: the cached circuit-breaker path (early
throw
when Redis key exists) and the
SET NX
path when a live 429 is received
These are the most impactful paths to cover to get over the 70% gate.
Minor observations
Minor observations
5. Test constants duplicated from production code
HandleHubspotRateLimitTest.php
re-declares
MAX_RETRY_DELAY = 600
,
MIN_RETRY_DELAY = 1
, and
JITTER_SECONDS = 5
(lines 20–22). If these ever change in the middleware, the tests will silently use the old values and could still pass while testing the wrong bounds. Consider testing relative to the actual delay value returned rather than hard-coding the expected range.
6. Double-clamping in
RateLimitException
The constructor default
$retryAfter = 1
(line 13) and
getRetryAfter()
calling
max($this->retryAfter, 1)
(line 21) both guard against zero/negative values. The
max()
in the getter is unreachable given the current signature default. Not a bug, but a minor inconsistency.
7.
RateLimitException
in
MatchActivityCrmData::failed()
The
failed()
hook (line 172) checks
$exception instanceof RateLimitException
, but the middleware always calls
$job->release()
without re-throwing, so
failed()
would only fire for rate limit exceptions if the
retryUntil()
window expires with a pending rate-limit release, or if somehow the middleware is not applied. The check is defensive and harmless, but worth noting in a comment since the path is non-obvious.
Add or remove reactions
@LakyLak
JY-20725
JY-20725
add more tests
add more tests
12 / 12 checks OK
02d5214
02d5214
@sonarqubecloud
Show options
sonarqubecloud Bot commented 1 hour ago
sonarqubecloud
sonarqubecloud
Bot
commented
1 hour ago
1 hour ago
Quality Gate Passed Quality Gate passed
Quality Gate Passed
Quality Gate passed
Issues
0 New issues
0 New issues
0 Accepted issues
0 Accepted issues
Measures
0 Security Hotspots
0 Security Hotspots
94.8% Coverage on New Code
94.8% Coverage on New Code
0.0% Duplication on New Code
0.0% Duplication on New Code
See analysis details on SonarQube Cloud
See analysis details on SonarQube Cloud
Add or remove reactions
@LakyLak
JY-20725
JY-20725
code review suggestions
code review suggestions
3 / 6 checks OK
0f3e438
0f3e438
This branch has not been deployed...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"New Tab","depth":4,"bounds":{"left":0.0,"top":0.0518755,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"New Tab","depth":5,"bounds":{"left":0.013297873,"top":0.06304868,"width":0.014960106,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.08459697,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.09577015,"width":0.16888298,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira","depth":4,"bounds":{"left":0.0,"top":0.11731844,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.12849163,"width":0.16140293,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":4,"bounds":{"left":0.0,"top":0.15003991,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":5,"bounds":{"left":0.013297873,"top":0.16121309,"width":0.4644282,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.18276137,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.19393456,"width":0.18816489,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.06732048,"top":0.18994413,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"Pipelines - jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.21548285,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pipelines - jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.22665602,"width":0.039228722,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.0028257978,"top":0.24980047,"width":0.07413564,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"bounds":{"left":0.0028257978,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"bounds":{"left":0.013796543,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Tabs from other devices","depth":6,"bounds":{"left":0.024933511,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open history (⇧⌘H)","depth":6,"bounds":{"left":0.036070477,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open bookmarks (⌘B)","depth":6,"bounds":{"left":0.04720745,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Skip to content","depth":6,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Skip to content","depth":7,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open menu","depth":10,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Homepage (g then d)","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"jiminny","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"jiminny","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"app","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"app","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Search or jump to…","depth":9,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Type","depth":12,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":12,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"to search","depth":12,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Chat with Copilot","depth":10,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXMenuButton","text":"Open Copilot…","depth":9,"on_screen":false,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXMenuButton","text":"Create new...","depth":9,"on_screen":false,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"All issues(g then i)","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"All pull requests","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"All repositories","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"You have unread notifications(g then n)","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Open user navigation menu","depth":9,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Repository navigation","depth":9,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Repository navigation","depth":10,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Code","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Code","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Pull requests (34)","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pull requests","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"34","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Agents","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Agents","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Actions","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Actions","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Wiki","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Wiki","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Security and quality (4)","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Security and quality","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"4","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Insights","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Insights","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Settings","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Settings","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Important update","depth":10,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Important update","depth":11,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"On April 24 we'll start using GitHub Copilot interaction data for AI model training unless you opt out.","depth":10,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Review this update","depth":10,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Review this update","depth":11,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"and manage your preferences in your","depth":10,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"GitHub account settings","depth":10,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"GitHub account settings","depth":11,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":10,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Dismiss banner","depth":9,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"JY-20725 add HS rate limit handling on activities rematching #12066 Edit title","depth":13,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"#","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"12066","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Edit title","depth":14,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Checks pending","depth":13,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Checks pending","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Code","depth":13,"on_screen":false,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Code","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Open","depth":13,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"LakyLak","depth":15,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"wants to merge 4 commits into","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"master","depth":15,"on_screen":false,"role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"master","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"from","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"JY-20725-handle-HS-search-rate-limit","depth":16,"on_screen":false,"role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy head branch name to clipboard","depth":16,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Lines changed: 757 additions & 249 deletions","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Conversation (3)","depth":16,"on_screen":false,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"Conversation","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"3","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Commits (4)","depth":16,"on_screen":false,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Commits","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"4","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Checks (2)","depth":16,"on_screen":false,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Checks","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"2","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Files changed (12)","depth":16,"on_screen":false,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Files changed","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"12","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Open","depth":14,"bounds":{"left":0.1008976,"top":0.0726257,"width":0.011968086,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"JY-20725 add HS rate limit handling on activities rematching #12066 LakyLak wants to merge 4 commits into master from JY-20725-handle-HS-search-rate-limit Copy head branch name to clipboard","depth":14,"bounds":{"left":0.11951463,"top":0.058260176,"width":0.21077128,"height":0.042298485},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"JY-20725 add HS rate limit handling on activities rematching","depth":16,"bounds":{"left":0.11951463,"top":0.05865922,"width":0.13663563,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching","depth":17,"bounds":{"left":0.11951463,"top":0.06304868,"width":0.13663563,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"#","depth":16,"bounds":{"left":0.25880983,"top":0.06304868,"width":0.0028257978,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"12066","depth":16,"bounds":{"left":0.26163563,"top":0.06304868,"width":0.013630319,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"LakyLak","depth":18,"bounds":{"left":0.11951463,"top":0.08339984,"width":0.016289894,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":19,"bounds":{"left":0.11951463,"top":0.08339984,"width":0.016289894,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"wants to merge 4 commits into","depth":18,"bounds":{"left":0.13713431,"top":0.08339984,"width":0.058011968,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"master","depth":18,"bounds":{"left":0.19647606,"top":0.08180367,"width":0.018450798,"height":0.015163607},"on_screen":true,"role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"master","depth":19,"bounds":{"left":0.19847074,"top":0.083798885,"width":0.014461436,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"from","depth":19,"bounds":{"left":0.21625665,"top":0.08339984,"width":0.008643617,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"JY-20725-handle-HS-search-rate-limit","depth":19,"bounds":{"left":0.22623006,"top":0.08180367,"width":0.09042553,"height":0.015163607},"on_screen":true,"role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit","depth":20,"bounds":{"left":0.22822474,"top":0.083798885,"width":0.08643617,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy head branch name to clipboard","depth":19,"bounds":{"left":0.3179854,"top":0.07821229,"width":0.00930851,"height":0.022346368},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Conversation","depth":12,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Conversation","depth":13,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@LakyLak","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":15,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"LakyLak commented 3 hours ago","depth":14,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"LakyLak","depth":16,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"3 hours ago","depth":15,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"3 hours ago","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"JIRA: JY-20725","depth":16,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"JIRA:","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"JY-20725","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Changes:","depth":16,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Changes:","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Adds","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"HandleHubspotRateLimit","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"middleware (release jobs with delay)","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Adds","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"executeRequest","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"wrapper in Hubspot Client. It handles 429 errors and throws","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"RateLimitException","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add or remove reactions","depth":16,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"LakyLak","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"added","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"2","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commits","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"3 hours ago","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"3 hours ago","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@LakyLak","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"JY-20725","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"add HS rate limit handling on activities rematching","depth":14,"on_screen":false,"help_text":"JY-20725 add HS rate limit handling on activities rematching","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"add HS rate limit handling on activities rematching","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"8 / 10 checks OK","depth":14,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"44d5678","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"44d5678","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@LakyLak","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"JY-20725","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"add tests","depth":14,"on_screen":false,"help_text":"JY-20725 add tests","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"add tests","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"11 / 12 checks OK","depth":14,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"cbed2c9","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"cbed2c9","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@sonarqubecloud","depth":13,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":14,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"sonarqubecloud Bot commented 2 hours ago","depth":13,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"sonarqubecloud","depth":15,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"sonarqubecloud","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Bot","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"2 hours ago","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"2 hours ago","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Quality Gate Failed Quality Gate failed","depth":16,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"Quality Gate Failed","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Quality Gate failed","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Failed conditions","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"34.4% Coverage on New Code","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"34.4% Coverage on New Code","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(required ≥ 70%)","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"See analysis details on SonarQube Cloud","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"See analysis details on SonarQube Cloud","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add or remove reactions","depth":15,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"@LakyLak","depth":13,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":14,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Author","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"LakyLak commented 2 hours ago","depth":13,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"LakyLak","depth":15,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"2 hours ago","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"2 hours ago","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@claude","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"@claude","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add or remove reactions","depth":15,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"react with eyes","depth":14,"on_screen":false,"role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"👀","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@claude","depth":13,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":14,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"claude Bot commented 2 hours ago •","depth":13,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"claude","depth":15,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"claude","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Bot","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"2 hours ago","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"2 hours ago","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"•","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"edited","depth":16,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"edited","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Claude finished","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@LakyLak","depth":18,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"@LakyLak","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'s task in 1m 42s","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"——","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"View job","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"View job","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Code Review","depth":16,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Code Review","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Completed task","depth":18,"on_screen":false,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Gathering context","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Completed task","depth":18,"on_screen":false,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Reading changed files","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Completed task","depth":18,"bounds":{"left":0.11702128,"top":0.0,"width":0.004654255,"height":0.011173184},"on_screen":false,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Analyzing code quality","depth":18,"bounds":{"left":0.12250665,"top":0.0,"width":0.049867023,"height":0.013567438},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Completed task","depth":18,"bounds":{"left":0.11702128,"top":0.0,"width":0.004654255,"height":0.011173184},"on_screen":false,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Posting review feedback","depth":18,"bounds":{"left":0.12250665,"top":0.0,"width":0.054022606,"height":0.013567438},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Overall this is a well-thought-out implementation. The circuit-breaker pattern using Redis NX, the jitter for thundering-herd prevention, and the log-sampling strategy are all solid. A few issues worth addressing before merge:","depth":17,"bounds":{"left":0.114527926,"top":0.0,"width":0.25182846,"height":0.030327214},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Issues","depth":16,"bounds":{"left":0.114527926,"top":0.05347167,"width":0.2521609,"height":0.017557861},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Issues","depth":17,"bounds":{"left":0.114527926,"top":0.05387071,"width":0.017453458,"height":0.016759777},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1. Batch API calls bypass rate-limit handling","depth":18,"bounds":{"left":0.114527926,"top":0.08539505,"width":0.09823803,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"batchReadObjects()","depth":18,"bounds":{"left":0.11619016,"top":0.11691939,"width":0.04288564,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"in","depth":17,"bounds":{"left":0.16057181,"top":0.114924185,"width":0.0063164895,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Client.php","depth":18,"bounds":{"left":0.16855054,"top":0.11691939,"width":0.023769947,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(lines 296–319) calls the HubSpot batch APIs directly without going through","depth":17,"bounds":{"left":0.1939827,"top":0.114924185,"width":0.16505983,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"executeRequest()","depth":18,"bounds":{"left":0.11619016,"top":0.13367917,"width":0.038065158,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":". If the batch endpoints return 429s, those exceptions will not set the Redis circuit-breaker and will not be wrapped in","depth":17,"bounds":{"left":0.114527926,"top":0.13168396,"width":0.2450133,"height":0.030327214},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"RateLimitException","depth":18,"bounds":{"left":0.16456117,"top":0.15043895,"width":0.04288564,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", so the middleware won't catch them.","depth":17,"bounds":{"left":0.20894282,"top":0.14844373,"width":0.08194814,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"// batchReadObjects — no executeRequest() wrapping","depth":17,"bounds":{"left":0.119847074,"top":0.19034317,"width":0.119182184,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$","depth":17,"bounds":{"left":0.119847074,"top":0.20391062,"width":0.0023271276,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"response","depth":17,"bounds":{"left":0.1221742,"top":0.20391062,"width":0.019115692,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"=","depth":17,"bounds":{"left":0.14128989,"top":0.20391062,"width":0.0071476065,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$","depth":17,"bounds":{"left":0.1484375,"top":0.20391062,"width":0.0023271276,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"batchConfig","depth":17,"bounds":{"left":0.15076463,"top":0.20391062,"width":0.026263298,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"[","depth":17,"bounds":{"left":0.17702793,"top":0.20391062,"width":0.0023271276,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"bounds":{"left":0.17935506,"top":0.20391062,"width":0.002493351,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"api","depth":17,"bounds":{"left":0.1818484,"top":0.20391062,"width":0.0071476065,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"bounds":{"left":0.18899602,"top":0.20391062,"width":0.0023271276,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"]->","depth":17,"bounds":{"left":0.19132313,"top":0.20391062,"width":0.0071476065,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"read","depth":17,"bounds":{"left":0.19847074,"top":0.20391062,"width":0.009474734,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":17,"bounds":{"left":0.20794548,"top":0.20391062,"width":0.002493351,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$","depth":17,"bounds":{"left":0.21043883,"top":0.20391062,"width":0.0023271276,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"batchReadRequest","depth":17,"bounds":{"left":0.21276596,"top":0.20391062,"width":0.038065158,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":");","depth":17,"bounds":{"left":0.25083113,"top":0.20391062,"width":0.0048204786,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy","depth":17,"bounds":{"left":0.35272607,"top":0.18276137,"width":0.011303191,"height":0.028332002},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Given the search endpoints can also hit rate limits during pagination-heavy rematching, the batch calls could too. Consider wrapping the batch call similarly, or at least catching 429 from batch APIs and converting them to","depth":17,"bounds":{"left":0.114527926,"top":0.24381484,"width":0.24301861,"height":0.030327214},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"RateLimitException","depth":18,"bounds":{"left":0.11619016,"top":0.2793296,"width":0.04288564,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":17,"bounds":{"left":0.16057181,"top":0.2773344,"width":0.0013297872,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Fix this →","depth":17,"bounds":{"left":0.114527926,"top":0.30686352,"width":0.020777926,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Fix this →","depth":18,"bounds":{"left":0.114527926,"top":0.30686352,"width":0.020777926,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"2. Circuit-breaker key is scoped to CRM config ID, not HubSpot portal","depth":18,"bounds":{"left":0.114527926,"top":0.36472467,"width":0.15591756,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"getRateLimitCacheKey()","depth":18,"bounds":{"left":0.11619016,"top":0.396249,"width":0.05236037,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":17,"bounds":{"left":0.17004654,"top":0.3942538,"width":0.0031582448,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Client.php:120","depth":18,"bounds":{"left":0.1747008,"top":0.396249,"width":0.033410903,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":") returns","depth":17,"bounds":{"left":0.2096077,"top":0.3942538,"width":0.019780586,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"hubspot:ratelimit:portal:{configId}","depth":18,"bounds":{"left":0.23105054,"top":0.396249,"width":0.083277926,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", but","depth":17,"bounds":{"left":0.3159907,"top":0.3942538,"width":0.010970744,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$this->config->getId()","depth":18,"bounds":{"left":0.114527926,"top":0.396249,"width":0.24750665,"height":0.028332002},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"is the CRM","depth":17,"bounds":{"left":0.13513963,"top":0.41101357,"width":0.025930852,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"configuration","depth":18,"bounds":{"left":0.16107048,"top":0.41101357,"width":0.028590426,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"record ID, not the HubSpot portal/account ID. If multiple CRM configuration records share the same HubSpot portal (e.g. re-connections), each will have its own independent circuit-breaker. A 429 received via config A will not suppress requests from config B even though they share the same quota.","depth":17,"bounds":{"left":0.114527926,"top":0.41101357,"width":0.2471742,"height":0.04708699},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"If configs are always 1:1 with portals this is fine, but it's worth confirming or renaming the key to avoid misleading future readers:","depth":17,"bounds":{"left":0.114527926,"top":0.47406226,"width":0.24251994,"height":0.030327214},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"// If config IDs == portal IDs, rename to make intent clear:","depth":17,"bounds":{"left":0.119847074,"top":0.53272146,"width":0.14295213,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"return","depth":17,"bounds":{"left":0.119847074,"top":0.5462889,"width":0.014295213,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"sprintf","depth":17,"bounds":{"left":0.13646941,"top":0.5462889,"width":0.016788565,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":17,"bounds":{"left":0.15325798,"top":0.5462889,"width":0.0023271276,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"bounds":{"left":0.15558511,"top":0.5462889,"width":0.0023271276,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"hubspot:ratelimit:config:%d","depth":17,"bounds":{"left":0.15791224,"top":0.5462889,"width":0.064328454,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"bounds":{"left":0.22224069,"top":0.5462889,"width":0.002493351,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":17,"bounds":{"left":0.22473404,"top":0.5462889,"width":0.004654255,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$","depth":17,"bounds":{"left":0.2293883,"top":0.5462889,"width":0.002493351,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"this","depth":17,"bounds":{"left":0.23188165,"top":0.5462889,"width":0.009474734,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"->","depth":17,"bounds":{"left":0.24135639,"top":0.5462889,"width":0.0048204786,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"config","depth":17,"bounds":{"left":0.24617687,"top":0.5462889,"width":0.014295213,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"->","depth":17,"bounds":{"left":0.2604721,"top":0.5462889,"width":0.004654255,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"getId","depth":17,"bounds":{"left":0.26512632,"top":0.5462889,"width":0.011968086,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"());","depth":17,"bounds":{"left":0.27709442,"top":0.5462889,"width":0.009474734,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy","depth":17,"bounds":{"left":0.35272607,"top":0.5251397,"width":0.011303191,"height":0.028332002},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"3.","depth":18,"bounds":{"left":0.114527926,"top":0.61452514,"width":0.005485372,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"isHubspotRateLimit()","depth":19,"bounds":{"left":0.12167553,"top":0.61652035,"width":0.047706116,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"and","depth":18,"bounds":{"left":0.17087767,"top":0.61452514,"width":0.010804521,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"parseRetryAfter()","depth":19,"bounds":{"left":0.18334441,"top":0.61652035,"width":0.04055851,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"are","depth":18,"bounds":{"left":0.22539894,"top":0.61452514,"width":0.009640957,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"public","depth":19,"bounds":{"left":0.23670213,"top":0.61652035,"width":0.014295213,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"but not in the interface","depth":18,"bounds":{"left":0.25249335,"top":0.61452514,"width":0.05269282,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"These two methods (","depth":17,"bounds":{"left":0.114527926,"top":0.6440543,"width":0.045545213,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Client.php:123","depth":18,"bounds":{"left":0.16156915,"top":0.6460495,"width":0.033410903,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":17,"bounds":{"left":0.19647606,"top":0.6440543,"width":0.0026595744,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"137","depth":18,"bounds":{"left":0.20079787,"top":0.6460495,"width":0.0071476065,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":") are an internal concern of the rate-limit handling, but they are","depth":17,"bounds":{"left":0.20944148,"top":0.6440543,"width":0.13680187,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"public","depth":18,"bounds":{"left":0.34773937,"top":0.6460495,"width":0.014295213,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":". Nothing outside","depth":17,"bounds":{"left":0.114527926,"top":0.6440543,"width":0.25049868,"height":0.030327214},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Client","depth":18,"bounds":{"left":0.15159574,"top":0.66280925,"width":0.014295213,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"needs to call them (the pagination service calls","depth":17,"bounds":{"left":0.16738696,"top":0.66081405,"width":0.10388963,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"isUnauthorizedException","depth":18,"bounds":{"left":0.2727726,"top":0.66280925,"width":0.054853722,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", not these). Making them","depth":17,"bounds":{"left":0.114527926,"top":0.66081405,"width":0.24052526,"height":0.030327214},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"private","depth":18,"bounds":{"left":0.14527926,"top":0.679569,"width":0.01662234,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"or at least","depth":17,"bounds":{"left":0.16356383,"top":0.6775738,"width":0.023936171,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"protected","depth":18,"bounds":{"left":0.18916224,"top":0.679569,"width":0.02144282,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"avoids callers depending on them in tests or subclasses. The only reason to keep them public would be if they're tested in isolation — but they currently have no direct tests at all.","depth":17,"bounds":{"left":0.114527926,"top":0.6775738,"width":0.24019282,"height":0.030327214},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"4. Missing test coverage for","depth":18,"bounds":{"left":0.114527926,"top":0.75219476,"width":0.063663565,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"executeRequest()","depth":19,"bounds":{"left":0.17985372,"top":0.75418997,"width":0.038065158,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":18,"bounds":{"left":0.21941489,"top":0.75219476,"width":0.0028257978,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"isHubspotRateLimit()","depth":19,"bounds":{"left":0.2237367,"top":0.75418997,"width":0.047706116,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":18,"bounds":{"left":0.27310506,"top":0.75219476,"width":0.0026595744,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"parseRetryAfter()","depth":19,"bounds":{"left":0.27742687,"top":0.75418997,"width":0.040392287,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"SonarCloud is already flagging 34.4% coverage. The three methods above — which contain the core logic of this PR — have zero test coverage. In particular:","depth":17,"bounds":{"left":0.114527926,"top":0.78172386,"width":0.24883644,"height":0.030327214},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"parseRetryAfter()","depth":19,"bounds":{"left":0.12416888,"top":0.830008,"width":0.040392287,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":": the","depth":18,"bounds":{"left":0.1662234,"top":0.82801276,"width":0.010804521,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Retry-After","depth":19,"bounds":{"left":0.17869017,"top":0.830008,"width":0.026097074,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"header parsing, fallback to message keyword matching, and the default-10-second case","depth":18,"bounds":{"left":0.12250665,"top":0.82801276,"width":0.22240691,"height":0.030327214},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"isHubspotRateLimit()","depth":19,"bounds":{"left":0.12416888,"top":0.86632085,"width":0.047539894,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":": each of the exception type branches","depth":18,"bounds":{"left":0.17337102,"top":0.86432564,"width":0.08111702,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"executeRequest()","depth":19,"bounds":{"left":0.12416888,"top":0.88627297,"width":0.038065158,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":": the cached circuit-breaker path (early","depth":18,"bounds":{"left":0.16373006,"top":0.88427776,"width":0.0852726,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"throw","depth":19,"bounds":{"left":0.2506649,"top":0.88627297,"width":0.011801862,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"when Redis key exists) and the","depth":18,"bounds":{"left":0.26412898,"top":0.88427776,"width":0.06898271,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"SET NX","depth":19,"bounds":{"left":0.33460772,"top":0.88627297,"width":0.014295213,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"path when a live 429 is received","depth":18,"bounds":{"left":0.12250665,"top":0.88427776,"width":0.23886304,"height":0.030327214},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"These are the most impactful paths to cover to get over the 70% gate.","depth":17,"bounds":{"left":0.114527926,"top":0.93056667,"width":0.15109707,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Minor observations","depth":16,"bounds":{"left":0.114527926,"top":0.9868316,"width":0.2521609,"height":0.013168395},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Minor observations","depth":17,"bounds":{"left":0.114527926,"top":0.98723066,"width":0.052526597,"height":0.0127693415},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"5. Test constants duplicated from production code","depth":18,"bounds":{"left":0.114527926,"top":1.0,"width":0.11353058,"height":-0.01875496},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php","depth":18,"bounds":{"left":0.11619016,"top":1.0,"width":0.071476065,"height":-0.05027938},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"re-declares","depth":17,"bounds":{"left":0.18916224,"top":1.0,"width":0.027260639,"height":-0.048284173},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"MAX_RETRY_DELAY = 600","depth":18,"bounds":{"left":0.21808511,"top":1.0,"width":0.050033245,"height":-0.05027938},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":17,"bounds":{"left":0.26961437,"top":1.0,"width":0.0026595744,"height":-0.048284173},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"MIN_RETRY_DELAY = 1","depth":18,"bounds":{"left":0.27393618,"top":1.0,"width":0.045212764,"height":-0.05027938},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", and","depth":17,"bounds":{"left":0.32081118,"top":1.0,"width":0.010638298,"height":-0.048284173},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"JITTER_SECONDS = 5","depth":18,"bounds":{"left":0.11619016,"top":1.0,"width":0.04288564,"height":-0.06703913},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(lines 20–22). If these ever change in the middleware, the tests will silently use the old values and could still pass while testing the wrong bounds. Consider testing relative to the actual delay value returned rather than hard-coding the expected range.","depth":17,"bounds":{"left":0.114527926,"top":1.0,"width":0.2478391,"height":-0.065043926},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"6. Double-clamping in","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"RateLimitException","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"The constructor default","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$retryAfter = 1","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(line 13) and","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"getRetryAfter()","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"calling","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"max($this->retryAfter, 1)","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(line 21) both guard against zero/negative values. The","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"max()","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"in the getter is unreachable given the current signature default. Not a bug, but a minor inconsistency.","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"7.","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"RateLimitException","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"in","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"MatchActivityCrmData::failed()","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"The","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"failed()","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"hook (line 172) checks","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$exception instanceof RateLimitException","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", but the middleware always calls","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$job->release()","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"without re-throwing, so","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"failed()","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"would only fire for rate limit exceptions if the","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"retryUntil()","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"window expires with a pending rate-limit release, or if somehow the middleware is not applied. The check is defensive and harmless, but worth noting in a comment since the path is non-obvious.","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add or remove reactions","depth":15,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"@LakyLak","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"JY-20725","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"add more tests","depth":14,"on_screen":false,"help_text":"JY-20725 add more tests","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"add more tests","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"12 / 12 checks OK","depth":13,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"02d5214","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"02d5214","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@sonarqubecloud","depth":13,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":14,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"sonarqubecloud Bot commented 1 hour ago","depth":13,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"sonarqubecloud","depth":15,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"sonarqubecloud","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Bot","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"1 hour ago","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"1 hour ago","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Quality Gate Passed Quality Gate passed","depth":16,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"Quality Gate Passed","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Quality Gate passed","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Issues","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"0 New issues","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"0 New issues","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"0 Accepted issues","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"0 Accepted issues","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Measures","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"0 Security Hotspots","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"0 Security Hotspots","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"94.8% Coverage on New Code","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"94.8% Coverage on New Code","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"0.0% Duplication on New Code","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"0.0% Duplication on New Code","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"See analysis details on SonarQube Cloud","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"See analysis details on SonarQube Cloud","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add or remove reactions","depth":15,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"@LakyLak","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"JY-20725","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"code review suggestions","depth":14,"on_screen":false,"help_text":"JY-20725 code review suggestions","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"code review suggestions","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"3 / 6 checks OK","depth":13,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"0f3e438","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"0f3e438","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"This branch has not been deployed","depth":14,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"}]...
|
1459430224050911912
|
-863267293278978172
|
visual_change
|
accessibility
|
NULL
|
New Tab
New Tab
Jy 20820 es reindex stream model h New Tab
New Tab
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS]
Show options
LakyLak commented 3 hours ago
LakyLak
LakyLak
commented
3 hours ago
3 hours ago
JIRA: JY-20725
JIRA:
JY-20725
JY-20725
Changes:
Changes:
Adds
HandleHubspotRateLimit
middleware (release jobs with delay)
Adds
executeRequest
wrapper in Hubspot Client. It handles 429 errors and throws
RateLimitException
Add or remove reactions
LakyLak
LakyLak
added
2
commits
3 hours ago
3 hours ago
@LakyLak
JY-20725
JY-20725
add HS rate limit handling on activities rematching
add HS rate limit handling on activities rematching
8 / 10 checks OK
44d5678
44d5678
@LakyLak
JY-20725
JY-20725
add tests
add tests
11 / 12 checks OK
cbed2c9
cbed2c9
@sonarqubecloud
Show options
sonarqubecloud Bot commented 2 hours ago
sonarqubecloud
sonarqubecloud
Bot
commented
2 hours ago
2 hours ago
Quality Gate Failed Quality Gate failed
Quality Gate Failed
Quality Gate failed
Failed conditions
34.4% Coverage on New Code
34.4% Coverage on New Code
(required ≥ 70%)
See analysis details on SonarQube Cloud
See analysis details on SonarQube Cloud
Add or remove reactions
@LakyLak
Show options
Author
LakyLak commented 2 hours ago
LakyLak
LakyLak
commented
2 hours ago
2 hours ago
@claude
@claude
Add or remove reactions
react with eyes
👀
1
@claude
Show options
claude Bot commented 2 hours ago •
claude
claude
Bot
commented
2 hours ago
2 hours ago
•
edited
edited
Claude finished
@LakyLak
@LakyLak
's task in 1m 42s
——
View job
View job
Code Review
Code Review
Completed task
Gathering context
Completed task
Reading changed files
Completed task
Analyzing code quality
Completed task
Posting review feedback
Overall this is a well-thought-out implementation. The circuit-breaker pattern using Redis NX, the jitter for thundering-herd prevention, and the log-sampling strategy are all solid. A few issues worth addressing before merge:
Issues
Issues
1. Batch API calls bypass rate-limit handling
batchReadObjects()
in
Client.php
(lines 296–319) calls the HubSpot batch APIs directly without going through
executeRequest()
. If the batch endpoints return 429s, those exceptions will not set the Redis circuit-breaker and will not be wrapped in
RateLimitException
, so the middleware won't catch them.
// batchReadObjects — no executeRequest() wrapping
$
response
=
$
batchConfig
[
'
api
'
]->
read
(
$
batchReadRequest
);
Copy
Given the search endpoints can also hit rate limits during pagination-heavy rematching, the batch calls could too. Consider wrapping the batch call similarly, or at least catching 429 from batch APIs and converting them to
RateLimitException
.
Fix this →
Fix this →
2. Circuit-breaker key is scoped to CRM config ID, not HubSpot portal
getRateLimitCacheKey()
(
Client.php:120
) returns
hubspot:ratelimit:portal:{configId}
, but
$this->config->getId()
is the CRM
configuration
record ID, not the HubSpot portal/account ID. If multiple CRM configuration records share the same HubSpot portal (e.g. re-connections), each will have its own independent circuit-breaker. A 429 received via config A will not suppress requests from config B even though they share the same quota.
If configs are always 1:1 with portals this is fine, but it's worth confirming or renaming the key to avoid misleading future readers:
// If config IDs == portal IDs, rename to make intent clear:
return
sprintf
(
'
hubspot:ratelimit:config:%d
'
,
$
this
->
config
->
getId
());
Copy
3.
isHubspotRateLimit()
and
parseRetryAfter()
are
public
but not in the interface
These two methods (
Client.php:123
,
137
) are an internal concern of the rate-limit handling, but they are
public
. Nothing outside
Client
needs to call them (the pagination service calls
isUnauthorizedException
, not these). Making them
private
or at least
protected
avoids callers depending on them in tests or subclasses. The only reason to keep them public would be if they're tested in isolation — but they currently have no direct tests at all.
4. Missing test coverage for
executeRequest()
,
isHubspotRateLimit()
,
parseRetryAfter()
SonarCloud is already flagging 34.4% coverage. The three methods above — which contain the core logic of this PR — have zero test coverage. In particular:
parseRetryAfter()
: the
Retry-After
header parsing, fallback to message keyword matching, and the default-10-second case
isHubspotRateLimit()
: each of the exception type branches
executeRequest()
: the cached circuit-breaker path (early
throw
when Redis key exists) and the
SET NX
path when a live 429 is received
These are the most impactful paths to cover to get over the 70% gate.
Minor observations
Minor observations
5. Test constants duplicated from production code
HandleHubspotRateLimitTest.php
re-declares
MAX_RETRY_DELAY = 600
,
MIN_RETRY_DELAY = 1
, and
JITTER_SECONDS = 5
(lines 20–22). If these ever change in the middleware, the tests will silently use the old values and could still pass while testing the wrong bounds. Consider testing relative to the actual delay value returned rather than hard-coding the expected range.
6. Double-clamping in
RateLimitException
The constructor default
$retryAfter = 1
(line 13) and
getRetryAfter()
calling
max($this->retryAfter, 1)
(line 21) both guard against zero/negative values. The
max()
in the getter is unreachable given the current signature default. Not a bug, but a minor inconsistency.
7.
RateLimitException
in
MatchActivityCrmData::failed()
The
failed()
hook (line 172) checks
$exception instanceof RateLimitException
, but the middleware always calls
$job->release()
without re-throwing, so
failed()
would only fire for rate limit exceptions if the
retryUntil()
window expires with a pending rate-limit release, or if somehow the middleware is not applied. The check is defensive and harmless, but worth noting in a comment since the path is non-obvious.
Add or remove reactions
@LakyLak
JY-20725
JY-20725
add more tests
add more tests
12 / 12 checks OK
02d5214
02d5214
@sonarqubecloud
Show options
sonarqubecloud Bot commented 1 hour ago
sonarqubecloud
sonarqubecloud
Bot
commented
1 hour ago
1 hour ago
Quality Gate Passed Quality Gate passed
Quality Gate Passed
Quality Gate passed
Issues
0 New issues
0 New issues
0 Accepted issues
0 Accepted issues
Measures
0 Security Hotspots
0 Security Hotspots
94.8% Coverage on New Code
94.8% Coverage on New Code
0.0% Duplication on New Code
0.0% Duplication on New Code
See analysis details on SonarQube Cloud
See analysis details on SonarQube Cloud
Add or remove reactions
@LakyLak
JY-20725
JY-20725
code review suggestions
code review suggestions
3 / 6 checks OK
0f3e438
0f3e438
This branch has not been deployed...
|
20639
|
NULL
|
NULL
|
NULL
|
|
20648
|
898
|
2
|
2026-05-11T16:05:49.325248+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515549325_m1.jpg...
|
Firefox
|
JY-20725 add HS rate limit handling on activities JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app — Work...
|
1
|
github.com/jiminny/app/pull/12066
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
New Tab
New Tab
Jy 20820 es reindex stream model h New Tab
New Tab
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS]
Show options
LakyLak commented 3 hours ago
LakyLak
LakyLak
commented
3 hours ago
3 hours ago
JIRA: JY-20725
JIRA:
JY-20725
JY-20725
Changes:
Changes:
Adds
HandleHubspotRateLimit
middleware (release jobs with delay)
Adds
executeRequest
wrapper in Hubspot Client. It handles 429 errors and throws
RateLimitException
Add or remove reactions
LakyLak
LakyLak
added
2
commits
3 hours ago
3 hours ago
@LakyLak
JY-20725
JY-20725
add HS rate limit handling on activities rematching
add HS rate limit handling on activities rematching
8 / 10 checks OK
44d5678
44d5678
@LakyLak
JY-20725
JY-20725
add tests
add tests
11 / 12 checks OK
cbed2c9
cbed2c9
@sonarqubecloud
Show options
sonarqubecloud Bot commented 2 hours ago
sonarqubecloud
sonarqubecloud
Bot
commented
2 hours ago
2 hours ago
Quality Gate Failed Quality Gate failed
Quality Gate Failed
Quality Gate failed
Failed conditions
34.4% Coverage on New Code
34.4% Coverage on New Code
(required ≥ 70%)
See analysis details on SonarQube Cloud
See analysis details on SonarQube Cloud
Add or remove reactions
@LakyLak
Show options
Author
LakyLak commented 2 hours ago
LakyLak
LakyLak
commented
2 hours ago
2 hours ago
@claude
@claude
Add or remove reactions
react with eyes
👀
1
@claude
Show options
claude Bot commented 2 hours ago •
claude
claude
Bot
commented
2 hours ago
2 hours ago
•
edited
edited
Claude finished
@LakyLak
@LakyLak
's task in 1m 42s
——
View job
View job
Code Review
Code Review
Completed task
Gathering context
Completed task
Reading changed files
Completed task
Analyzing code quality
Completed task
Posting review feedback
Overall this is a well-thought-out implementation. The circuit-breaker pattern using Redis NX, the jitter for thundering-herd prevention, and the log-sampling strategy are all solid. A few issues worth addressing before merge:
Issues
Issues
1. Batch API calls bypass rate-limit handling
batchReadObjects()
in
Client.php
(lines 296–319) calls the HubSpot batch APIs directly without going through
executeRequest()
. If the batch endpoints return 429s, those exceptions will not set the Redis circuit-breaker and will not be wrapped in
RateLimitException
, so the middleware won't catch them.
// batchReadObjects — no executeRequest() wrapping
$
response
=
$
batchConfig
[
'
api
'
]->
read
(
$
batchReadRequest
);
Copy
Given the search endpoints can also hit rate limits during pagination-heavy rematching, the batch calls could too. Consider wrapping the batch call similarly, or at least catching 429 from batch APIs and converting them to
RateLimitException
.
Fix this →
Fix this →
2. Circuit-breaker key is scoped to CRM config ID, not HubSpot portal
getRateLimitCacheKey()
(
Client.php:120
) returns
hubspot:ratelimit:portal:{configId}
, but
$this->config->getId()
is the CRM
configuration
record ID, not the HubSpot portal/account ID. If multiple CRM configuration records share the same HubSpot portal (e.g. re-connections), each will have its own independent circuit-breaker. A 429 received via config A will not suppress requests from config B even though they share the same quota.
If configs are always 1:1 with portals this is fine, but it's worth confirming or renaming the key to avoid misleading future readers:
// If config IDs == portal IDs, rename to make intent clear:
return
sprintf
(
'
hubspot:ratelimit:config:%d
'
,
$
this
->
config
->
getId
());
Copy
3.
isHubspotRateLimit()
and
parseRetryAfter()
are
public
but not in the interface
These two methods (
Client.php:123
,
137
) are an internal concern of the rate-limit handling, but they are
public
. Nothing outside
Client
needs to call them (the pagination service calls
isUnauthorizedException
, not these). Making them
private
or at least
protected
avoids callers depending on them in tests or subclasses. The only reason to keep them public would be if they're tested in isolation — but they currently have no direct tests at all.
4. Missing test coverage for
executeRequest()
,
isHubspotRateLimit()
,
parseRetryAfter()
SonarCloud is already flagging 34.4% coverage. The three methods above — which contain the core logic of this PR — have zero test coverage. In particular:
parseRetryAfter()
: the
Retry-After
header parsing, fallback to message keyword matching, and the default-10-second case
isHubspotRateLimit()
: each of the exception type branches
executeRequest()
: the cached circuit-breaker path (early
throw
when Redis key exists) and the
SET NX
path when a live 429 is received
These are the most impactful paths to cover to get over the 70% gate.
Minor observations
Minor observations
5. Test constants duplicated from production code
HandleHubspotRateLimitTest.php
re-declares
MAX_RETRY_DELAY = 600
,
MIN_RETRY_DELAY = 1
, and
JITTER_SECONDS = 5
(lines 20–22). If these ever change in the middleware, the tests will silently use the old values and could still pass while testing the wrong bounds. Consider testing relative to the actual delay value returned rather than hard-coding the expected range.
6. Double-clamping in
RateLimitException
The constructor default
$retryAfter = 1
(line 13) and
getRetryAfter()
calling
max($this->retryAfter, 1)
(line 21) both guard against zero/negative values. The
max()
in the getter is unreachable given the current signature default. Not a bug, but a minor inconsistency.
7.
RateLimitException
in
MatchActivityCrmData::failed()
The
failed()
hook (line 172) checks
$exception instanceof RateLimitException
, but the middleware always calls
$job->release()
without re-throwing, so
failed()
would only fire for rate limit exceptions if the
retryUntil()
window expires with a pending rate-limit release, or if somehow the middleware is not applied. The check is defensive and harmless, but worth noting in a comment since the path is non-obvious.
Add or remove reactions
@LakyLak
JY-20725
JY-20725
add more tests
add more tests
12 / 12 checks OK
02d5214
02d5214
@sonarqubecloud
Show options
sonarqubecloud Bot commented 1 hour ago
sonarqubecloud
sonarqubecloud
Bot
commented
1 hour ago
1 hour ago
Quality Gate Passed Quality Gate passed
Quality Gate Passed
Quality Gate passed
Issues
0 New issues
0 New issues
0 Accepted issues
0 Accepted issues
Measures
0 Security Hotspots
0 Security Hotspots
94.8% Coverage on New Code
94.8% Coverage on New Code
0.0% Duplication on New Code
0.0% Duplication on New Code
See analysis details on SonarQube Cloud
See analysis details on SonarQube Cloud
Add or remove reactions
@LakyLak
JY-20725
JY-20725
code review suggestions
code review suggestions
3 / 6 checks OK
0f3e438
0f3e438
This branch has not been deployed
This branch has not been deployed
No deployments
Merge info
Merge info
Review required
Review required
At least 1 approving review is required by reviewers with write access.
Some checks haven't completed yet
Some checks haven't completed yet
2 pending, 1 in progress, 1 expected, 3 successful checks
Collapse checks
Collapse 3 pending checks group
3 pending checks
Checks settings
pending checks
pending checks
ci/circleci: build-backend
ci/circleci: build-backend
ci/circleci: build-backend
Waiting for status to be reported
— CircleCI is running your tests
More actions
ci/circleci: build-frontend
ci/circleci: build-frontend
ci/circleci: build-frontend
Waiting for status to be reported
— CircleCI is running your tests
More actions
SonarCloud Code Analysis
SonarCloud Code Analysis
Expected
— Waiting for status to be reported
Required
Collapse 1 in progress check group...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"New Tab","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"New Tab","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"Pipelines - jiminny/app","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pipelines - jiminny/app","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"New Tab","depth":4,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Tabs from other devices","depth":6,"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open history (⇧⌘H)","depth":6,"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open bookmarks (⌘B)","depth":6,"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Skip to content","depth":6,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Skip to content","depth":7,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open menu","depth":10,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Homepage (g then d)","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"jiminny","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"jiminny","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"app","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"app","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Search or jump to…","depth":9,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Type","depth":12,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":12,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"to search","depth":12,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Chat with Copilot","depth":10,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXMenuButton","text":"Open Copilot…","depth":9,"on_screen":false,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXMenuButton","text":"Create new...","depth":9,"on_screen":false,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"All issues(g then i)","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"All pull requests","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"All repositories","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"You have unread notifications(g then n)","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Open user navigation menu","depth":9,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Repository navigation","depth":9,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Repository navigation","depth":10,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Code","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Code","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Pull requests (34)","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pull requests","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"34","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Agents","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Agents","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Actions","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Actions","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Wiki","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Wiki","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Security and quality (4)","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Security and quality","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"4","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Insights","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Insights","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Settings","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Settings","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Important update","depth":10,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Important update","depth":11,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"On April 24 we'll start using GitHub Copilot interaction data for AI model training unless you opt out.","depth":10,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Review this update","depth":10,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Review this update","depth":11,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"and manage your preferences in your","depth":10,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"GitHub account settings","depth":10,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"GitHub account settings","depth":11,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":10,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Dismiss banner","depth":9,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"JY-20725 add HS rate limit handling on activities rematching #12066 Edit title","depth":13,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"#","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"12066","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Edit title","depth":14,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Checks pending","depth":13,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Checks pending","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Code","depth":13,"on_screen":false,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Code","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Open","depth":13,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"LakyLak","depth":15,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"wants to merge 4 commits into","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"master","depth":15,"on_screen":false,"role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"master","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"from","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"JY-20725-handle-HS-search-rate-limit","depth":16,"on_screen":false,"role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy head branch name to clipboard","depth":16,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Lines changed: 757 additions & 249 deletions","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Conversation (3)","depth":16,"on_screen":false,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"Conversation","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"3","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Commits (4)","depth":16,"on_screen":false,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Commits","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"4","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Checks (2)","depth":16,"on_screen":false,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Checks","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"2","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Files changed (12)","depth":16,"on_screen":false,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Files changed","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"12","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Open","depth":14,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"JY-20725 add HS rate limit handling on activities rematching #12066 LakyLak wants to merge 4 commits into master from JY-20725-handle-HS-search-rate-limit Copy head branch name to clipboard","depth":14,"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"JY-20725 add HS rate limit handling on activities rematching","depth":16,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"#","depth":16,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"12066","depth":16,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"LakyLak","depth":18,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":19,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"wants to merge 4 commits into","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"master","depth":18,"on_screen":true,"role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"master","depth":19,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"from","depth":19,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"JY-20725-handle-HS-search-rate-limit","depth":19,"on_screen":true,"role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit","depth":20,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy head branch name to clipboard","depth":19,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Conversation","depth":12,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Conversation","depth":13,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@LakyLak","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":15,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"LakyLak commented 3 hours ago","depth":14,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"LakyLak","depth":16,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"3 hours ago","depth":15,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"3 hours ago","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"JIRA: JY-20725","depth":16,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"JIRA:","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"JY-20725","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Changes:","depth":16,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Changes:","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Adds","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"HandleHubspotRateLimit","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"middleware (release jobs with delay)","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Adds","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"executeRequest","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"wrapper in Hubspot Client. It handles 429 errors and throws","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"RateLimitException","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add or remove reactions","depth":16,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"LakyLak","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"added","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"2","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commits","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"3 hours ago","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"3 hours ago","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@LakyLak","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"JY-20725","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"add HS rate limit handling on activities rematching","depth":14,"on_screen":false,"help_text":"JY-20725 add HS rate limit handling on activities rematching","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"add HS rate limit handling on activities rematching","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"8 / 10 checks OK","depth":14,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"44d5678","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"44d5678","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@LakyLak","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"JY-20725","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"add tests","depth":14,"on_screen":false,"help_text":"JY-20725 add tests","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"add tests","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"11 / 12 checks OK","depth":14,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"cbed2c9","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"cbed2c9","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@sonarqubecloud","depth":13,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":14,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"sonarqubecloud Bot commented 2 hours ago","depth":13,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"sonarqubecloud","depth":15,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"sonarqubecloud","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Bot","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"2 hours ago","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"2 hours ago","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Quality Gate Failed Quality Gate failed","depth":16,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"Quality Gate Failed","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Quality Gate failed","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Failed conditions","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"34.4% Coverage on New Code","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"34.4% Coverage on New Code","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(required ≥ 70%)","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"See analysis details on SonarQube Cloud","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"See analysis details on SonarQube Cloud","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add or remove reactions","depth":15,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"@LakyLak","depth":13,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":14,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Author","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"LakyLak commented 2 hours ago","depth":13,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"LakyLak","depth":15,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"2 hours ago","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"2 hours ago","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@claude","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"@claude","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add or remove reactions","depth":15,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"react with eyes","depth":14,"on_screen":false,"role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"👀","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@claude","depth":13,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":14,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"claude Bot commented 2 hours ago •","depth":13,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"claude","depth":15,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"claude","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Bot","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"2 hours ago","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"2 hours ago","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"•","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"edited","depth":16,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"edited","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Claude finished","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@LakyLak","depth":18,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"@LakyLak","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'s task in 1m 42s","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"——","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"View job","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"View job","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Code Review","depth":16,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Code Review","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Completed task","depth":18,"on_screen":false,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Gathering context","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Completed task","depth":18,"on_screen":false,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Reading changed files","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Completed task","depth":18,"on_screen":false,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Analyzing code quality","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Completed task","depth":18,"on_screen":false,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Posting review feedback","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Overall this is a well-thought-out implementation. The circuit-breaker pattern using Redis NX, the jitter for thundering-herd prevention, and the log-sampling strategy are all solid. A few issues worth addressing before merge:","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Issues","depth":16,"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Issues","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1. Batch API calls bypass rate-limit handling","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"batchReadObjects()","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"in","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Client.php","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(lines 296–319) calls the HubSpot batch APIs directly without going through","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"executeRequest()","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":". If the batch endpoints return 429s, those exceptions will not set the Redis circuit-breaker and will not be wrapped in","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"RateLimitException","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", so the middleware won't catch them.","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"// batchReadObjects — no executeRequest() wrapping","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"response","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"=","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"batchConfig","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"[","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"api","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"]->","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"read","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"batchReadRequest","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":");","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy","depth":17,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Given the search endpoints can also hit rate limits during pagination-heavy rematching, the batch calls could too. Consider wrapping the batch call similarly, or at least catching 429 from batch APIs and converting them to","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"RateLimitException","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Fix this →","depth":17,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Fix this →","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"2. Circuit-breaker key is scoped to CRM config ID, not HubSpot portal","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"getRateLimitCacheKey()","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Client.php:120","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":") returns","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"hubspot:ratelimit:portal:{configId}","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", but","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$this->config->getId()","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"is the CRM","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"configuration","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"record ID, not the HubSpot portal/account ID. If multiple CRM configuration records share the same HubSpot portal (e.g. re-connections), each will have its own independent circuit-breaker. A 429 received via config A will not suppress requests from config B even though they share the same quota.","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"If configs are always 1:1 with portals this is fine, but it's worth confirming or renaming the key to avoid misleading future readers:","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"// If config IDs == portal IDs, rename to make intent clear:","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"return","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"sprintf","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"hubspot:ratelimit:config:%d","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"this","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"->","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"config","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"->","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"getId","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"());","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy","depth":17,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"3.","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"isHubspotRateLimit()","depth":19,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"and","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"parseRetryAfter()","depth":19,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"are","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"public","depth":19,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"but not in the interface","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"These two methods (","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Client.php:123","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"137","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":") are an internal concern of the rate-limit handling, but they are","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"public","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":". Nothing outside","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Client","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"needs to call them (the pagination service calls","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"isUnauthorizedException","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", not these). Making them","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"private","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"or at least","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"protected","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"avoids callers depending on them in tests or subclasses. The only reason to keep them public would be if they're tested in isolation — but they currently have no direct tests at all.","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"4. Missing test coverage for","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"executeRequest()","depth":19,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"isHubspotRateLimit()","depth":19,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"parseRetryAfter()","depth":19,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"SonarCloud is already flagging 34.4% coverage. The three methods above — which contain the core logic of this PR — have zero test coverage. In particular:","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"parseRetryAfter()","depth":19,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":": the","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Retry-After","depth":19,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"header parsing, fallback to message keyword matching, and the default-10-second case","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"isHubspotRateLimit()","depth":19,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":": each of the exception type branches","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"executeRequest()","depth":19,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":": the cached circuit-breaker path (early","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"throw","depth":19,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"when Redis key exists) and the","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"SET NX","depth":19,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"path when a live 429 is received","depth":18,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"These are the most impactful paths to cover to get over the 70% gate.","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Minor observations","depth":16,"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Minor observations","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"5. Test constants duplicated from production code","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"re-declares","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"MAX_RETRY_DELAY = 600","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":17,"bounds":{"left":0.0,"top":0.06722222,"width":0.0055555557,"height":0.018888889},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"MIN_RETRY_DELAY = 1","depth":18,"bounds":{"left":0.007638889,"top":0.07,"width":0.094444446,"height":0.016111111},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", and","depth":17,"bounds":{"left":0.10555556,"top":0.06722222,"width":0.022222223,"height":0.018888889},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"JITTER_SECONDS = 5","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(lines 20–22). If these ever change in the middleware, the tests will silently use the old values and could still pass while testing the wrong bounds. Consider testing relative to the actual delay value returned rather than hard-coding the expected range.","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"6. Double-clamping in","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"RateLimitException","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"The constructor default","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$retryAfter = 1","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(line 13) and","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"getRetryAfter()","depth":18,"bounds":{"left":0.0,"top":0.22222222,"width":0.074652776,"height":0.016111111},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"calling","depth":17,"bounds":{"left":0.0065972223,"top":0.21944444,"width":0.034722224,"height":0.018888889},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"max($this->retryAfter, 1)","depth":18,"bounds":{"left":0.044444446,"top":0.22222222,"width":0.124305554,"height":0.016111111},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(line 21) both guard against zero/negative values. The","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"max()","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"in the getter is unreachable given the current signature default. Not a bug, but a minor inconsistency.","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"7.","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"RateLimitException","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"in","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"MatchActivityCrmData::failed()","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"The","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"failed()","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"hook (line 172) checks","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$exception instanceof RateLimitException","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", but the middleware always calls","depth":17,"bounds":{"left":0.052430555,"top":0.34833333,"width":0.14791666,"height":0.018888889},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$job->release()","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"without re-throwing, so","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"failed()","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"would only fire for rate limit exceptions if the","depth":17,"bounds":{"left":0.0,"top":0.37166667,"width":0.2048611,"height":0.018888889},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"retryUntil()","depth":18,"bounds":{"left":0.12048611,"top":0.37444445,"width":0.059722222,"height":0.016111111},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"window expires with a pending rate-limit release, or if somehow the middleware is not applied. The check is defensive and harmless, but worth noting in a comment since the path is non-obvious.","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add or remove reactions","depth":15,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"@LakyLak","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"JY-20725","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"add more tests","depth":14,"on_screen":false,"help_text":"JY-20725 add more tests","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"add more tests","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"12 / 12 checks OK","depth":13,"bounds":{"left":0.16423611,"top":0.57666665,"width":0.011111111,"height":0.023333333},"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"02d5214","depth":14,"bounds":{"left":0.178125,"top":0.58166665,"width":0.035069443,"height":0.016111111},"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"02d5214","depth":15,"bounds":{"left":0.178125,"top":0.58166665,"width":0.035069443,"height":0.016111111},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@sonarqubecloud","depth":13,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":14,"bounds":{"left":0.18472221,"top":0.6433333,"width":0.016666668,"height":0.04111111},"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"sonarqubecloud Bot commented 1 hour ago","depth":13,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"sonarqubecloud","depth":15,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"sonarqubecloud","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Bot","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"1 hour ago","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"1 hour ago","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Quality Gate Passed Quality Gate passed","depth":16,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"Quality Gate Passed","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Quality Gate passed","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Issues","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"0 New issues","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"0 New issues","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"0 Accepted issues","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"0 Accepted issues","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Measures","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"0 Security Hotspots","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"0 Security Hotspots","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"94.8% Coverage on New Code","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"94.8% Coverage on New Code","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"0.0% Duplication on New Code","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"0.0% Duplication on New Code","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"See analysis details on SonarQube Cloud","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"See analysis details on SonarQube Cloud","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add or remove reactions","depth":15,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"@LakyLak","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"JY-20725","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"code review suggestions","depth":14,"on_screen":false,"help_text":"JY-20725 code review suggestions","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"code review suggestions","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"3 / 6 checks OK","depth":13,"bounds":{"left":0.16423611,"top":1.0,"width":0.011111111,"height":-0.087777734},"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"0f3e438","depth":14,"bounds":{"left":0.178125,"top":1.0,"width":0.035069443,"height":-0.04888892},"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"0f3e438","depth":15,"bounds":{"left":0.178125,"top":1.0,"width":0.035069443,"height":-0.04888892},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"This branch has not been deployed","depth":14,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"This branch has not been deployed","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"No deployments","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Merge info","depth":12,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Merge info","depth":13,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Review required","depth":13,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Review required","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"At least 1 approving review is required by reviewers with write access.","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Some checks haven't completed yet","depth":13,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Some checks haven't completed yet","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"2 pending, 1 in progress, 1 expected, 3 successful checks","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Collapse checks","depth":13,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXButton","text":"Collapse 3 pending checks group","depth":16,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"3 pending checks","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Checks settings","depth":16,"on_screen":false,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"pending checks","depth":19,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"pending checks","depth":20,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"ci/circleci: build-backend","depth":22,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"ci/circleci: build-backend","depth":23,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"ci/circleci: build-backend","depth":24,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Waiting for status to be reported","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"— CircleCI is running your tests","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions","depth":21,"on_screen":false,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"ci/circleci: build-frontend","depth":22,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"ci/circleci: build-frontend","depth":23,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"ci/circleci: build-frontend","depth":24,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Waiting for status to be reported","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"— CircleCI is running your tests","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions","depth":21,"on_screen":false,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"SonarCloud Code Analysis","depth":22,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"SonarCloud Code Analysis","depth":23,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Expected","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"— Waiting for status to be reported","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Required","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Collapse 1 in progress check group","depth":16,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true}]...
|
-2585452116530181406
|
-862845201072998522
|
visual_change
|
accessibility
|
NULL
|
New Tab
New Tab
Jy 20820 es reindex stream model h New Tab
New Tab
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS]
Show options
LakyLak commented 3 hours ago
LakyLak
LakyLak
commented
3 hours ago
3 hours ago
JIRA: JY-20725
JIRA:
JY-20725
JY-20725
Changes:
Changes:
Adds
HandleHubspotRateLimit
middleware (release jobs with delay)
Adds
executeRequest
wrapper in Hubspot Client. It handles 429 errors and throws
RateLimitException
Add or remove reactions
LakyLak
LakyLak
added
2
commits
3 hours ago
3 hours ago
@LakyLak
JY-20725
JY-20725
add HS rate limit handling on activities rematching
add HS rate limit handling on activities rematching
8 / 10 checks OK
44d5678
44d5678
@LakyLak
JY-20725
JY-20725
add tests
add tests
11 / 12 checks OK
cbed2c9
cbed2c9
@sonarqubecloud
Show options
sonarqubecloud Bot commented 2 hours ago
sonarqubecloud
sonarqubecloud
Bot
commented
2 hours ago
2 hours ago
Quality Gate Failed Quality Gate failed
Quality Gate Failed
Quality Gate failed
Failed conditions
34.4% Coverage on New Code
34.4% Coverage on New Code
(required ≥ 70%)
See analysis details on SonarQube Cloud
See analysis details on SonarQube Cloud
Add or remove reactions
@LakyLak
Show options
Author
LakyLak commented 2 hours ago
LakyLak
LakyLak
commented
2 hours ago
2 hours ago
@claude
@claude
Add or remove reactions
react with eyes
👀
1
@claude
Show options
claude Bot commented 2 hours ago •
claude
claude
Bot
commented
2 hours ago
2 hours ago
•
edited
edited
Claude finished
@LakyLak
@LakyLak
's task in 1m 42s
——
View job
View job
Code Review
Code Review
Completed task
Gathering context
Completed task
Reading changed files
Completed task
Analyzing code quality
Completed task
Posting review feedback
Overall this is a well-thought-out implementation. The circuit-breaker pattern using Redis NX, the jitter for thundering-herd prevention, and the log-sampling strategy are all solid. A few issues worth addressing before merge:
Issues
Issues
1. Batch API calls bypass rate-limit handling
batchReadObjects()
in
Client.php
(lines 296–319) calls the HubSpot batch APIs directly without going through
executeRequest()
. If the batch endpoints return 429s, those exceptions will not set the Redis circuit-breaker and will not be wrapped in
RateLimitException
, so the middleware won't catch them.
// batchReadObjects — no executeRequest() wrapping
$
response
=
$
batchConfig
[
'
api
'
]->
read
(
$
batchReadRequest
);
Copy
Given the search endpoints can also hit rate limits during pagination-heavy rematching, the batch calls could too. Consider wrapping the batch call similarly, or at least catching 429 from batch APIs and converting them to
RateLimitException
.
Fix this →
Fix this →
2. Circuit-breaker key is scoped to CRM config ID, not HubSpot portal
getRateLimitCacheKey()
(
Client.php:120
) returns
hubspot:ratelimit:portal:{configId}
, but
$this->config->getId()
is the CRM
configuration
record ID, not the HubSpot portal/account ID. If multiple CRM configuration records share the same HubSpot portal (e.g. re-connections), each will have its own independent circuit-breaker. A 429 received via config A will not suppress requests from config B even though they share the same quota.
If configs are always 1:1 with portals this is fine, but it's worth confirming or renaming the key to avoid misleading future readers:
// If config IDs == portal IDs, rename to make intent clear:
return
sprintf
(
'
hubspot:ratelimit:config:%d
'
,
$
this
->
config
->
getId
());
Copy
3.
isHubspotRateLimit()
and
parseRetryAfter()
are
public
but not in the interface
These two methods (
Client.php:123
,
137
) are an internal concern of the rate-limit handling, but they are
public
. Nothing outside
Client
needs to call them (the pagination service calls
isUnauthorizedException
, not these). Making them
private
or at least
protected
avoids callers depending on them in tests or subclasses. The only reason to keep them public would be if they're tested in isolation — but they currently have no direct tests at all.
4. Missing test coverage for
executeRequest()
,
isHubspotRateLimit()
,
parseRetryAfter()
SonarCloud is already flagging 34.4% coverage. The three methods above — which contain the core logic of this PR — have zero test coverage. In particular:
parseRetryAfter()
: the
Retry-After
header parsing, fallback to message keyword matching, and the default-10-second case
isHubspotRateLimit()
: each of the exception type branches
executeRequest()
: the cached circuit-breaker path (early
throw
when Redis key exists) and the
SET NX
path when a live 429 is received
These are the most impactful paths to cover to get over the 70% gate.
Minor observations
Minor observations
5. Test constants duplicated from production code
HandleHubspotRateLimitTest.php
re-declares
MAX_RETRY_DELAY = 600
,
MIN_RETRY_DELAY = 1
, and
JITTER_SECONDS = 5
(lines 20–22). If these ever change in the middleware, the tests will silently use the old values and could still pass while testing the wrong bounds. Consider testing relative to the actual delay value returned rather than hard-coding the expected range.
6. Double-clamping in
RateLimitException
The constructor default
$retryAfter = 1
(line 13) and
getRetryAfter()
calling
max($this->retryAfter, 1)
(line 21) both guard against zero/negative values. The
max()
in the getter is unreachable given the current signature default. Not a bug, but a minor inconsistency.
7.
RateLimitException
in
MatchActivityCrmData::failed()
The
failed()
hook (line 172) checks
$exception instanceof RateLimitException
, but the middleware always calls
$job->release()
without re-throwing, so
failed()
would only fire for rate limit exceptions if the
retryUntil()
window expires with a pending rate-limit release, or if somehow the middleware is not applied. The check is defensive and harmless, but worth noting in a comment since the path is non-obvious.
Add or remove reactions
@LakyLak
JY-20725
JY-20725
add more tests
add more tests
12 / 12 checks OK
02d5214
02d5214
@sonarqubecloud
Show options
sonarqubecloud Bot commented 1 hour ago
sonarqubecloud
sonarqubecloud
Bot
commented
1 hour ago
1 hour ago
Quality Gate Passed Quality Gate passed
Quality Gate Passed
Quality Gate passed
Issues
0 New issues
0 New issues
0 Accepted issues
0 Accepted issues
Measures
0 Security Hotspots
0 Security Hotspots
94.8% Coverage on New Code
94.8% Coverage on New Code
0.0% Duplication on New Code
0.0% Duplication on New Code
See analysis details on SonarQube Cloud
See analysis details on SonarQube Cloud
Add or remove reactions
@LakyLak
JY-20725
JY-20725
code review suggestions
code review suggestions
3 / 6 checks OK
0f3e438
0f3e438
This branch has not been deployed
This branch has not been deployed
No deployments
Merge info
Merge info
Review required
Review required
At least 1 approving review is required by reviewers with write access.
Some checks haven't completed yet
Some checks haven't completed yet
2 pending, 1 in progress, 1 expected, 3 successful checks
Collapse checks
Collapse 3 pending checks group
3 pending checks
Checks settings
pending checks
pending checks
ci/circleci: build-backend
ci/circleci: build-backend
ci/circleci: build-backend
Waiting for status to be reported
— CircleCI is running your tests
More actions
ci/circleci: build-frontend
ci/circleci: build-frontend
ci/circleci: build-frontend
Waiting for status to be reported
— CircleCI is running your tests
More actions
SonarCloud Code Analysis
SonarCloud Code Analysis
Expected
— Waiting for status to be reported
Required
Collapse 1 in progress check group...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20661
|
899
|
8
|
2026-05-11T16:08:27.844597+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515707844_m2.jpg...
|
Firefox
|
Work — Mozilla Firefox
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Pipelines - jiminny/app
app.circleci.com
New Tab
N Pipelines - jiminny/app
app.circleci.com
New Tab
New Tab
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app
JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
Close tab
New Tab
New Tab
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Mozilla Firefox
Search with Google or enter address
Search with Google or enter address
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Open context menu for Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Jiminny
Jiminny
Open context menu for Jiminny
Pipelines - jiminny/app
Pipelines - jiminny/app
Open context menu for Pipelines - jiminny/app
Inbox (1,620) - [EMAIL] - Jiminny Mail
Inbox (1,620) - [EMAIL] - Jiminny Mail
Open context menu for Inbox (1,620) - [EMAIL] - Jiminny Mail
Jiminny
Jiminny
Open context menu for Jiminny
Userpilot | Events
Userpilot | Events
Open context menu for Userpilot | Events
JY-20543 add AJ reports User pilot tracking by LakyLak · Pull Request #11932 · jiminny/app
JY-20543 add AJ reports User pilot tracking by LakyLak · Pull Request #11932 · jiminny/app
Open context menu for JY-20543 add AJ reports User pilot tracking by LakyLak · Pull Request #11932 · jiminny/app
Meet - Daily - Platform
Meet - Daily - Platform
Open context menu for Meet - Daily - Platform
Customize
Customize...
|
[{"role":"AXStaticText","text& [{"role":"AXStaticText","text":"Pipelines - jiminny/app","depth":4,"bounds":{"left":0.08361037,"top":0.24501197,"width":0.041223403,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"app.circleci.com","depth":4,"bounds":{"left":0.08361037,"top":0.25578612,"width":0.028590426,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"New Tab","depth":4,"bounds":{"left":0.0,"top":0.074221864,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"New Tab","depth":5,"bounds":{"left":0.013297873,"top":0.08539505,"width":0.014960106,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.10694334,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.11811652,"width":0.16888298,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira","depth":4,"bounds":{"left":0.0,"top":0.1396648,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.15083799,"width":0.16140293,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":4,"bounds":{"left":0.0,"top":0.17238627,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":5,"bounds":{"left":0.013297873,"top":0.18355946,"width":0.4644282,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.20510775,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.21628092,"width":0.18816489,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Pipelines - jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.23782921,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pipelines - jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.2490024,"width":0.039228722,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.06732048,"top":0.24501197,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"New Tab","depth":4,"bounds":{"left":0.0,"top":0.27055067,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"New Tab","depth":5,"bounds":{"left":0.013297873,"top":0.28172386,"width":0.014960106,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.06732048,"top":0.27773345,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.0028257978,"top":0.3048683,"width":0.07413564,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"bounds":{"left":0.0028257978,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"bounds":{"left":0.013796543,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Tabs from other devices","depth":6,"bounds":{"left":0.024933511,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open history (⇧⌘H)","depth":6,"bounds":{"left":0.036070477,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open bookmarks (⌘B)","depth":6,"bounds":{"left":0.04720745,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Mozilla Firefox","depth":8,"bounds":{"left":0.12549867,"top":0.40782124,"width":0.3287899,"height":0.051077414},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXButton","text":"Search with Google or enter address","depth":8,"bounds":{"left":0.17021276,"top":0.4828412,"width":0.2393617,"height":0.0415004},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Search with Google or enter address","depth":10,"bounds":{"left":0.18666889,"top":0.4960096,"width":0.08344415,"height":0.014764565},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira","depth":11,"bounds":{"left":0.13031915,"top":0.5482841,"width":0.039893616,"height":0.09736632},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira","depth":13,"bounds":{"left":0.1349734,"top":0.61731845,"width":0.030418882,"height":0.06384677},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open context menu for Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira","depth":11,"bounds":{"left":0.16223404,"top":0.55706304,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"Open menu","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Jiminny","depth":11,"bounds":{"left":0.17021276,"top":0.5482841,"width":0.039893616,"height":0.09736632},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jiminny","depth":13,"bounds":{"left":0.18234707,"top":0.61731845,"width":0.015458777,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open context menu for Jiminny","depth":11,"bounds":{"left":0.20212767,"top":0.55706304,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"Open menu","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Pipelines - jiminny/app","depth":11,"bounds":{"left":0.21010639,"top":0.5482841,"width":0.039893616,"height":0.09736632},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pipelines - jiminny/app","depth":13,"bounds":{"left":0.21858378,"top":0.61731845,"width":0.022772606,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open context menu for Pipelines - jiminny/app","depth":11,"bounds":{"left":0.24202128,"top":0.55706304,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"Open menu","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Inbox (1,620) - lukas.kovalik@jiminny.com - Jiminny Mail","depth":11,"bounds":{"left":0.25,"top":0.5482841,"width":0.039893616,"height":0.09736632},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Inbox (1,620) - lukas.kovalik@jiminny.com - Jiminny Mail","depth":13,"bounds":{"left":0.25465426,"top":0.61731845,"width":0.030418882,"height":0.051077414},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open context menu for Inbox (1,620) - lukas.kovalik@jiminny.com - Jiminny Mail","depth":11,"bounds":{"left":0.2819149,"top":0.55706304,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"Open menu","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Jiminny","depth":11,"bounds":{"left":0.28989363,"top":0.5482841,"width":0.039893616,"height":0.09736632},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jiminny","depth":13,"bounds":{"left":0.3020279,"top":0.61731845,"width":0.015458777,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open context menu for Jiminny","depth":11,"bounds":{"left":0.32180852,"top":0.55706304,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"Open menu","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Userpilot | Events","depth":11,"bounds":{"left":0.32978722,"top":0.5482841,"width":0.039893616,"height":0.09736632},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Userpilot | Events","depth":13,"bounds":{"left":0.3394282,"top":0.61731845,"width":0.02044548,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open context menu for Userpilot | Events","depth":11,"bounds":{"left":0.3617021,"top":0.55706304,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"Open menu","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"JY-20543 add AJ reports User pilot tracking by LakyLak · Pull Request #11932 · jiminny/app","depth":11,"bounds":{"left":0.36968085,"top":0.5482841,"width":0.039893616,"height":0.09736632},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20543 add AJ reports User pilot tracking by LakyLak · Pull Request #11932 · jiminny/app","depth":13,"bounds":{"left":0.375,"top":0.61731845,"width":0.029089095,"height":0.10215483},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open context menu for JY-20543 add AJ reports User pilot tracking by LakyLak · Pull Request #11932 · jiminny/app","depth":11,"bounds":{"left":0.40159574,"top":0.55706304,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"Open menu","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Meet - Daily - Platform","depth":11,"bounds":{"left":0.40957448,"top":0.5482841,"width":0.039893616,"height":0.09736632},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Meet - Daily - Platform","depth":13,"bounds":{"left":0.41555852,"top":0.61731845,"width":0.027759308,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open context menu for Meet - Daily - Platform","depth":11,"bounds":{"left":0.44148937,"top":0.55706304,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"Open menu","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Customize","depth":8,"bounds":{"left":0.47955453,"top":0.9509178,"width":0.012965426,"height":0.0311253},"on_screen":true,"help_text":"Customize this page","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Customize","depth":10,"bounds":{"left":0.48271278,"top":0.95929766,"width":0.024102394,"height":0.014764565},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
-6726011229187365688
|
-786650461871871865
|
visual_change
|
accessibility
|
NULL
|
Pipelines - jiminny/app
app.circleci.com
New Tab
N Pipelines - jiminny/app
app.circleci.com
New Tab
New Tab
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app
JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
Close tab
New Tab
New Tab
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Mozilla Firefox
Search with Google or enter address
Search with Google or enter address
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Open context menu for Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Jiminny
Jiminny
Open context menu for Jiminny
Pipelines - jiminny/app
Pipelines - jiminny/app
Open context menu for Pipelines - jiminny/app
Inbox (1,620) - [EMAIL] - Jiminny Mail
Inbox (1,620) - [EMAIL] - Jiminny Mail
Open context menu for Inbox (1,620) - [EMAIL] - Jiminny Mail
Jiminny
Jiminny
Open context menu for Jiminny
Userpilot | Events
Userpilot | Events
Open context menu for Userpilot | Events
JY-20543 add AJ reports User pilot tracking by LakyLak · Pull Request #11932 · jiminny/app
JY-20543 add AJ reports User pilot tracking by LakyLak · Pull Request #11932 · jiminny/app
Open context menu for JY-20543 add AJ reports User pilot tracking by LakyLak · Pull Request #11932 · jiminny/app
Meet - Daily - Platform
Meet - Daily - Platform
Open context menu for Meet - Daily - Platform
Customize
Customize...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20664
|
899
|
10
|
2026-05-11T16:08:36.999841+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515716999_m2.jpg...
|
Firefox
|
JY-20543 add AJ reports User pilot tracking by Lak JY-20543 add AJ reports User pilot tracking by LakyLak · Pull Request #11932 · jiminny/app — Work...
|
1
|
github.com/jiminny/app/pull/11932
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
New Tab
New Tab
Jy 20820 es reindex stream model h New Tab
New Tab
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app
JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
JY-20543 add AJ reports User pilot tracking by LakyLak · Pull Request #11932 · jiminny/app
JY-20543 add AJ reports User pilot tracking by LakyLak · Pull Request #11932 · jiminny/app
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Skip to content
Skip to content
Open menu
Homepage (g then d)
jiminny
jiminny
app
app
Search or jump to…
Type
/
to search
Chat with Copilot
Open Copilot…
Create new...
All issues(g then i)
All pull requests
All repositories
You have unread notifications(g then n)
Open user navigation menu
Repository navigation
Repository navigation
Code
Code
Pull requests (35)
Pull requests
(
35
)
Agents
Agents
Actions
Actions
Wiki
Wiki
Security and quality (4)
Security and quality
(...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"New Tab","depth":4,"bounds":{"left":0.0,"top":0.0518755,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"New Tab","depth":5,"bounds":{"left":0.013297873,"top":0.06304868,"width":0.014960106,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.08459697,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.09577015,"width":0.16888298,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira","depth":4,"bounds":{"left":0.0,"top":0.11731844,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.12849163,"width":0.16140293,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":4,"bounds":{"left":0.0,"top":0.15003991,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":5,"bounds":{"left":0.013297873,"top":0.16121309,"width":0.4644282,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.18276137,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.19393456,"width":0.18816489,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Pipelines - jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.21548285,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pipelines - jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.22665602,"width":0.039228722,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20543 add AJ reports User pilot tracking by LakyLak · Pull Request #11932 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.2482043,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"JY-20543 add AJ reports User pilot tracking by LakyLak · Pull Request #11932 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.25937748,"width":0.15924202,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.06732048,"top":0.25538707,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.0028257978,"top":0.28252193,"width":0.07413564,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"bounds":{"left":0.0028257978,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"bounds":{"left":0.013796543,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Tabs from other devices","depth":6,"bounds":{"left":0.024933511,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open history (⇧⌘H)","depth":6,"bounds":{"left":0.036070477,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open bookmarks (⌘B)","depth":6,"bounds":{"left":0.04720745,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Skip to content","depth":6,"bounds":{"left":0.07962101,"top":0.0518755,"width":0.0003324468,"height":0.0007980846},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Skip to content","depth":7,"bounds":{"left":0.07962101,"top":0.05347167,"width":0.0029920214,"height":0.21468475},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open menu","depth":10,"bounds":{"left":0.08494016,"top":0.06464485,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Homepage (g then d)","depth":9,"bounds":{"left":0.099567816,"top":0.06464485,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"jiminny","depth":12,"bounds":{"left":0.112865694,"top":0.06464485,"width":0.018949468,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"jiminny","depth":14,"bounds":{"left":0.11486037,"top":0.07063048,"width":0.014960106,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"app","depth":12,"bounds":{"left":0.13680187,"top":0.06464485,"width":0.017785905,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"app","depth":14,"bounds":{"left":0.13879654,"top":0.07063048,"width":0.008477394,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Search or jump to…","depth":9,"bounds":{"left":0.31698802,"top":0.06464485,"width":0.06565824,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Type","depth":12,"bounds":{"left":0.32928857,"top":0.07063048,"width":0.011801862,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":12,"bounds":{"left":0.34242022,"top":0.07222666,"width":0.002493351,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"to search","depth":12,"bounds":{"left":0.3464096,"top":0.07063048,"width":0.021276595,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Chat with Copilot","depth":10,"bounds":{"left":0.38464096,"top":0.06464485,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXMenuButton","text":"Open Copilot…","depth":9,"bounds":{"left":0.3949468,"top":0.06464485,"width":0.008643617,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXMenuButton","text":"Create new...","depth":9,"bounds":{"left":0.41156915,"top":0.06464485,"width":0.01662234,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"All issues(g then i)","depth":9,"bounds":{"left":0.43085107,"top":0.06464485,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"All pull requests","depth":9,"bounds":{"left":0.44414893,"top":0.06464485,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"All repositories","depth":9,"bounds":{"left":0.4574468,"top":0.06464485,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"You have unread notifications(g then n)","depth":9,"bounds":{"left":0.47074467,"top":0.06464485,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Open user navigation menu","depth":9,"bounds":{"left":0.48404256,"top":0.06464485,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Repository navigation","depth":9,"bounds":{"left":0.079288565,"top":0.051077414,"width":0.0003324468,"height":0.0007980846},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Repository navigation","depth":10,"bounds":{"left":0.079288565,"top":0.05387071,"width":0.0787899,"height":0.023144454},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Code","depth":12,"bounds":{"left":0.08494016,"top":0.09936153,"width":0.025099734,"height":0.026336791},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Code","depth":14,"bounds":{"left":0.095744684,"top":0.10574621,"width":0.011469414,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Pull requests (35)","depth":12,"bounds":{"left":0.11269947,"top":0.09936153,"width":0.05501995,"height":0.026336791},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pull requests","depth":14,"bounds":{"left":0.12333777,"top":0.10574621,"width":0.02925532,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":14,"bounds":{"left":0.15525267,"top":0.113727055,"width":0.0029920214,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"35","depth":14,"bounds":{"left":0.15824468,"top":0.113727055,"width":0.0056515955,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":14,"bounds":{"left":0.16389628,"top":0.113727055,"width":0.0018284575,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Agents","depth":12,"bounds":{"left":0.17037898,"top":0.09936153,"width":0.02925532,"height":0.026336791},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Agents","depth":14,"bounds":{"left":0.18151596,"top":0.10574621,"width":0.014960106,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Actions","depth":12,"bounds":{"left":0.20229389,"top":0.09936153,"width":0.030086435,"height":0.026336791},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Actions","depth":14,"bounds":{"left":0.21326463,"top":0.10574621,"width":0.016123671,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Wiki","depth":12,"bounds":{"left":0.23503989,"top":0.09936153,"width":0.023105053,"height":0.026336791},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Wiki","depth":14,"bounds":{"left":0.24601063,"top":0.10574621,"width":0.009142287,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Security and quality (4)","depth":12,"bounds":{"left":0.26080453,"top":0.09936153,"width":0.06815159,"height":0.026336791},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Security and quality","depth":14,"bounds":{"left":0.27244017,"top":0.10574621,"width":0.042719416,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":14,"bounds":{"left":0.31881648,"top":0.113727055,"width":0.0029920214,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
8508480138753874465
|
-2984387012864183670
|
visual_change
|
accessibility
|
NULL
|
New Tab
New Tab
Jy 20820 es reindex stream model h New Tab
New Tab
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app
JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
JY-20543 add AJ reports User pilot tracking by LakyLak · Pull Request #11932 · jiminny/app
JY-20543 add AJ reports User pilot tracking by LakyLak · Pull Request #11932 · jiminny/app
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Skip to content
Skip to content
Open menu
Homepage (g then d)
jiminny
jiminny
app
app
Search or jump to…
Type
/
to search
Chat with Copilot
Open Copilot…
Create new...
All issues(g then i)
All pull requests
All repositories
You have unread notifications(g then n)
Open user navigation menu
Repository navigation
Repository navigation
Code
Code
Pull requests (35)
Pull requests
(
35
)
Agents
Agents
Actions
Actions
Wiki
Wiki
Security and quality (4)
Security and quality
(...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20669
|
899
|
13
|
2026-05-11T16:08:43.011042+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515723011_m2.jpg...
|
Firefox
|
JY-20725 add HS rate limit handling on activities JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app — Work...
|
1
|
github.com/jiminny/app/pull/12066
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
New Tab
New Tab
Jy 20820 es reindex stream model h New Tab
New Tab
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS]
Show options
LakyLak commented 3 hours ago
LakyLak
LakyLak
commented
3 hours ago
3 hours ago
JIRA: JY-20725
JIRA:
JY-20725
JY-20725
Changes:
Changes:
Adds
HandleHubspotRateLimit
middleware (release jobs with delay)
Adds
executeRequest
wrapper in Hubspot Client. It handles 429 errors and throws
RateLimitException
Add or remove reactions
LakyLak
LakyLak
added
2
commits
3 hours ago
3 hours ago
@LakyLak
JY-20725
JY-20725
add HS rate limit handling on activities rematching
add HS rate limit handling on activities rematching
8 / 10 checks OK
44d5678
44d5678
@LakyLak
JY-20725
JY-20725
add tests
add tests
11 / 12 checks OK
cbed2c9
cbed2c9
@sonarqubecloud
Show options
sonarqubecloud Bot commented 2 hours ago
sonarqubecloud
sonarqubecloud
Bot
commented
2 hours ago
2 hours ago
Quality Gate Failed Quality Gate failed
Quality Gate Failed
Quality Gate failed
Failed conditions
34.4% Coverage on New Code
34.4% Coverage on New Code
(required ≥ 70%)
See analysis details on SonarQube Cloud
See analysis details on SonarQube Cloud
Add or remove reactions
@LakyLak
Show options
Author
LakyLak commented 2 hours ago
LakyLak
LakyLak
commented
2 hours ago
2 hours ago
@claude
@claude
Add or remove reactions
react with eyes
👀
1
@claude
Show options
claude Bot commented 2 hours ago •
claude
claude
Bot
commented
2 hours ago
2 hours ago
•
edited
edited
Claude finished
@LakyLak
@LakyLak
's task in 1m 42s
——
View job
View job
Code Review
Code Review
Completed task
Gathering context
Completed task
Reading changed files
Completed task
Analyzing code quality
Completed task
Posting review feedback
Overall this is a well-thought-out implementation. The circuit-breaker pattern using Redis NX, the jitter for thundering-herd prevention, and the log-sampling strategy are all solid. A few issues worth addressing before merge:
Issues
Issues
1. Batch API calls bypass rate-limit handling
batchReadObjects()
in
Client.php
(lines 296–319) calls the HubSpot batch APIs directly without going through
executeRequest()
. If the batch endpoints return 429s, those exceptions will not set the Redis circuit-breaker and will not be wrapped in
RateLimitException
, so the middleware won't catch them.
// batchReadObjects — no executeRequest() wrapping
$
response
=
$
batchConfig
[
'
api
'
]->
read
(
$
batchReadRequest
);
Copy
Given the search endpoints can also hit rate limits during pagination-heavy rematching, the batch calls could too. Consider wrapping the batch call similarly, or at least catching 429 from batch APIs and converting them to
RateLimitException
.
Fix this →
Fix this →
2. Circuit-breaker key is scoped to CRM config ID, not HubSpot portal
getRateLimitCacheKey()
(
Client.php:120
) returns
hubspot:ratelimit:portal:{configId}
, but
$this->config->getId()
is the CRM
configuration
record ID, not the HubSpot portal/account ID. If multiple CRM configuration records share the same HubSpot portal (e.g. re-connections), each will have its own independent circuit-breaker. A 429 received via config A will not suppress requests from config B even though they share the same quota.
If configs are always 1:1 with portals this is fine, but it's worth confirming or renaming the key to avoid misleading future readers:
// If config IDs == portal IDs, rename to make intent clear:
return
sprintf
(
'
hubspot:ratelimit:config:%d
'
,
$
this
->
config
->
getId
());
Copy
3.
isHubspotRateLimit()
and
parseRetryAfter()
are
public
but not in the interface
These two methods (
Client.php:123
,
137
) are an internal concern of the rate-limit handling, but they are
public
. Nothing outside
Client
needs to call them (the pagination service calls
isUnauthorizedException
, not these). Making them
private
or at least
protected
avoids callers depending on them in tests or subclasses. The only reason to keep them public would be if they're tested in isolation — but they currently have no direct tests at all.
4. Missing test coverage for
executeRequest()
,
isHubspotRateLimit()
,
parseRetryAfter()
SonarCloud is already flagging 34.4% coverage. The three methods above — which contain the core logic of this PR — have zero test coverage. In particular:
parseRetryAfter()
: the
Retry-After
header parsing, fallback to message keyword matching, and the default-10-second case
isHubspotRateLimit()
: each of the exception type branches
executeRequest()
: the cached circuit-breaker path (early
throw
when Redis key exists) and the
SET NX
path when a live 429 is received
These are the most impactful paths to cover to get over the 70% gate.
Minor observations
Minor observations
5. Test constants duplicated from production code
HandleHubspotRateLimitTest.php
re-declares
MAX_RETRY_DELAY = 600...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"New Tab","depth":4,"bounds":{"left":0.0,"top":0.0518755,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"New Tab","depth":5,"bounds":{"left":0.013297873,"top":0.06304868,"width":0.014960106,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.08459697,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.09577015,"width":0.16888298,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira","depth":4,"bounds":{"left":0.0,"top":0.11731844,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.12849163,"width":0.16140293,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":4,"bounds":{"left":0.0,"top":0.15003991,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":5,"bounds":{"left":0.013297873,"top":0.16121309,"width":0.4644282,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.18276137,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.19393456,"width":0.18816489,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.06732048,"top":0.18994413,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"Pipelines - jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.21548285,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pipelines - jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.22665602,"width":0.039228722,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Pull requests · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.2482043,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pull requests · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.25937748,"width":0.04537899,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.0028257978,"top":0.28252193,"width":0.07413564,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"bounds":{"left":0.0028257978,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"bounds":{"left":0.013796543,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Tabs from other devices","depth":6,"bounds":{"left":0.024933511,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open history (⇧⌘H)","depth":6,"bounds":{"left":0.036070477,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open bookmarks (⌘B)","depth":6,"bounds":{"left":0.04720745,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Skip to content","depth":6,"bounds":{"left":0.07962101,"top":0.0518755,"width":0.0003324468,"height":0.0007980846},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Skip to content","depth":7,"bounds":{"left":0.07962101,"top":0.05347167,"width":0.0029920214,"height":0.21468475},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open menu","depth":10,"bounds":{"left":0.08494016,"top":0.06464485,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Homepage (g then d)","depth":9,"bounds":{"left":0.099567816,"top":0.06464485,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"jiminny","depth":12,"bounds":{"left":0.112865694,"top":0.06464485,"width":0.018949468,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"jiminny","depth":14,"bounds":{"left":0.11486037,"top":0.07063048,"width":0.014960106,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"app","depth":12,"bounds":{"left":0.13680187,"top":0.06464485,"width":0.017785905,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"app","depth":14,"bounds":{"left":0.13879654,"top":0.07063048,"width":0.008477394,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Search or jump to…","depth":9,"bounds":{"left":0.31698802,"top":0.06464485,"width":0.06565824,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Type","depth":12,"bounds":{"left":0.32928857,"top":0.07063048,"width":0.011801862,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":12,"bounds":{"left":0.34242022,"top":0.07222666,"width":0.002493351,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"to search","depth":12,"bounds":{"left":0.3464096,"top":0.07063048,"width":0.021276595,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Chat with Copilot","depth":10,"bounds":{"left":0.38464096,"top":0.06464485,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXMenuButton","text":"Open Copilot…","depth":9,"bounds":{"left":0.3949468,"top":0.06464485,"width":0.008643617,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXMenuButton","text":"Create new...","depth":9,"bounds":{"left":0.41156915,"top":0.06464485,"width":0.01662234,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"All issues(g then i)","depth":9,"bounds":{"left":0.43085107,"top":0.06464485,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"All pull requests","depth":9,"bounds":{"left":0.44414893,"top":0.06464485,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"All repositories","depth":9,"bounds":{"left":0.4574468,"top":0.06464485,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"You have unread notifications(g then n)","depth":9,"bounds":{"left":0.47074467,"top":0.06464485,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Open user navigation menu","depth":9,"bounds":{"left":0.48404256,"top":0.06464485,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Repository navigation","depth":9,"bounds":{"left":0.079288565,"top":0.051077414,"width":0.0003324468,"height":0.0007980846},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Repository navigation","depth":10,"bounds":{"left":0.079288565,"top":0.05387071,"width":0.0787899,"height":0.023144454},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Code","depth":12,"bounds":{"left":0.08494016,"top":0.09936153,"width":0.025099734,"height":0.026336791},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Code","depth":14,"bounds":{"left":0.095744684,"top":0.10574621,"width":0.011469414,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Pull requests (34)","depth":12,"bounds":{"left":0.11269947,"top":0.09936153,"width":0.05518617,"height":0.026336791},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pull requests","depth":14,"bounds":{"left":0.12333777,"top":0.10574621,"width":0.02925532,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":14,"bounds":{"left":0.15525267,"top":0.113727055,"width":0.0029920214,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"34","depth":14,"bounds":{"left":0.15824468,"top":0.113727055,"width":0.005817819,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":14,"bounds":{"left":0.1640625,"top":0.113727055,"width":0.0016622341,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Agents","depth":12,"bounds":{"left":0.1705452,"top":0.09936153,"width":0.029089095,"height":0.026336791},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Agents","depth":14,"bounds":{"left":0.18151596,"top":0.10574621,"width":0.01512633,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Actions","depth":12,"bounds":{"left":0.20229389,"top":0.09936153,"width":0.03025266,"height":0.026336791},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Actions","depth":14,"bounds":{"left":0.21343085,"top":0.10574621,"width":0.015957447,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Wiki","depth":12,"bounds":{"left":0.23520611,"top":0.09936153,"width":0.022938829,"height":0.026336791},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Wiki","depth":14,"bounds":{"left":0.24617687,"top":0.10574621,"width":0.009142287,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Security and quality (4)","depth":12,"bounds":{"left":0.26080453,"top":0.09936153,"width":0.06815159,"height":0.026336791},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Security and quality","depth":14,"bounds":{"left":0.27260637,"top":0.10574621,"width":0.04255319,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":14,"bounds":{"left":0.31898272,"top":0.113727055,"width":0.0029920214,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"4","depth":14,"bounds":{"left":0.32197472,"top":0.113727055,"width":0.0029920214,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":14,"bounds":{"left":0.32496676,"top":0.113727055,"width":0.0016622341,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Insights","depth":12,"bounds":{"left":0.3316157,"top":0.09936153,"width":0.03125,"height":0.026336791},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Insights","depth":14,"bounds":{"left":0.34275267,"top":0.10574621,"width":0.016954787,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Settings","depth":12,"bounds":{"left":0.36552528,"top":0.09936153,"width":0.032081116,"height":0.026336791},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Settings","depth":14,"bounds":{"left":0.37666222,"top":0.10574621,"width":0.017785905,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Important update","depth":10,"bounds":{"left":0.09325133,"top":0.14365523,"width":0.0003324468,"height":0.016759777},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Important update","depth":11,"bounds":{"left":0.09325133,"top":0.1452514,"width":0.039228722,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"On April 24 we'll start using GitHub Copilot interaction data for AI model training unless you opt out.","depth":10,"bounds":{"left":0.09325133,"top":0.1452514,"width":0.2159242,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Review this update","depth":10,"bounds":{"left":0.30917552,"top":0.1452514,"width":0.04055851,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Review this update","depth":11,"bounds":{"left":0.30917552,"top":0.1452514,"width":0.04055851,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"and manage your preferences in your","depth":10,"bounds":{"left":0.34973404,"top":0.1452514,"width":0.08261303,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"GitHub account settings","depth":10,"bounds":{"left":0.09325133,"top":0.1452514,"width":0.3728391,"height":0.030327214},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"GitHub account settings","depth":11,"bounds":{"left":0.09325133,"top":0.1452514,"width":0.3728391,"height":0.030327214},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":10,"bounds":{"left":0.110538565,"top":0.16201118,"width":0.0013297872,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Dismiss banner","depth":9,"bounds":{"left":0.48636967,"top":0.13886672,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"JY-20725 add HS rate limit handling on activities rematching #12066 Edit title","depth":13,"bounds":{"left":0.090259306,"top":0.20830008,"width":0.31366357,"height":0.06384677},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching","depth":14,"bounds":{"left":0.090259306,"top":0.20909816,"width":0.27543217,"height":0.030327214},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"#","depth":15,"bounds":{"left":0.09291888,"top":0.24102154,"width":0.006482713,"height":0.030327214},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"12066","depth":15,"bounds":{"left":0.09940159,"top":0.24102154,"width":0.030418882,"height":0.030327214},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Edit title","depth":14,"bounds":{"left":0.13115026,"top":0.2434158,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Checks pending","depth":13,"bounds":{"left":0.40658244,"top":0.2150838,"width":0.051861703,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Checks pending","depth":15,"bounds":{"left":0.41888297,"top":0.22106944,"width":0.03523936,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Code","depth":13,"bounds":{"left":0.46110374,"top":0.2150838,"width":0.02825798,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Code","depth":15,"bounds":{"left":0.46542552,"top":0.22106944,"width":0.011635638,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Open","depth":13,"bounds":{"left":0.1008976,"top":0.28451717,"width":0.011968086,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"LakyLak","depth":15,"bounds":{"left":0.11951463,"top":0.28132483,"width":0.01861702,"height":0.016759777},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":16,"bounds":{"left":0.11951463,"top":0.282921,"width":0.01861702,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"wants to merge 4 commits into","depth":15,"bounds":{"left":0.13946144,"top":0.282921,"width":0.06648936,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"master","depth":15,"bounds":{"left":0.20728059,"top":0.28092578,"width":0.018450798,"height":0.017557861},"on_screen":true,"role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"master","depth":16,"bounds":{"left":0.20927526,"top":0.28411812,"width":0.014461436,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"from","depth":16,"bounds":{"left":0.22706117,"top":0.282921,"width":0.009973404,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"JY-20725-handle-HS-search-rate-limit","depth":16,"bounds":{"left":0.23836437,"top":0.28092578,"width":0.090259306,"height":0.017557861},"on_screen":true,"role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit","depth":17,"bounds":{"left":0.24035904,"top":0.28411812,"width":0.086269945,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy head branch name to clipboard","depth":16,"bounds":{"left":0.32995346,"top":0.27853152,"width":0.00930851,"height":0.022346368},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Lines changed: 757 additions & 249 deletions","depth":14,"bounds":{"left":0.45212767,"top":0.3347965,"width":0.019946808,"height":0.11412609},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Conversation (3)","depth":16,"bounds":{"left":0.090259306,"top":0.31683958,"width":0.054853722,"height":0.031923383},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"Conversation","depth":17,"bounds":{"left":0.10255984,"top":0.3264166,"width":0.02825798,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"bounds":{"left":0.14079122,"top":0.3264166,"width":0.0029920214,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"3","depth":18,"bounds":{"left":0.14378324,"top":0.3264166,"width":0.0028257978,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"bounds":{"left":0.14660904,"top":0.3264166,"width":0.0016622341,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Commits (4)","depth":16,"bounds":{"left":0.14511304,"top":0.31683958,"width":0.045545213,"height":0.031923383},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Commits","depth":17,"bounds":{"left":0.15741356,"top":0.3264166,"width":0.018949468,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"bounds":{"left":0.18633644,"top":0.3264166,"width":0.0029920214,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"4","depth":18,"bounds":{"left":0.18932846,"top":0.3264166,"width":0.0029920214,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"bounds":{"left":0.19232048,"top":0.3264166,"width":0.0018284575,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Checks (2)","depth":16,"bounds":{"left":0.19065824,"top":0.31683958,"width":0.042386968,"height":0.031923383},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Checks","depth":17,"bounds":{"left":0.20295878,"top":0.3264166,"width":0.015957447,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"bounds":{"left":0.2287234,"top":0.3264166,"width":0.0029920214,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"2","depth":18,"bounds":{"left":0.23171543,"top":0.3264166,"width":0.0028257978,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"bounds":{"left":0.23454122,"top":0.3264166,"width":0.0016622341,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Files changed (12)","depth":16,"bounds":{"left":0.2330452,"top":0.31683958,"width":0.05817819,"height":0.031923383},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Files changed","depth":17,"bounds":{"left":0.24534574,"top":0.3264166,"width":0.029753989,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"bounds":{"left":0.2869016,"top":0.3264166,"width":0.0029920214,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"12","depth":18,"bounds":{"left":0.28989363,"top":0.3264166,"width":0.004986702,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"bounds":{"left":0.29488033,"top":0.3264166,"width":0.0016622341,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Conversation","depth":12,"bounds":{"left":0.090259306,"top":0.3623304,"width":0.0003324468,"height":0.0007980846},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Conversation","depth":13,"bounds":{"left":0.090259306,"top":0.3651237,"width":0.04837101,"height":0.023144454},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@LakyLak","depth":12,"bounds":{"left":0.090259306,"top":0.3623304,"width":0.013297873,"height":0.031923383},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":15,"bounds":{"left":0.3587101,"top":0.36312848,"width":0.007978723,"height":0.02952913},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"LakyLak commented 3 hours ago","depth":14,"bounds":{"left":0.114527926,"top":0.36312848,"width":0.23620346,"height":0.02952913},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"LakyLak","depth":16,"bounds":{"left":0.114527926,"top":0.37110934,"width":0.01861702,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":17,"bounds":{"left":0.114527926,"top":0.37110934,"width":0.01861702,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":15,"bounds":{"left":0.13447474,"top":0.37110934,"width":0.025598405,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"3 hours ago","depth":15,"bounds":{"left":0.16140293,"top":0.36951315,"width":0.025598405,"height":0.016759777},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"3 hours ago","depth":17,"bounds":{"left":0.16140293,"top":0.37110934,"width":0.025598405,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"JIRA: JY-20725","depth":16,"bounds":{"left":0.114527926,"top":0.40622506,"width":0.2521609,"height":0.017557861},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"JIRA:","depth":17,"bounds":{"left":0.114527926,"top":0.4066241,"width":0.015957447,"height":0.016759777},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"JY-20725","depth":17,"bounds":{"left":0.13048537,"top":0.4066241,"width":0.026928192,"height":0.016759777},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725","depth":18,"bounds":{"left":0.13048537,"top":0.4066241,"width":0.026928192,"height":0.016759777},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Changes:","depth":16,"bounds":{"left":0.114527926,"top":0.44293696,"width":0.2521609,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Changes:","depth":17,"bounds":{"left":0.114527926,"top":0.44293696,"width":0.021276595,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Adds","depth":18,"bounds":{"left":0.12250665,"top":0.47126895,"width":0.012300532,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"HandleHubspotRateLimit","depth":19,"bounds":{"left":0.13480718,"top":0.47126895,"width":0.056349736,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"middleware (release jobs with delay)","depth":18,"bounds":{"left":0.19115691,"top":0.47126895,"width":0.07978723,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Adds","depth":18,"bounds":{"left":0.12250665,"top":0.49082202,"width":0.012300532,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"executeRequest","depth":19,"bounds":{"left":0.13480718,"top":0.49082202,"width":0.036070477,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"wrapper in Hubspot Client. It handles 429 errors and throws","depth":18,"bounds":{"left":0.17087767,"top":0.49082202,"width":0.1314827,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"RateLimitException","depth":19,"bounds":{"left":0.3023604,"top":0.49082202,"width":0.043550532,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add or remove reactions","depth":16,"bounds":{"left":0.114527926,"top":0.519154,"width":0.008643617,"height":0.0207502},"on_screen":true,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"LakyLak","depth":14,"bounds":{"left":0.12250665,"top":0.584597,"width":0.01861702,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":15,"bounds":{"left":0.12250665,"top":0.584597,"width":0.01861702,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"added","depth":14,"bounds":{"left":0.14112367,"top":0.584597,"width":0.016123671,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"2","depth":14,"bounds":{"left":0.15724733,"top":0.584597,"width":0.0028257978,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commits","depth":14,"bounds":{"left":0.16007313,"top":0.584597,"width":0.020944148,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"3 hours ago","depth":14,"bounds":{"left":0.1810173,"top":0.584597,"width":0.025598405,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"3 hours ago","depth":16,"bounds":{"left":0.1810173,"top":0.584597,"width":0.025598405,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@LakyLak","depth":12,"bounds":{"left":0.12250665,"top":0.62450117,"width":0.0066489363,"height":0.015961692},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"JY-20725","depth":14,"bounds":{"left":0.13115026,"top":0.6280926,"width":0.019115692,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725","depth":15,"bounds":{"left":0.13115026,"top":0.6280926,"width":0.019115692,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"add HS rate limit handling on activities rematching","depth":14,"bounds":{"left":0.15275931,"top":0.6280926,"width":0.122340426,"height":0.011572227},"on_screen":true,"help_text":"JY-20725 add HS rate limit handling on activities rematching","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"add HS rate limit handling on activities rematching","depth":15,"bounds":{"left":0.15275931,"top":0.6280926,"width":0.122340426,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"8 / 10 checks OK","depth":14,"bounds":{"left":0.3489029,"top":0.62450117,"width":0.005319149,"height":0.016759777},"on_screen":true,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"44d5678","depth":14,"bounds":{"left":0.35555187,"top":0.6280926,"width":0.016788565,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"44d5678","depth":15,"bounds":{"left":0.35555187,"top":0.6280926,"width":0.016788565,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@LakyLak","depth":12,"bounds":{"left":0.12250665,"top":0.6660016,"width":0.0066489363,"height":0.015961692},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"JY-20725","depth":14,"bounds":{"left":0.13115026,"top":0.669593,"width":0.019115692,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725","depth":15,"bounds":{"left":0.13115026,"top":0.669593,"width":0.019115692,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"add tests","depth":14,"bounds":{"left":0.15275931,"top":0.669593,"width":0.021609042,"height":0.011572227},"on_screen":true,"help_text":"JY-20725 add tests","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"add tests","depth":15,"bounds":{"left":0.15275931,"top":0.669593,"width":0.021609042,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"11 / 12 checks OK","depth":14,"bounds":{"left":0.3489029,"top":0.6660016,"width":0.005319149,"height":0.016759777},"on_screen":true,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"cbed2c9","depth":14,"bounds":{"left":0.35555187,"top":0.669593,"width":0.016788565,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"cbed2c9","depth":15,"bounds":{"left":0.35555187,"top":0.669593,"width":0.016788565,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@sonarqubecloud","depth":13,"bounds":{"left":0.090259306,"top":0.7130886,"width":0.013297873,"height":0.031923383},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":14,"bounds":{"left":0.3587101,"top":0.7138867,"width":0.007978723,"height":0.02952913},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"sonarqubecloud Bot commented 2 hours ago","depth":13,"bounds":{"left":0.114527926,"top":0.7138867,"width":0.23620346,"height":0.029928172},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"sonarqubecloud","depth":15,"bounds":{"left":0.114527926,"top":0.7218675,"width":0.036402926,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"sonarqubecloud","depth":16,"bounds":{"left":0.114527926,"top":0.7218675,"width":0.036402926,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Bot","depth":16,"bounds":{"left":0.15442154,"top":0.7234637,"width":0.0066489363,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":14,"bounds":{"left":0.16472739,"top":0.7218675,"width":0.02543218,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"2 hours ago","depth":14,"bounds":{"left":0.19148937,"top":0.72027135,"width":0.025598405,"height":0.016759777},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"2 hours ago","depth":16,"bounds":{"left":0.19148937,"top":0.7218675,"width":0.025598405,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Quality Gate Failed Quality Gate failed","depth":16,"bounds":{"left":0.114527926,"top":0.7573823,"width":0.2521609,"height":0.026735835},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"Quality Gate Failed","depth":17,"bounds":{"left":0.114527926,"top":0.7577813,"width":0.0066489363,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Quality Gate failed","depth":18,"bounds":{"left":0.12283909,"top":0.7577813,"width":0.05867686,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Failed conditions","depth":17,"bounds":{"left":0.114527926,"top":0.7984836,"width":0.036236703,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"34.4% Coverage on New Code","depth":17,"bounds":{"left":0.12117686,"top":0.8152434,"width":0.06615692,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"34.4% Coverage on New Code","depth":18,"bounds":{"left":0.12117686,"top":0.8152434,"width":0.06615692,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(required ≥ 70%)","depth":17,"bounds":{"left":0.18733378,"top":0.8152434,"width":0.037732713,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"See analysis details on SonarQube Cloud","depth":17,"bounds":{"left":0.114527926,"top":0.8447725,"width":0.08809841,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"See analysis details on SonarQube Cloud","depth":18,"bounds":{"left":0.114527926,"top":0.8447725,"width":0.08809841,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add or remove reactions","depth":15,"bounds":{"left":0.114527926,"top":0.8727055,"width":0.008643617,"height":0.0207502},"on_screen":true,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"@LakyLak","depth":13,"bounds":{"left":0.090259306,"top":0.9325619,"width":0.013297873,"height":0.031923383},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":14,"bounds":{"left":0.3587101,"top":0.9333599,"width":0.007978723,"height":0.02952913},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Author","depth":15,"bounds":{"left":0.34075797,"top":0.9425379,"width":0.012965426,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"LakyLak commented 2 hours ago","depth":13,"bounds":{"left":0.114527926,"top":0.9333599,"width":0.21725398,"height":0.02952913},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"LakyLak","depth":15,"bounds":{"left":0.114527926,"top":0.9413408,"width":0.01861702,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":16,"bounds":{"left":0.114527926,"top":0.9413408,"width":0.01861702,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":14,"bounds":{"left":0.13447474,"top":0.9413408,"width":0.025598405,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"2 hours ago","depth":14,"bounds":{"left":0.16140293,"top":0.9397446,"width":0.02543218,"height":0.016759777},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"2 hours ago","depth":16,"bounds":{"left":0.16140293,"top":0.9413408,"width":0.02543218,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@claude","depth":17,"bounds":{"left":0.114527926,"top":0.9780527,"width":0.019281914,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"@claude","depth":18,"bounds":{"left":0.114527926,"top":0.9780527,"width":0.019281914,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add or remove reactions","depth":15,"bounds":{"left":0.114527926,"top":1.0,"width":0.008643617,"height":-0.0059856176},"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"react with eyes","depth":14,"bounds":{"left":0.12450133,"top":1.0,"width":0.013796543,"height":-0.0059856176},"on_screen":false,"role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"👀","depth":16,"bounds":{"left":0.12682846,"top":1.0,"width":0.004155585,"height":-0.011173129},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1","depth":16,"bounds":{"left":0.13347739,"top":1.0,"width":0.0018284575,"height":-0.011173129},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@claude","depth":13,"bounds":{"left":0.090259306,"top":1.0,"width":0.013297873,"height":-0.06584203},"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":14,"bounds":{"left":0.3587101,"top":1.0,"width":0.007978723,"height":-0.06664002},"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"claude Bot commented 2 hours ago •","depth":13,"bounds":{"left":0.114527926,"top":1.0,"width":0.23620346,"height":-0.06664002},"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"claude","depth":15,"bounds":{"left":0.114527926,"top":1.0,"width":0.014960106,"height":-0.07462096},"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"claude","depth":16,"bounds":{"left":0.114527926,"top":1.0,"width":0.014960106,"height":-0.07462096},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Bot","depth":16,"bounds":{"left":0.13297872,"top":1.0,"width":0.0066489363,"height":-0.076217055},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":14,"bounds":{"left":0.14328457,"top":1.0,"width":0.025598405,"height":-0.07462096},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"2 hours ago","depth":14,"bounds":{"left":0.17021276,"top":1.0,"width":0.02543218,"height":-0.07302475},"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"2 hours ago","depth":16,"bounds":{"left":0.17021276,"top":1.0,"width":0.02543218,"height":-0.07462096},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"•","depth":16,"bounds":{"left":0.19697474,"top":1.0,"width":0.0021609042,"height":-0.07462096},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"edited","depth":16,"bounds":{"left":0.20046543,"top":1.0,"width":0.020113032,"height":-0.07302475},"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"edited","depth":18,"bounds":{"left":0.20046543,"top":1.0,"width":0.014793883,"height":-0.07462096},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Claude finished","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@LakyLak","depth":18,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"@LakyLak","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'s task in 1m 42s","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"——","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"View job","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"View job","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Code Review","depth":16,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Code Review","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Completed task","depth":18,"on_screen":false,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Gathering context","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Completed task","depth":18,"on_screen":false,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Reading changed files","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Completed task","depth":18,"on_screen":false,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Analyzing code quality","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Completed task","depth":18,"on_screen":false,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Posting review feedback","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Overall this is a well-thought-out implementation. The circuit-breaker pattern using Redis NX, the jitter for thundering-herd prevention, and the log-sampling strategy are all solid. A few issues worth addressing before merge:","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Issues","depth":16,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Issues","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1. Batch API calls bypass rate-limit handling","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"batchReadObjects()","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"in","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Client.php","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(lines 296–319) calls the HubSpot batch APIs directly without going through","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"executeRequest()","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":". If the batch endpoints return 429s, those exceptions will not set the Redis circuit-breaker and will not be wrapped in","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"RateLimitException","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", so the middleware won't catch them.","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"// batchReadObjects — no executeRequest() wrapping","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"response","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"=","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"batchConfig","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"[","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"api","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"]->","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"read","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"batchReadRequest","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":");","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy","depth":17,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Given the search endpoints can also hit rate limits during pagination-heavy rematching, the batch calls could too. Consider wrapping the batch call similarly, or at least catching 429 from batch APIs and converting them to","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"RateLimitException","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Fix this →","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Fix this →","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"2. Circuit-breaker key is scoped to CRM config ID, not HubSpot portal","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"getRateLimitCacheKey()","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Client.php:120","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":") returns","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"hubspot:ratelimit:portal:{configId}","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", but","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$this->config->getId()","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"is the CRM","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"configuration","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"record ID, not the HubSpot portal/account ID. If multiple CRM configuration records share the same HubSpot portal (e.g. re-connections), each will have its own independent circuit-breaker. A 429 received via config A will not suppress requests from config B even though they share the same quota.","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"If configs are always 1:1 with portals this is fine, but it's worth confirming or renaming the key to avoid misleading future readers:","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"// If config IDs == portal IDs, rename to make intent clear:","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"return","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"sprintf","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"hubspot:ratelimit:config:%d","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"this","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"->","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"config","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"->","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"getId","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"());","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy","depth":17,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"3.","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"isHubspotRateLimit()","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"and","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"parseRetryAfter()","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"are","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"public","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"but not in the interface","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"These two methods (","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Client.php:123","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"137","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":") are an internal concern of the rate-limit handling, but they are","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"public","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":". Nothing outside","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Client","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"needs to call them (the pagination service calls","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"isUnauthorizedException","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", not these). Making them","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"private","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"or at least","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"protected","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"avoids callers depending on them in tests or subclasses. The only reason to keep them public would be if they're tested in isolation — but they currently have no direct tests at all.","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"4. Missing test coverage for","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"executeRequest()","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"isHubspotRateLimit()","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"parseRetryAfter()","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"SonarCloud is already flagging 34.4% coverage. The three methods above — which contain the core logic of this PR — have zero test coverage. In particular:","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"parseRetryAfter()","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":": the","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Retry-After","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"header parsing, fallback to message keyword matching, and the default-10-second case","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"isHubspotRateLimit()","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":": each of the exception type branches","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"executeRequest()","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":": the cached circuit-breaker path (early","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"throw","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"when Redis key exists) and the","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"SET NX","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"path when a live 429 is received","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"These are the most impactful paths to cover to get over the 70% gate.","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Minor observations","depth":16,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Minor observations","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"5. Test constants duplicated from production code","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"re-declares","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"MAX_RETRY_DELAY = 600","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
2422050462884629634
|
-1251007369427110010
|
visual_change
|
accessibility
|
NULL
|
New Tab
New Tab
Jy 20820 es reindex stream model h New Tab
New Tab
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS]
Show options
LakyLak commented 3 hours ago
LakyLak
LakyLak
commented
3 hours ago
3 hours ago
JIRA: JY-20725
JIRA:
JY-20725
JY-20725
Changes:
Changes:
Adds
HandleHubspotRateLimit
middleware (release jobs with delay)
Adds
executeRequest
wrapper in Hubspot Client. It handles 429 errors and throws
RateLimitException
Add or remove reactions
LakyLak
LakyLak
added
2
commits
3 hours ago
3 hours ago
@LakyLak
JY-20725
JY-20725
add HS rate limit handling on activities rematching
add HS rate limit handling on activities rematching
8 / 10 checks OK
44d5678
44d5678
@LakyLak
JY-20725
JY-20725
add tests
add tests
11 / 12 checks OK
cbed2c9
cbed2c9
@sonarqubecloud
Show options
sonarqubecloud Bot commented 2 hours ago
sonarqubecloud
sonarqubecloud
Bot
commented
2 hours ago
2 hours ago
Quality Gate Failed Quality Gate failed
Quality Gate Failed
Quality Gate failed
Failed conditions
34.4% Coverage on New Code
34.4% Coverage on New Code
(required ≥ 70%)
See analysis details on SonarQube Cloud
See analysis details on SonarQube Cloud
Add or remove reactions
@LakyLak
Show options
Author
LakyLak commented 2 hours ago
LakyLak
LakyLak
commented
2 hours ago
2 hours ago
@claude
@claude
Add or remove reactions
react with eyes
👀
1
@claude
Show options
claude Bot commented 2 hours ago •
claude
claude
Bot
commented
2 hours ago
2 hours ago
•
edited
edited
Claude finished
@LakyLak
@LakyLak
's task in 1m 42s
——
View job
View job
Code Review
Code Review
Completed task
Gathering context
Completed task
Reading changed files
Completed task
Analyzing code quality
Completed task
Posting review feedback
Overall this is a well-thought-out implementation. The circuit-breaker pattern using Redis NX, the jitter for thundering-herd prevention, and the log-sampling strategy are all solid. A few issues worth addressing before merge:
Issues
Issues
1. Batch API calls bypass rate-limit handling
batchReadObjects()
in
Client.php
(lines 296–319) calls the HubSpot batch APIs directly without going through
executeRequest()
. If the batch endpoints return 429s, those exceptions will not set the Redis circuit-breaker and will not be wrapped in
RateLimitException
, so the middleware won't catch them.
// batchReadObjects — no executeRequest() wrapping
$
response
=
$
batchConfig
[
'
api
'
]->
read
(
$
batchReadRequest
);
Copy
Given the search endpoints can also hit rate limits during pagination-heavy rematching, the batch calls could too. Consider wrapping the batch call similarly, or at least catching 429 from batch APIs and converting them to
RateLimitException
.
Fix this →
Fix this →
2. Circuit-breaker key is scoped to CRM config ID, not HubSpot portal
getRateLimitCacheKey()
(
Client.php:120
) returns
hubspot:ratelimit:portal:{configId}
, but
$this->config->getId()
is the CRM
configuration
record ID, not the HubSpot portal/account ID. If multiple CRM configuration records share the same HubSpot portal (e.g. re-connections), each will have its own independent circuit-breaker. A 429 received via config A will not suppress requests from config B even though they share the same quota.
If configs are always 1:1 with portals this is fine, but it's worth confirming or renaming the key to avoid misleading future readers:
// If config IDs == portal IDs, rename to make intent clear:
return
sprintf
(
'
hubspot:ratelimit:config:%d
'
,
$
this
->
config
->
getId
());
Copy
3.
isHubspotRateLimit()
and
parseRetryAfter()
are
public
but not in the interface
These two methods (
Client.php:123
,
137
) are an internal concern of the rate-limit handling, but they are
public
. Nothing outside
Client
needs to call them (the pagination service calls
isUnauthorizedException
, not these). Making them
private
or at least
protected
avoids callers depending on them in tests or subclasses. The only reason to keep them public would be if they're tested in isolation — but they currently have no direct tests at all.
4. Missing test coverage for
executeRequest()
,
isHubspotRateLimit()
,
parseRetryAfter()
SonarCloud is already flagging 34.4% coverage. The three methods above — which contain the core logic of this PR — have zero test coverage. In particular:
parseRetryAfter()
: the
Retry-After
header parsing, fallback to message keyword matching, and the default-10-second case
isHubspotRateLimit()
: each of the exception type branches
executeRequest()
: the cached circuit-breaker path (early
throw
when Redis key exists) and the
SET NX
path when a live 429 is received
These are the most impactful paths to cover to get over the 70% gate.
Minor observations
Minor observations
5. Test constants duplicated from production code
HandleHubspotRateLimitTest.php
re-declares
MAX_RETRY_DELAY = 600...
|
20668
|
NULL
|
NULL
|
NULL
|
|
20670
|
899
|
14
|
2026-05-11T16:08:46.046715+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515726046_m2.jpg...
|
Firefox
|
JY-20725 add HS rate limit handling on activities JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app — Work...
|
1
|
github.com/jiminny/app/pull/12066
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
New Tab
New Tab
Jy 20820 es reindex stream model h New Tab
New Tab
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS]
Show options
LakyLak commented 3 hours ago
LakyLak
LakyLak
commented
3 hours ago
3 hours ago
JIRA: JY-20725
JIRA:
JY-20725
JY-20725
Changes:
Changes:
Adds
HandleHubspotRateLimit
middleware (release jobs with delay)
Adds
executeRequest
wrapper in Hubspot Client. It handles 429 errors and throws
RateLimitException
Add or remove reactions
LakyLak
LakyLak
added
2
commits
3 hours ago
3 hours ago
@LakyLak
JY-20725
JY-20725
add HS rate limit handling on activities rematching
add HS rate limit handling on activities rematching
8 / 10 checks OK
44d5678
44d5678
@LakyLak
JY-20725
JY-20725
add tests
add tests
11 / 12 checks OK
cbed2c9
cbed2c9
@sonarqubecloud
Show options
sonarqubecloud Bot commented 2 hours ago
sonarqubecloud
sonarqubecloud
Bot
commented
2 hours ago
2 hours ago
Quality Gate Failed Quality Gate failed
Quality Gate Failed
Quality Gate failed
Failed conditions
34.4% Coverage on New Code
34.4% Coverage on New Code
(required ≥ 70%)
See analysis details on SonarQube Cloud
See analysis details on SonarQube Cloud
Add or remove reactions
@LakyLak
Show options
Author
LakyLak commented 2 hours ago
LakyLak
LakyLak
commented
2 hours ago...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"New Tab","depth":4,"bounds":{"left":0.0,"top":0.0518755,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"New Tab","depth":5,"bounds":{"left":0.013297873,"top":0.06304868,"width":0.014960106,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.08459697,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.09577015,"width":0.16888298,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira","depth":4,"bounds":{"left":0.0,"top":0.11731844,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.12849163,"width":0.16140293,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":4,"bounds":{"left":0.0,"top":0.15003991,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":5,"bounds":{"left":0.013297873,"top":0.16121309,"width":0.4644282,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.18276137,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.19393456,"width":0.18816489,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.06732048,"top":0.18994413,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"Pipelines - jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.21548285,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pipelines - jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.22665602,"width":0.039228722,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Pull requests · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.2482043,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pull requests · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.25937748,"width":0.04537899,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.0028257978,"top":0.28252193,"width":0.07413564,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"bounds":{"left":0.0028257978,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"bounds":{"left":0.013796543,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Tabs from other devices","depth":6,"bounds":{"left":0.024933511,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open history (⇧⌘H)","depth":6,"bounds":{"left":0.036070477,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open bookmarks (⌘B)","depth":6,"bounds":{"left":0.04720745,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Skip to content","depth":6,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Skip to content","depth":7,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open menu","depth":10,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Homepage (g then d)","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"jiminny","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"jiminny","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"app","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"app","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Search or jump to…","depth":9,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Type","depth":12,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":12,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"to search","depth":12,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Chat with Copilot","depth":10,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXMenuButton","text":"Open Copilot…","depth":9,"on_screen":false,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXMenuButton","text":"Create new...","depth":9,"on_screen":false,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"All issues(g then i)","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"All pull requests","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"All repositories","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"You have unread notifications(g then n)","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Open user navigation menu","depth":9,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Repository navigation","depth":9,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Repository navigation","depth":10,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Code","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Code","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Pull requests (34)","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pull requests","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"34","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Agents","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Agents","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Actions","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Actions","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Wiki","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Wiki","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Security and quality (4)","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Security and quality","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"4","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Insights","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Insights","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Settings","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Settings","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Important update","depth":10,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Important update","depth":11,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"On April 24 we'll start using GitHub Copilot interaction data for AI model training unless you opt out.","depth":10,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Review this update","depth":10,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Review this update","depth":11,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"and manage your preferences in your","depth":10,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"GitHub account settings","depth":10,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"GitHub account settings","depth":11,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":10,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Dismiss banner","depth":9,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"JY-20725 add HS rate limit handling on activities rematching #12066 Edit title","depth":13,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"#","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"12066","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Edit title","depth":14,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Checks pending","depth":13,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Checks pending","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Code","depth":13,"on_screen":false,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Code","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Open","depth":13,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"LakyLak","depth":15,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"wants to merge 4 commits into","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"master","depth":15,"on_screen":false,"role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"master","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"from","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"JY-20725-handle-HS-search-rate-limit","depth":16,"on_screen":false,"role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy head branch name to clipboard","depth":16,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Lines changed: 757 additions & 249 deletions","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Conversation (3)","depth":16,"on_screen":false,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"Conversation","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"3","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Commits (4)","depth":16,"on_screen":false,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Commits","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"4","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Checks (2)","depth":16,"on_screen":false,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Checks","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"2","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Files changed (12)","depth":16,"on_screen":false,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Files changed","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"12","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Open","depth":14,"bounds":{"left":0.1008976,"top":0.0726257,"width":0.011968086,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"JY-20725 add HS rate limit handling on activities rematching #12066 LakyLak wants to merge 4 commits into master from JY-20725-handle-HS-search-rate-limit Copy head branch name to clipboard","depth":14,"bounds":{"left":0.11951463,"top":0.058260176,"width":0.21077128,"height":0.042298485},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"JY-20725 add HS rate limit handling on activities rematching","depth":16,"bounds":{"left":0.11951463,"top":0.05865922,"width":0.13663563,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching","depth":17,"bounds":{"left":0.11951463,"top":0.06304868,"width":0.13663563,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"#","depth":16,"bounds":{"left":0.25880983,"top":0.06304868,"width":0.0028257978,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"12066","depth":16,"bounds":{"left":0.26163563,"top":0.06304868,"width":0.013630319,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"LakyLak","depth":18,"bounds":{"left":0.11951463,"top":0.08339984,"width":0.016289894,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":19,"bounds":{"left":0.11951463,"top":0.08339984,"width":0.016289894,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"wants to merge 4 commits into","depth":18,"bounds":{"left":0.13713431,"top":0.08339984,"width":0.058011968,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"master","depth":18,"bounds":{"left":0.19647606,"top":0.08180367,"width":0.018450798,"height":0.015163607},"on_screen":true,"role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"master","depth":19,"bounds":{"left":0.19847074,"top":0.083798885,"width":0.014461436,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"from","depth":19,"bounds":{"left":0.21625665,"top":0.08339984,"width":0.008643617,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"JY-20725-handle-HS-search-rate-limit","depth":19,"bounds":{"left":0.22623006,"top":0.08180367,"width":0.09042553,"height":0.015163607},"on_screen":true,"role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit","depth":20,"bounds":{"left":0.22822474,"top":0.083798885,"width":0.08643617,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy head branch name to clipboard","depth":19,"bounds":{"left":0.3179854,"top":0.07821229,"width":0.00930851,"height":0.022346368},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Conversation","depth":12,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Conversation","depth":13,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@LakyLak","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":15,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"LakyLak commented 3 hours ago","depth":14,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"LakyLak","depth":16,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"3 hours ago","depth":15,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"3 hours ago","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"JIRA: JY-20725","depth":16,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"JIRA:","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"JY-20725","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Changes:","depth":16,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Changes:","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Adds","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"HandleHubspotRateLimit","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"middleware (release jobs with delay)","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Adds","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"executeRequest","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"wrapper in Hubspot Client. It handles 429 errors and throws","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"RateLimitException","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add or remove reactions","depth":16,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"LakyLak","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"added","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"2","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commits","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"3 hours ago","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"3 hours ago","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@LakyLak","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"JY-20725","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"add HS rate limit handling on activities rematching","depth":14,"on_screen":false,"help_text":"JY-20725 add HS rate limit handling on activities rematching","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"add HS rate limit handling on activities rematching","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"8 / 10 checks OK","depth":14,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"44d5678","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"44d5678","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@LakyLak","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"JY-20725","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"add tests","depth":14,"on_screen":false,"help_text":"JY-20725 add tests","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"add tests","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"11 / 12 checks OK","depth":14,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"cbed2c9","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"cbed2c9","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@sonarqubecloud","depth":13,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":14,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"sonarqubecloud Bot commented 2 hours ago","depth":13,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"sonarqubecloud","depth":15,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"sonarqubecloud","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Bot","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"2 hours ago","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"2 hours ago","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Quality Gate Failed Quality Gate failed","depth":16,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"Quality Gate Failed","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Quality Gate failed","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Failed conditions","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"34.4% Coverage on New Code","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"34.4% Coverage on New Code","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(required ≥ 70%)","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"See analysis details on SonarQube Cloud","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"See analysis details on SonarQube Cloud","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add or remove reactions","depth":15,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"@LakyLak","depth":13,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":14,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Author","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"LakyLak commented 2 hours ago","depth":13,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"LakyLak","depth":15,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"2 hours ago","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false}]...
|
-5007515586561648605
|
6919656518048068992
|
visual_change
|
accessibility
|
NULL
|
New Tab
New Tab
Jy 20820 es reindex stream model h New Tab
New Tab
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS]
Show options
LakyLak commented 3 hours ago
LakyLak
LakyLak
commented
3 hours ago
3 hours ago
JIRA: JY-20725
JIRA:
JY-20725
JY-20725
Changes:
Changes:
Adds
HandleHubspotRateLimit
middleware (release jobs with delay)
Adds
executeRequest
wrapper in Hubspot Client. It handles 429 errors and throws
RateLimitException
Add or remove reactions
LakyLak
LakyLak
added
2
commits
3 hours ago
3 hours ago
@LakyLak
JY-20725
JY-20725
add HS rate limit handling on activities rematching
add HS rate limit handling on activities rematching
8 / 10 checks OK
44d5678
44d5678
@LakyLak
JY-20725
JY-20725
add tests
add tests
11 / 12 checks OK
cbed2c9
cbed2c9
@sonarqubecloud
Show options
sonarqubecloud Bot commented 2 hours ago
sonarqubecloud
sonarqubecloud
Bot
commented
2 hours ago
2 hours ago
Quality Gate Failed Quality Gate failed
Quality Gate Failed
Quality Gate failed
Failed conditions
34.4% Coverage on New Code
34.4% Coverage on New Code
(required ≥ 70%)
See analysis details on SonarQube Cloud
See analysis details on SonarQube Cloud
Add or remove reactions
@LakyLak
Show options
Author
LakyLak commented 2 hours ago
LakyLak
LakyLak
commented
2 hours ago...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20679
|
900
|
2
|
2026-05-11T16:11:04.735513+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515864735_m1.jpg...
|
Slack
|
releases (Channel) - Jiminny Inc - 3 new items - S releases (Channel) - Jiminny Inc - 3 new items - Slack...
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Aneliya Angelova
Galya Dimitrova
Petko Kashinski
Stefka Stoyanova
Vasil Vasilev
Nikolay Ivanov
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Stoyan Tanev
Ves
Lukas Kovalik
you
Jira Cloud
Toast
Messages
Messages
Files
Files
Bookmarks
Bookmarks
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
CircleCI
APP
Today at 4:16:00 PM
4:16 PM
Deployment Successful! tada emoji
Deployment Successful!
Project
: app
When
: 05/11/2026 13:16:00
Tag
:
View Job
View Job
GitHub
APP
Today at 4:45:17 PM
4:45 PM
2 new commits
2 new commits
pushed to
master
master
by
mihailmihaylovjiminny
mihailmihaylovjiminny
4ff69be1
4ff69be1
- JY-17836: Handle non-bot activities
af953504
af953504
- Merge pull request #12067 from jiminny/JY-17836-participant-speeches-hotfix
jiminny/app
jiminny/app
|
Added by
GitHub
GitHub
GitHub
APP
Today at 5:10:22 PM
5:10 PM
13 new commits
13 new commits
pushed to
master
master
by
ilian-jiminny
ilian-jiminny
9353b609
9353b609
- JY-20395 wip
bee402b7
bee402b7
- JY-20395 Stop fetching ALL headers (including file) for email import
d6a0fe5a
d6a0fe5a
- JY-20395 lint
daa0373e
daa0373e
- JY-20395 lint fixes
af59d609
af59d609
- JY-20395 Removed no longer used methods
Show more
jiminny/app
jiminny/app
|
Added by
GitHub
GitHub
CircleCI
APP
Today at 5:11:35 PM
5:11 PM
Deployment Successful! tada emoji
Deployment Successful!
Project
: app
When
: 05/11/2026 14:11:35
Tag
:
View Job
View Job
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
CircleCI
APP
Today at 5:35:58 PM
5:35 PM
Deployment Successful! tada emoji
Deployment Successful!
Project
: app
When
: 05/11/2026 14:35:58
Tag
:
View Job
View Job
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
loading…
Channel releases...
|
[{"role":"AXPopUpButton","text [{"role":"AXPopUpButton","text":"Switch workspaces… (Jiminny Inc) Has new messages","depth":14,"bounds":{"left":0.51180553,"top":0.08111111,"width":0.025,"height":0.04},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Home","depth":14,"bounds":{"left":0.50625,"top":0.14,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"Home","depth":16,"bounds":{"left":0.5138889,"top":0.19222222,"width":0.020833334,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"DMs","depth":14,"bounds":{"left":0.50625,"top":0.21555555,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"DMs","depth":16,"bounds":{"left":0.5159722,"top":0.26777777,"width":0.016666668,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Activity","depth":14,"bounds":{"left":0.50625,"top":0.2911111,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Activity","depth":16,"bounds":{"left":0.51111114,"top":0.34333333,"width":0.027083334,"height":0.015555556},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.51111114,"top":0.34333333,"width":0.0055555557,"height":0.015555556}},{"char_start":1,"char_count":7,"bounds":{"left":0.5159722,"top":0.34333333,"width":0.022222223,"height":0.015555556}}],"role_description":"text"},{"role":"AXRadioButton","text":"Files","depth":14,"bounds":{"left":0.50625,"top":0.36666667,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Files","depth":16,"bounds":{"left":0.51666665,"top":0.4188889,"width":0.015972223,"height":0.015555556},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.51666665,"top":0.4188889,"width":0.004166667,"height":0.015555556}},{"char_start":1,"char_count":4,"bounds":{"left":0.5208333,"top":0.4188889,"width":0.011805556,"height":0.015555556}}],"role_description":"text"},{"role":"AXRadioButton","text":"Later","depth":14,"bounds":{"left":0.50625,"top":0.4422222,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Later","depth":16,"bounds":{"left":0.5152778,"top":0.49444443,"width":0.018055556,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"More…","depth":14,"bounds":{"left":0.50625,"top":0.5177778,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More","depth":16,"bounds":{"left":0.5152778,"top":0.57,"width":0.01875,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Unreads","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.038194444,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Threads","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.036805555,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Huddles","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.038194444,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Drafts & sent","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.06111111,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":21,"bounds":{"left":0.68472224,"top":0.12777779,"width":0.0055555557,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Directories","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.050694443,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":23,"bounds":{"left":0.58819443,"top":0.12777779,"width":0.09166667,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"platform-inner-team","depth":23,"bounds":{"left":0.58819443,"top":0.12777779,"width":0.093055554,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ai-chapter","depth":23,"bounds":{"left":0.58819443,"top":0.12777779,"width":0.046527777,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"alerts","depth":23,"bounds":{"left":0.58819443,"top":0.12777779,"width":0.025694445,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"backend","depth":23,"bounds":{"left":0.58819443,"top":0.12777779,"width":0.038194444,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"bugs","depth":23,"bounds":{"left":0.58819443,"top":0.12777779,"width":0.022222223,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"confusion-clinic","depth":23,"bounds":{"left":0.58819443,"top":0.12777779,"width":0.072222225,"height":0.012222222},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"curiosity_lab","depth":23,"bounds":{"left":0.58819443,"top":0.15111111,"width":0.057638887,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.15111111,"width":0.0048611113,"height":0.02}},{"char_start":1,"char_count":12,"bounds":{"left":0.59305555,"top":0.15111111,"width":0.05277778,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"engineering","depth":23,"bounds":{"left":0.58819443,"top":0.18222222,"width":0.054166667,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"general","depth":23,"bounds":{"left":0.58819443,"top":0.21333334,"width":0.034027778,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"jiminny-bg","depth":23,"bounds":{"left":0.58819443,"top":0.24444444,"width":0.048611112,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"platform-tickets","depth":23,"bounds":{"left":0.58819443,"top":0.27555555,"width":0.072916664,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.27555555,"width":0.00625,"height":0.02}},{"char_start":1,"char_count":15,"bounds":{"left":0.59444445,"top":0.27555555,"width":0.06666667,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"product_launches","depth":23,"bounds":{"left":0.58819443,"top":0.30666667,"width":0.08055556,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"random","depth":23,"bounds":{"left":0.58819443,"top":0.33777776,"width":0.035416666,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"releases","depth":23,"bounds":{"left":0.58819443,"top":0.36888888,"width":0.036805555,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"sofia-office","depth":23,"bounds":{"left":0.58819443,"top":0.4,"width":0.05138889,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.4,"width":0.0048611113,"height":0.02}},{"char_start":1,"char_count":11,"bounds":{"left":0.59305555,"top":0.4,"width":0.045833334,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"support","depth":23,"bounds":{"left":0.58819443,"top":0.4311111,"width":0.036111113,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"thank-yous","depth":23,"bounds":{"left":0.58819443,"top":0.46222222,"width":0.05138889,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"the_people_of_jiminny","depth":23,"bounds":{"left":0.58819443,"top":0.49333334,"width":0.094444446,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.49333334,"width":0.004166667,"height":0.02}},{"char_start":1,"char_count":20,"bounds":{"left":0.5923611,"top":0.49333334,"width":0.09861111,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"Aneliya Angelova","depth":23,"bounds":{"left":0.58819443,"top":0.56666666,"width":0.07847222,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Galya Dimitrova","depth":23,"bounds":{"left":0.58819443,"top":0.5977778,"width":0.07361111,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Petko Kashinski","depth":23,"bounds":{"left":0.58819443,"top":0.6288889,"width":0.072222225,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Stefka Stoyanova","depth":23,"bounds":{"left":0.58819443,"top":0.66,"width":0.079166666,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Vasil Vasilev","depth":23,"bounds":{"left":0.58819443,"top":0.6911111,"width":0.055555556,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.6911111,"width":0.00625,"height":0.02}},{"char_start":1,"char_count":12,"bounds":{"left":0.59444445,"top":0.6911111,"width":0.048611112,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"Nikolay Ivanov","depth":23,"bounds":{"left":0.58819443,"top":0.7222222,"width":0.06736111,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Aneliya Angelova","depth":23,"bounds":{"left":0.58819443,"top":0.75333333,"width":0.07847222,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"bounds":{"left":0.6666667,"top":0.75333333,"width":0.013194445,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Nikolay Yankov","depth":23,"bounds":{"left":0.6715278,"top":0.75333333,"width":0.029861111,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.6715278,"top":0.75333333,"width":0.008333334,"height":0.02}},{"char_start":1,"char_count":13,"bounds":{"left":0.6798611,"top":0.75333333,"width":0.060416665,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Steliyan Georgiev","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Stoyan Tanev","depth":23,"bounds":{"left":0.58819443,"top":0.78444445,"width":0.060416665,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Ves","depth":23,"bounds":{"left":0.58819443,"top":0.8155556,"width":0.016666668,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":23,"bounds":{"left":0.58819443,"top":0.8466667,"width":0.061805554,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"you","depth":23,"bounds":{"left":0.65555555,"top":0.8466667,"width":0.013194445,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.65555555,"top":0.8466667,"width":0.0048611113,"height":0.02}},{"char_start":1,"char_count":2,"bounds":{"left":0.66041666,"top":0.8466667,"width":0.011805556,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"Jira Cloud","depth":23,"bounds":{"left":0.58819443,"top":0.92,"width":0.045833334,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Toast","depth":23,"bounds":{"left":0.58819443,"top":0.95111114,"width":0.024305556,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Messages","depth":17,"bounds":{"left":0.71319443,"top":0.12777779,"width":0.06458333,"height":0.04222222},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"Messages","depth":19,"bounds":{"left":0.7326389,"top":0.14,"width":0.039583333,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Files","depth":17,"bounds":{"left":0.7798611,"top":0.12777779,"width":0.04375,"height":0.04222222},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Files","depth":19,"bounds":{"left":0.79930556,"top":0.14,"width":0.01875,"height":0.017777778},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.79930556,"top":0.14,"width":0.0055555557,"height":0.017777778}},{"char_start":1,"char_count":4,"bounds":{"left":0.8048611,"top":0.14,"width":0.013194445,"height":0.017777778}}],"role_description":"text"},{"role":"AXRadioButton","text":"Bookmarks","depth":17,"bounds":{"left":0.8263889,"top":0.12777779,"width":0.07083333,"height":0.04222222},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Bookmarks","depth":19,"bounds":{"left":0.84583336,"top":0.14,"width":0.045833334,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"Add and Edit Channel Tabs","depth":17,"bounds":{"left":0.8993056,"top":0.12777779,"width":0.022916667,"height":0.04222222},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Canvas","depth":17,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"List","depth":17,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Folder","depth":17,"on_screen":false,"role_description":"text"},{"role":"AXPopUpButton","text":"Jump to date","depth":22,"bounds":{"left":0.8229167,"top":0.17666666,"width":0.05277778,"height":0.031111112},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"CircleCI","depth":23,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.0375,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"APP","depth":23,"bounds":{"left":0.7881944,"top":0.16111112,"width":0.013888889,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":23,"bounds":{"left":0.8041667,"top":0.16111112,"width":0.0055555557,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Today at 4:16:00 PM","depth":23,"bounds":{"left":0.8090278,"top":0.16111112,"width":0.031944446,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"4:16 PM","depth":24,"bounds":{"left":0.8090278,"top":0.16111112,"width":0.031944446,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"Deployment Successful! tada emoji","depth":23,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.2361111,"height":0.0011111111},"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"Deployment Successful!","depth":25,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.115277775,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Project","depth":24,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.033333335,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":": app","depth":24,"bounds":{"left":0.7798611,"top":0.16111112,"width":0.022222223,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"When","depth":24,"bounds":{"left":0.8298611,"top":0.16111112,"width":0.028472222,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":": 05/11/2026 13:16:00","depth":24,"bounds":{"left":0.8298611,"top":0.16111112,"width":0.058333334,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Tag","depth":24,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.015972223,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":":","depth":24,"bounds":{"left":0.7625,"top":0.16111112,"width":0.0027777778,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"View Job","depth":24,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.049305554,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"View Job","depth":26,"bounds":{"left":0.75277776,"top":0.16111112,"width":0.036805555,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"GitHub","depth":23,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.034722224,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"APP","depth":23,"bounds":{"left":0.78541666,"top":0.16111112,"width":0.013888889,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":23,"bounds":{"left":0.80138886,"top":0.16111112,"width":0.0055555557,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Today at 4:45:17 PM","depth":23,"bounds":{"left":0.80625,"top":0.16111112,"width":0.031944446,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"4:45 PM","depth":24,"bounds":{"left":0.80625,"top":0.16111112,"width":0.031944446,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"2 new commits","depth":23,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.06944445,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"2 new commits","depth":24,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.06944445,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"pushed to","depth":23,"bounds":{"left":0.8159722,"top":0.16111112,"width":0.05138889,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"master","depth":24,"bounds":{"left":0.86944443,"top":0.16111112,"width":0.030555556,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"master","depth":25,"bounds":{"left":0.86944443,"top":0.16111112,"width":0.030555556,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"by","depth":23,"bounds":{"left":0.90208334,"top":0.16111112,"width":0.013888889,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"mihailmihaylovjiminny","depth":23,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.10138889,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":24,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.10138889,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"4ff69be1","depth":26,"bounds":{"left":0.7604167,"top":0.16111112,"width":0.04027778,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"4ff69be1","depth":27,"bounds":{"left":0.7604167,"top":0.16111112,"width":0.04027778,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"- JY-17836: Handle non-bot activities","depth":25,"bounds":{"left":0.7576389,"top":0.16111112,"width":0.17638889,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"af953504","depth":26,"bounds":{"left":0.7604167,"top":0.16111112,"width":0.04027778,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"af953504","depth":27,"bounds":{"left":0.7604167,"top":0.16111112,"width":0.04027778,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"- Merge pull request #12067 from jiminny/JY-17836-participant-speeches-hotfix","depth":25,"bounds":{"left":0.7576389,"top":0.16111112,"width":0.21041666,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"jiminny/app","depth":25,"bounds":{"left":0.77152777,"top":0.16111112,"width":0.043055557,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"jiminny/app","depth":26,"bounds":{"left":0.77152777,"top":0.16111112,"width":0.043055557,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"|","depth":25,"bounds":{"left":0.81458336,"top":0.16111112,"width":0.00625,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Added by","depth":25,"bounds":{"left":0.8208333,"top":0.16111112,"width":0.036805555,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"GitHub","depth":25,"bounds":{"left":0.8576389,"top":0.16111112,"width":0.027083334,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"GitHub","depth":26,"bounds":{"left":0.8576389,"top":0.16111112,"width":0.027083334,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"GitHub","depth":23,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.034722224,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"APP","depth":23,"bounds":{"left":0.78541666,"top":0.16111112,"width":0.013888889,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":23,"bounds":{"left":0.80138886,"top":0.16111112,"width":0.0055555557,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Today at 5:10:22 PM","depth":23,"bounds":{"left":0.80625,"top":0.16111112,"width":0.031944446,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5:10 PM","depth":24,"bounds":{"left":0.80625,"top":0.16111112,"width":0.031944446,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"13 new commits","depth":23,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.07569444,"height":0.006666667},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"13 new commits","depth":24,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.07569444,"height":0.006666667},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"pushed to","depth":23,"bounds":{"left":0.8215278,"top":0.16111112,"width":0.05138889,"height":0.006666667},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"master","depth":24,"bounds":{"left":0.87569445,"top":0.16111112,"width":0.029861111,"height":0.006666667},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"master","depth":25,"bounds":{"left":0.87569445,"top":0.16111112,"width":0.029861111,"height":0.006666667},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"by","depth":23,"bounds":{"left":0.90833336,"top":0.16111112,"width":0.016666668,"height":0.006666667},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"ilian-jiminny","depth":23,"bounds":{"left":0.92430556,"top":0.16111112,"width":0.055555556,"height":0.006666667},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"ilian-jiminny","depth":24,"bounds":{"left":0.92430556,"top":0.16111112,"width":0.055555556,"height":0.006666667},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"9353b609","depth":26,"bounds":{"left":0.7604167,"top":0.18444444,"width":0.04027778,"height":0.016666668},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"9353b609","depth":27,"bounds":{"left":0.7604167,"top":0.18444444,"width":0.04027778,"height":0.016666668},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"- JY-20395 wip","depth":25,"bounds":{"left":0.8034722,"top":0.18111111,"width":0.072222225,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"bee402b7","depth":26,"bounds":{"left":0.7604167,"top":0.20888889,"width":0.04027778,"height":0.016666668},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"bee402b7","depth":27,"bounds":{"left":0.7604167,"top":0.20888889,"width":0.04027778,"height":0.016666668},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"- JY-20395 Stop fetching ALL headers (including file) for email import","depth":25,"bounds":{"left":0.7576389,"top":0.20555556,"width":0.18194444,"height":0.044444446},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.8034722,"top":0.20555556,"width":0.0027777778,"height":0.02}},{"char_start":1,"char_count":69,"bounds":{"left":0.7576389,"top":0.20555556,"width":0.18263888,"height":0.044444446}}],"role_description":"text"},{"role":"AXLink","text":"d6a0fe5a","depth":26,"bounds":{"left":0.7604167,"top":0.25777778,"width":0.04027778,"height":0.016666668},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"d6a0fe5a","depth":27,"bounds":{"left":0.7604167,"top":0.25777778,"width":0.04027778,"height":0.016666668},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"- JY-20395 lint","depth":25,"bounds":{"left":0.8034722,"top":0.25444445,"width":0.07013889,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"daa0373e","depth":26,"bounds":{"left":0.7604167,"top":0.2822222,"width":0.04027778,"height":0.016666668},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"daa0373e","depth":27,"bounds":{"left":0.7604167,"top":0.2822222,"width":0.04027778,"height":0.016666668},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"- JY-20395 lint fixes","depth":25,"bounds":{"left":0.8034722,"top":0.27888888,"width":0.09375,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.8034722,"top":0.27888888,"width":0.0027777778,"height":0.02}},{"char_start":1,"char_count":20,"bounds":{"left":0.80625,"top":0.27888888,"width":0.08680555,"height":0.02}}],"role_description":"text"},{"role":"AXLink","text":"af59d609","depth":26,"bounds":{"left":0.7604167,"top":0.30666667,"width":0.04027778,"height":0.016666668},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"af59d609","depth":27,"bounds":{"left":0.7604167,"top":0.30666667,"width":0.04027778,"height":0.016666668},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"- JY-20395 Removed no longer used methods","depth":25,"bounds":{"left":0.7576389,"top":0.30333334,"width":0.21458334,"height":0.044444446},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.8034722,"top":0.30333334,"width":0.0027777778,"height":0.02}},{"char_start":1,"char_count":40,"bounds":{"left":0.7576389,"top":0.30333334,"width":0.21458334,"height":0.044444446}}],"role_description":"text"},{"role":"AXButton","text":"Show more","depth":25,"bounds":{"left":0.7576389,"top":0.35,"width":0.052083332,"height":0.024444444},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"jiminny/app","depth":25,"bounds":{"left":0.77152777,"top":0.38,"width":0.043055557,"height":0.016666668},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"jiminny/app","depth":26,"bounds":{"left":0.77152777,"top":0.38,"width":0.043055557,"height":0.016666668},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"|","depth":25,"bounds":{"left":0.81458336,"top":0.38,"width":0.00625,"height":0.016666668},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Added by","depth":25,"bounds":{"left":0.8208333,"top":0.38,"width":0.036805555,"height":0.016666668},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"GitHub","depth":25,"bounds":{"left":0.8576389,"top":0.38,"width":0.027083334,"height":0.016666668},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"GitHub","depth":26,"bounds":{"left":0.8576389,"top":0.38,"width":0.027083334,"height":0.016666668},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"CircleCI","depth":23,"bounds":{"left":0.7465278,"top":0.41222224,"width":0.0375,"height":0.024444444},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"APP","depth":23,"bounds":{"left":0.7881944,"top":0.41777778,"width":0.013888889,"height":0.013333334},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":23,"bounds":{"left":0.8041667,"top":0.41444445,"width":0.0055555557,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Today at 5:11:35 PM","depth":23,"bounds":{"left":0.8090278,"top":0.41777778,"width":0.031944446,"height":0.016666668},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5:11 PM","depth":24,"bounds":{"left":0.8090278,"top":0.41777778,"width":0.031944446,"height":0.016666668},"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"Deployment Successful! tada emoji","depth":23,"bounds":{"left":0.7465278,"top":0.44111112,"width":0.2361111,"height":0.024444444},"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"Deployment Successful!","depth":25,"bounds":{"left":0.7465278,"top":0.44333333,"width":0.115277775,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Project","depth":24,"bounds":{"left":0.7465278,"top":0.4811111,"width":0.033333335,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":": app","depth":24,"bounds":{"left":0.7798611,"top":0.4811111,"width":0.022222223,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.7798611,"top":0.4811111,"width":0.0027777778,"height":0.02}},{"char_start":1,"char_count":4,"bounds":{"left":0.7826389,"top":0.4811111,"width":0.019444445,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"When","depth":24,"bounds":{"left":0.8298611,"top":0.4811111,"width":0.028472222,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":": 05/11/2026 14:11:35","depth":24,"bounds":{"left":0.8298611,"top":0.4811111,"width":0.058333334,"height":0.06888889},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.85833335,"top":0.4811111,"width":0.0027777778,"height":0.02}},{"char_start":1,"char_count":20,"bounds":{"left":0.8298611,"top":0.50555557,"width":0.057638887,"height":0.044444446}}],"role_description":"text"},{"role":"AXStaticText","text":"Tag","depth":24,"bounds":{"left":0.7465278,"top":0.55444443,"width":0.015972223,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":":","depth":24,"bounds":{"left":0.7625,"top":0.55444443,"width":0.0027777778,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"View Job","depth":24,"bounds":{"left":0.7465278,"top":0.59444445,"width":0.049305554,"height":0.031111112},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"View Job","depth":26,"bounds":{"left":0.75277776,"top":0.6011111,"width":0.036805555,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"React with white_check_mark","depth":25,"bounds":{"left":0.8048611,"top":0.39333335,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with eyes","depth":25,"bounds":{"left":0.82708335,"top":0.39333335,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with raised_hands","depth":25,"bounds":{"left":0.84930557,"top":0.39333335,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Add reaction…","depth":25,"bounds":{"left":0.8715278,"top":0.39333335,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Reply in thread","depth":25,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Forward message…","depth":25,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Save for later","depth":25,"on_screen":false,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":25,"on_screen":false,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"CircleCI","depth":23,"bounds":{"left":0.7465278,"top":0.6388889,"width":0.0375,"height":0.024444444},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"APP","depth":23,"bounds":{"left":0.7881944,"top":0.64444447,"width":0.013888889,"height":0.013333334},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":23,"bounds":{"left":0.8041667,"top":0.64111114,"width":0.0055555557,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Today at 5:35:58 PM","depth":23,"bounds":{"left":0.8090278,"top":0.64444447,"width":0.031944446,"height":0.016666668},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5:35 PM","depth":24,"bounds":{"left":0.8090278,"top":0.64444447,"width":0.031944446,"height":0.016666668},"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"Deployment Successful! tada emoji","depth":23,"bounds":{"left":0.7465278,"top":0.6677778,"width":0.2361111,"height":0.024444444},"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"Deployment Successful!","depth":25,"bounds":{"left":0.7465278,"top":0.67,"width":0.115277775,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Project","depth":24,"bounds":{"left":0.7465278,"top":0.7077778,"width":0.033333335,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":": app","depth":24,"bounds":{"left":0.7798611,"top":0.7077778,"width":0.022222223,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.7798611,"top":0.7077778,"width":0.0027777778,"height":0.02}},{"char_start":1,"char_count":4,"bounds":{"left":0.7826389,"top":0.7077778,"width":0.019444445,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"When","depth":24,"bounds":{"left":0.8298611,"top":0.7077778,"width":0.028472222,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":": 05/11/2026 14:35:58","depth":24,"bounds":{"left":0.8298611,"top":0.7077778,"width":0.058333334,"height":0.06888889},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.85833335,"top":0.7077778,"width":0.0027777778,"height":0.02}},{"char_start":1,"char_count":20,"bounds":{"left":0.8298611,"top":0.7322222,"width":0.057638887,"height":0.044444446}}],"role_description":"text"},{"role":"AXStaticText","text":"Tag","depth":24,"bounds":{"left":0.7465278,"top":0.7811111,"width":0.015972223,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":":","depth":24,"bounds":{"left":0.7625,"top":0.7811111,"width":0.0027777778,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"View Job","depth":24,"bounds":{"left":0.7465278,"top":0.8211111,"width":0.049305554,"height":0.031111112},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"View Job","depth":26,"bounds":{"left":0.75277776,"top":0.8277778,"width":0.036805555,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"React with white_check_mark","depth":25,"bounds":{"left":0.8048611,"top":0.62,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with eyes","depth":25,"bounds":{"left":0.82708335,"top":0.62,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with raised_hands","depth":25,"bounds":{"left":0.84930557,"top":0.62,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Add reaction…","depth":25,"bounds":{"left":0.8715278,"top":0.62,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Reply in thread","depth":25,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Forward message…","depth":25,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Save for later","depth":25,"on_screen":false,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":25,"on_screen":false,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"","depth":23,"bounds":{"left":0.71666664,"top":0.88,"width":0.26527777,"height":0.04222222},"on_screen":true,"value":"","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"loading…","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Channel releases","depth":11,"on_screen":false,"role_description":"text"}]...
|
4943832644539749684
|
-4308936641396424102
|
visual_change
|
hybrid
|
NULL
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Aneliya Angelova
Galya Dimitrova
Petko Kashinski
Stefka Stoyanova
Vasil Vasilev
Nikolay Ivanov
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Stoyan Tanev
Ves
Lukas Kovalik
you
Jira Cloud
Toast
Messages
Messages
Files
Files
Bookmarks
Bookmarks
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
CircleCI
APP
Today at 4:16:00 PM
4:16 PM
Deployment Successful! tada emoji
Deployment Successful!
Project
: app
When
: 05/11/2026 13:16:00
Tag
:
View Job
View Job
GitHub
APP
Today at 4:45:17 PM
4:45 PM
2 new commits
2 new commits
pushed to
master
master
by
mihailmihaylovjiminny
mihailmihaylovjiminny
4ff69be1
4ff69be1
- JY-17836: Handle non-bot activities
af953504
af953504
- Merge pull request #12067 from jiminny/JY-17836-participant-speeches-hotfix
jiminny/app
jiminny/app
|
Added by
GitHub
GitHub
GitHub
APP
Today at 5:10:22 PM
5:10 PM
13 new commits
13 new commits
pushed to
master
master
by
ilian-jiminny
ilian-jiminny
9353b609
9353b609
- JY-20395 wip
bee402b7
bee402b7
- JY-20395 Stop fetching ALL headers (including file) for email import
d6a0fe5a
d6a0fe5a
- JY-20395 lint
daa0373e
daa0373e
- JY-20395 lint fixes
af59d609
af59d609
- JY-20395 Removed no longer used methods
Show more
jiminny/app
jiminny/app
|
Added by
GitHub
GitHub
CircleCI
APP
Today at 5:11:35 PM
5:11 PM
Deployment Successful! tada emoji
Deployment Successful!
Project
: app
When
: 05/11/2026 14:11:35
Tag
:
View Job
View Job
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
CircleCI
APP
Today at 5:35:58 PM
5:35 PM
Deployment Successful! tada emoji
Deployment Successful!
Project
: app
When
: 05/11/2026 14:35:58
Tag
:
View Job
View Job
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
loading…
Channel releases
SlackFileEditViewGoHistoryWindowHelpDOCKER₴81DEV (docker)₴2APP (-zsh)+ +ScrmService->syncOpportunity('374720564');ScrmService-›matchByName('Robot');end diffFixed 4 of 5666 files in 146.870 seconds, 60.00 MB memory usedWhat's next:Try Docker Debug for seamless, persistentdebugging tools in any containerorimage →Learn moreat [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20725-handle-HS-search-rate-lirdocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.plPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminskius humtostiaanphconerfiuto.dist,plPHP runtime: 8.3.30Running analysis on 7 cores with 10 files per process.Parallel runner is an experimental feature and may be unstable, use it at your own risk.Loadedconfig default from".php-cs-fixer.dist.php".5666/5666 [100%Fixed 0 of 5666 files in 66.457 seconds, 60.00 MB memory usedWhat's next:Try Docker Debug for seamless, persistent debugging tools in any containeror image →Learn more at https://docs.docker.com/go/debug-cli/lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-lirAPP (|-zsh•••EDHomeDMsActivityFilesLater..•More, 0→Jiminny ...# contusion-clinic# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of jimi...^ Direct messagesP. Aneliya Angelova% Galya Dimitrova I& Petko Kashinski&. Stefka StoyanovaVasil Vasilev&. Nikolay IvanovAneliya Angelova, ...Stoyan Tanev&. VesE. Lukas Kovalik y... O::: AppsS Jira CloudToast100% <478• Mon 11 May 19:11:04Describe what you are looking for# releases8 22• MessagesC FilesBookmarks+9353b609 - JYToday~bee402b7- JY-20395 Stop fetching ALLheaders (including file) for email importd6a0fe5a - JY-20395 lintdaa0373e - JY-20395 lint fixesaf59d609 - JY-20395 Removed no longer usedmethodsShow morejiminny/app| Added by GitHubCircleCl APP 5:11 PMDeployment Successful!Project: appWhen:05/11/202614:11:35Tag:View JobCircleCl APP 5:35 PMDeployment Successful!Project: appWhen:05/11/202614:35:58Tag:View JobMessage #releases..•...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20680
|
901
|
2
|
2026-05-11T16:11:05.056896+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515865056_m2.jpg...
|
Slack
|
releases (Channel) - Jiminny Inc - 3 new items - S releases (Channel) - Jiminny Inc - 3 new items - Slack...
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Aneliya Angelova
Galya Dimitrova
Petko Kashinski
Stefka Stoyanova
Vasil Vasilev
Nikolay Ivanov
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Stoyan Tanev
Ves
Lukas Kovalik
you
Jira Cloud
Toast
Messages
Messages
Files
Files
Bookmarks
Bookmarks
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
CircleCI
APP
Today at 4:16:00 PM
4:16 PM
Deployment Successful! tada emoji
Deployment Successful!
Project
: app
When
: 05/11/2026 13:16:00
Tag
:
View Job
View Job
GitHub
APP
Today at 4:45:17 PM
4:45 PM
2 new commits
2 new commits
pushed to
master
master
by
mihailmihaylovjiminny
mihailmihaylovjiminny
4ff69be1
4ff69be1
- JY-17836: Handle non-bot activities
af953504
af953504
- Merge pull request #12067 from jiminny/JY-17836-participant-speeches-hotfix
jiminny/app
jiminny/app
|
Added by
GitHub
GitHub
GitHub
APP
Today at 5:10:22 PM
5:10 PM
13 new commits
13 new commits
pushed to
master
master
by
ilian-jiminny
ilian-jiminny
9353b609
9353b609
- JY-20395 wip
bee402b7
bee402b7
- JY-20395 Stop fetching ALL headers (including file) for email import
d6a0fe5a
d6a0fe5a
- JY-20395 lint
daa0373e
daa0373e
- JY-20395 lint fixes
af59d609
af59d609
- JY-20395 Removed no longer used methods
Show more
jiminny/app
jiminny/app
|
Added by
GitHub
GitHub
CircleCI
APP
Today at 5:11:35 PM
5:11 PM
Deployment Successful! tada emoji
Deployment Successful!
Project
: app
When
: 05/11/2026 14:11:35
Tag
:
View Job
View Job
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
CircleCI
APP
Today at 5:35:58 PM
5:35 PM
Deployment Successful! tada emoji
Deployment Successful!
Project
: app
When
: 05/11/2026 14:35:58
Tag
:
View Job
View Job
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
loading…
Channel releases...
|
[{"role":"AXPopUpButton","text [{"role":"AXPopUpButton","text":"Switch workspaces… (Jiminny Inc) Has new messages","depth":14,"bounds":{"left":0.5152925,"top":1.0,"width":0.011968086,"height":-0.058260202},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Home","depth":14,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"Home","depth":16,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"DMs","depth":14,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"DMs","depth":16,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Activity","depth":14,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Activity","depth":16,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Files","depth":14,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Files","depth":16,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Later","depth":14,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Later","depth":16,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"More…","depth":14,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More","depth":16,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Unreads","depth":21,"bounds":{"left":0.5465425,"top":1.0,"width":0.018284574,"height":-0.09177971},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Threads","depth":21,"bounds":{"left":0.5465425,"top":1.0,"width":0.01761968,"height":-0.09177971},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Huddles","depth":21,"bounds":{"left":0.5465425,"top":1.0,"width":0.018284574,"height":-0.09177971},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Drafts & sent","depth":21,"bounds":{"left":0.5465425,"top":1.0,"width":0.02925532,"height":-0.09177971},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":21,"bounds":{"left":0.5980718,"top":1.0,"width":0.0026595744,"height":-0.09177971},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Directories","depth":21,"bounds":{"left":0.5465425,"top":1.0,"width":0.024268618,"height":-0.09177971},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":23,"bounds":{"left":0.5518617,"top":1.0,"width":0.043882977,"height":-0.09177971},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"platform-inner-team","depth":23,"bounds":{"left":0.5518617,"top":1.0,"width":0.04454787,"height":-0.09177971},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ai-chapter","depth":23,"bounds":{"left":0.5518617,"top":1.0,"width":0.022273935,"height":-0.09177971},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"alerts","depth":23,"bounds":{"left":0.5518617,"top":1.0,"width":0.012300532,"height":-0.09177971},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"backend","depth":23,"bounds":{"left":0.5518617,"top":1.0,"width":0.018284574,"height":-0.09177971},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"bugs","depth":23,"bounds":{"left":0.5518617,"top":1.0,"width":0.010638298,"height":-0.09177971},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"confusion-clinic","depth":23,"bounds":{"left":0.5518617,"top":1.0,"width":0.034574468,"height":-0.09177971},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"curiosity_lab","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"engineering","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"general","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"jiminny-bg","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"platform-tickets","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"product_launches","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"random","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"releases","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"sofia-office","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"support","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"thank-yous","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"the_people_of_jiminny","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Aneliya Angelova","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Galya Dimitrova","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Petko Kashinski","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Stefka Stoyanova","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Vasil Vasilev","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Nikolay Ivanov","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Aneliya Angelova","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Nikolay Yankov","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Steliyan Georgiev","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Stoyan Tanev","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Ves","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"you","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Jira Cloud","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Toast","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Messages","depth":17,"bounds":{"left":0.61170214,"top":1.0,"width":0.030917553,"height":-0.09177971},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"Messages","depth":19,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Files","depth":17,"bounds":{"left":0.64361703,"top":1.0,"width":0.020944148,"height":-0.09177971},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Files","depth":19,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Bookmarks","depth":17,"bounds":{"left":0.66589093,"top":1.0,"width":0.033909574,"height":-0.09177971},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Bookmarks","depth":19,"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"Add and Edit Channel Tabs","depth":17,"bounds":{"left":0.70079786,"top":1.0,"width":0.010970744,"height":-0.09177971},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Canvas","depth":17,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"List","depth":17,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Folder","depth":17,"on_screen":false,"role_description":"text"},{"role":"AXPopUpButton","text":"Jump to date","depth":22,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"CircleCI","depth":23,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"APP","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Today at 4:16:00 PM","depth":23,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"4:16 PM","depth":24,"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"Deployment Successful! tada emoji","depth":23,"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"Deployment Successful!","depth":25,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Project","depth":24,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":": app","depth":24,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"When","depth":24,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":": 05/11/2026 13:16:00","depth":24,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Tag","depth":24,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":":","depth":24,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"View Job","depth":24,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"View Job","depth":26,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"GitHub","depth":23,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"APP","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Today at 4:45:17 PM","depth":23,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"4:45 PM","depth":24,"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"2 new commits","depth":23,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"2 new commits","depth":24,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"pushed to","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"master","depth":24,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"master","depth":25,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"by","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"mihailmihaylovjiminny","depth":23,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":24,"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"4ff69be1","depth":26,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"4ff69be1","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"- JY-17836: Handle non-bot activities","depth":25,"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"af953504","depth":26,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"af953504","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"- Merge pull request #12067 from jiminny/JY-17836-participant-speeches-hotfix","depth":25,"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"jiminny/app","depth":25,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"jiminny/app","depth":26,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"|","depth":25,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Added by","depth":25,"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"GitHub","depth":25,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"GitHub","depth":26,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"GitHub","depth":23,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"APP","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Today at 5:10:22 PM","depth":23,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5:10 PM","depth":24,"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"13 new commits","depth":23,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"13 new commits","depth":24,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"pushed to","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"master","depth":24,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"master","depth":25,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"by","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"ilian-jiminny","depth":23,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"ilian-jiminny","depth":24,"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"9353b609","depth":26,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"9353b609","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"- JY-20395 wip","depth":25,"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"bee402b7","depth":26,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"bee402b7","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"- JY-20395 Stop fetching ALL headers (including file) for email import","depth":25,"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"d6a0fe5a","depth":26,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"d6a0fe5a","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"- JY-20395 lint","depth":25,"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"daa0373e","depth":26,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"daa0373e","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"- JY-20395 lint fixes","depth":25,"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"af59d609","depth":26,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"af59d609","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"- JY-20395 Removed no longer used methods","depth":25,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Show more","depth":25,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"jiminny/app","depth":25,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"jiminny/app","depth":26,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"|","depth":25,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Added by","depth":25,"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"GitHub","depth":25,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"GitHub","depth":26,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"CircleCI","depth":23,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"APP","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Today at 5:11:35 PM","depth":23,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5:11 PM","depth":24,"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"Deployment Successful! tada emoji","depth":23,"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"Deployment Successful!","depth":25,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Project","depth":24,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":": app","depth":24,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"When","depth":24,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":": 05/11/2026 14:11:35","depth":24,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Tag","depth":24,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":":","depth":24,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"View Job","depth":24,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"View Job","depth":26,"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"React with white_check_mark","depth":25,"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with eyes","depth":25,"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with raised_hands","depth":25,"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Add reaction…","depth":25,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Reply in thread","depth":25,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Forward message…","depth":25,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Save for later","depth":25,"on_screen":false,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":25,"on_screen":false,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"CircleCI","depth":23,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"APP","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Today at 5:35:58 PM","depth":23,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5:35 PM","depth":24,"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"Deployment Successful! tada emoji","depth":23,"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"Deployment Successful!","depth":25,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Project","depth":24,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":": app","depth":24,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"When","depth":24,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":": 05/11/2026 14:35:58","depth":24,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Tag","depth":24,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":":","depth":24,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"View Job","depth":24,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"View Job","depth":26,"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"React with white_check_mark","depth":25,"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with eyes","depth":25,"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with raised_hands","depth":25,"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Add reaction…","depth":25,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Reply in thread","depth":25,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Forward message…","depth":25,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Save for later","depth":25,"on_screen":false,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":25,"on_screen":false,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"","depth":23,"on_screen":true,"value":"","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"loading…","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Channel releases","depth":11,"on_screen":false,"role_description":"text"}]...
|
4943832644539749684
|
-4308936641396424102
|
visual_change
|
hybrid
|
NULL
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Aneliya Angelova
Galya Dimitrova
Petko Kashinski
Stefka Stoyanova
Vasil Vasilev
Nikolay Ivanov
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Stoyan Tanev
Ves
Lukas Kovalik
you
Jira Cloud
Toast
Messages
Messages
Files
Files
Bookmarks
Bookmarks
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
CircleCI
APP
Today at 4:16:00 PM
4:16 PM
Deployment Successful! tada emoji
Deployment Successful!
Project
: app
When
: 05/11/2026 13:16:00
Tag
:
View Job
View Job
GitHub
APP
Today at 4:45:17 PM
4:45 PM
2 new commits
2 new commits
pushed to
master
master
by
mihailmihaylovjiminny
mihailmihaylovjiminny
4ff69be1
4ff69be1
- JY-17836: Handle non-bot activities
af953504
af953504
- Merge pull request #12067 from jiminny/JY-17836-participant-speeches-hotfix
jiminny/app
jiminny/app
|
Added by
GitHub
GitHub
GitHub
APP
Today at 5:10:22 PM
5:10 PM
13 new commits
13 new commits
pushed to
master
master
by
ilian-jiminny
ilian-jiminny
9353b609
9353b609
- JY-20395 wip
bee402b7
bee402b7
- JY-20395 Stop fetching ALL headers (including file) for email import
d6a0fe5a
d6a0fe5a
- JY-20395 lint
daa0373e
daa0373e
- JY-20395 lint fixes
af59d609
af59d609
- JY-20395 Removed no longer used methods
Show more
jiminny/app
jiminny/app
|
Added by
GitHub
GitHub
CircleCI
APP
Today at 5:11:35 PM
5:11 PM
Deployment Successful! tada emoji
Deployment Successful!
Project
: app
When
: 05/11/2026 14:11:35
Tag
:
View Job
View Job
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
CircleCI
APP
Today at 5:35:58 PM
5:35 PM
Deployment Successful! tada emoji
Deployment Successful!
Project
: app
When
: 05/11/2026 14:35:58
Tag
:
View Job
View Job
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
loading…
Channel releases
slackО000New Tab• Jy 20820 es reindex stream mode4 [JY-20725] [HubSpot) Optimise CFSevenShores Hubspot ExceptionsJY-20725 add HS rate limit har XPipelines - jiminny/apPull requests • jiminny/app• New TabMistomyOpenJY-20725 add HS rate limit handling on activities rematching #12066LakyLak wants to merge 4 commits into master from JY-20725-handle-HS-search-rate-limit0.0% Duplication on New CodeSee analvsis details on SonarQube Cloud• @ Jy-20725 code review suggestionsInis oranch nas not veen cemloveoiReview reauiredAt least 1 aoorovina review is reauired bv reviewers with write access.Some checks haven't completed vet2 oendina. 1 in orodress. 1 exoected. 7 successtul checksnondina chortev• 0 ci/circleci: test Waiting for status to be reported — CircleCl is running your tests• • ci/circleci: test-backend-lint Waiting for status to be reported — CircleCl is running vour tests• € SonarCloud Code Analysis Expected — Waiting for status to be reported1 in proqress check v• 0 build_accept_deploy Started 7 minutes ago — Workflow: build_accept_deployv O ci/circleci: build-backend - Your tests passed on CircleCI:Thic hranch ic out.of-aato with tha haco hranchMerge the latest changes from master into this branch. This merge commit will belaccociated with I akvl akMerging is blockedAt least 1 anorovina review is reauired bv reviewers with write access.Enable auto-meraeYou can also merge this with the command line. View command line instructions.Add a commont|• 0f3e438RequiredUindate branchStill in nroaress? Convert to draftvent.phpnotematch.ong$12123S0 Ml A m (A100% 5• мon 11 Mаy 19.11:04= laravel.logA SF [jiminny@localhost]4 HS_local [jiminny@localhost]A console [PROD]« console [EU]& console [STAGING]"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEs0kXi ~07-May-26 14:51:15 GMT: domain=.hubapi.com: Http0nly: Secure: SameSite=None"].w19A"keporc-10".""endpoints":N"urL\":1"https:(VNVa.net.cloudf Lare.com\V/report\V/v4?s=NYALsVTPotYm52qrSDJxYE4sd2RwRq15p5wHsmd=g<Lz@YdxLx2B1XVpHmsKnS0%2BKVA5mF1J2m/YRECD65nx2BW2LYT206FM4%2l v("groupl"; \"cf-nel!","max age":604800,"J,"NEL":""*"success traction".0.olg"max ade":6048002""Serven":"cloudflare"?>4"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab","trace id"."c7ab8365-903f-46d4-9403-0e5b551e3545"}1 differencecurrent version'driver' => 'errorlog','level' => env('LOG LEVEL', 'info')'path' => storage path('loas/laravel.10g')custom channel' =>nath' => storage pathc'Loas/custom.1o0'))W Windsurf Teams 1-1UTF.Rfo 4 spaces 0...
|
20678
|
NULL
|
NULL
|
NULL
|
|
20681
|
900
|
3
|
2026-05-11T16:11:07.791028+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515867791_m1.jpg...
|
Firefox
|
All docs · AFFiNE — Personal
|
1
|
app.affine.pro/workspace/e90fb5c6-df16-48cd-ab2d-f app.affine.pro/workspace/e90fb5c6-df16-48cd-ab2d-fe7a6cb103bc/all...
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
SlackFileEditViewGoHistoryWindowHelpAPP (|•••ED→DO SlackFileEditViewGoHistoryWindowHelpAPP (|•••ED→DOCKER₴81DEV (docker)₴2APP (-zsh)-zshScrmService->syncOpportunity('374720564');ScrmService-›matchByName('Robot');Homeend diffFixed 4 of 5666 files in 146.870 seconds, 60.00 MBmemory usedDMsWhat's next:Try DockerDebug forseamless, persistentdebuggingtools in any containerimage →Learn moreat [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20725-handle-HS-search-rate-lirdocker exec -it docker_lamp_1./vendor/bin/php-cs-fixer fix--config=.php-cs-fixer.dist.plPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminskiandcontributors.PHP runtime:8.3.30Running analysis on 7 cores with 10 files per process.Parallel runner is an experimental feature and may be unstable, use it at your own risk.Loadedconfig default from".php-cs-fixer.dist.php".5666/5666100%Fixed 0 of 5666 files in 66.457 seconds, 60.00 MBmemory usedNrorenyarmguere®. Galya Dimitrova E&. Petko Kashinski&. Stefka StoyanovaVasil Vasilev. Nikolay Ivanov(3 Aneliya Angelova, ...2o Stoyan Tanev&o VesLukas Kovalik y...What's next:Try Docker Debug for seamless, persistentdebugging tools in any containerimage +Learn more at https://docs.docker.com/go/debug-cli/lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-lir::: Apps$ Jira CloudToast100% <478• Mon 11 May 19:11:07Describe what you are looking for# releases8 226 0• MessagesC FilesBookmarks+9353b609 - JYToday~bee402b7- JY-20395 Stop fetching ALLheaders (including file) for email importd6a0fe5a - JY-20395 lintdaa0373e - JY-20395 lint fixesaf59d609 - JY-20395 Removed no longer usedmethodsShow morejiminny/app| Added by GitHubN0o|View JobCircleCl APP5:35 PMDeployment Successful!Project: appWhen:05/11/202614:35:58Tag:View JobMessage #releases+Aa...
|
NULL
|
-410833402243243900
|
NULL
|
visual_change
|
ocr
|
NULL
|
SlackFileEditViewGoHistoryWindowHelpAPP (|•••ED→DO SlackFileEditViewGoHistoryWindowHelpAPP (|•••ED→DOCKER₴81DEV (docker)₴2APP (-zsh)-zshScrmService->syncOpportunity('374720564');ScrmService-›matchByName('Robot');Homeend diffFixed 4 of 5666 files in 146.870 seconds, 60.00 MBmemory usedDMsWhat's next:Try DockerDebug forseamless, persistentdebuggingtools in any containerimage →Learn moreat [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20725-handle-HS-search-rate-lirdocker exec -it docker_lamp_1./vendor/bin/php-cs-fixer fix--config=.php-cs-fixer.dist.plPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminskiandcontributors.PHP runtime:8.3.30Running analysis on 7 cores with 10 files per process.Parallel runner is an experimental feature and may be unstable, use it at your own risk.Loadedconfig default from".php-cs-fixer.dist.php".5666/5666100%Fixed 0 of 5666 files in 66.457 seconds, 60.00 MBmemory usedNrorenyarmguere®. Galya Dimitrova E&. Petko Kashinski&. Stefka StoyanovaVasil Vasilev. Nikolay Ivanov(3 Aneliya Angelova, ...2o Stoyan Tanev&o VesLukas Kovalik y...What's next:Try Docker Debug for seamless, persistentdebugging tools in any containerimage +Learn more at https://docs.docker.com/go/debug-cli/lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-lir::: Apps$ Jira CloudToast100% <478• Mon 11 May 19:11:07Describe what you are looking for# releases8 226 0• MessagesC FilesBookmarks+9353b609 - JYToday~bee402b7- JY-20395 Stop fetching ALLheaders (including file) for email importd6a0fe5a - JY-20395 lintdaa0373e - JY-20395 lint fixesaf59d609 - JY-20395 Removed no longer usedmethodsShow morejiminny/app| Added by GitHubN0o|View JobCircleCl APP5:35 PMDeployment Successful!Project: appWhen:05/11/202614:35:58Tag:View JobMessage #releases+Aa...
|
20679
|
NULL
|
NULL
|
NULL
|
|
20682
|
900
|
4
|
2026-05-11T16:11:10.738255+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515870738_m1.jpg...
|
Firefox
|
All docs · AFFiNE — Personal
|
1
|
app.affine.pro/workspace/e90fb5c6-df16-48cd-ab2d-f app.affine.pro/workspace/e90fb5c6-df16-48cd-ab2d-fe7a6cb103bc/all...
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
New Tab
New Tab
Screenpipe — Archive
Screenpipe — New Tab
New Tab
Screenpipe — Archive
Screenpipe — Archive
All docs · AFFiNE
All docs · AFFiNE
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Open history (⇧⌘H)
Open bookmarks (⌘B)
Bitwarden
Open this doc in AFFiNE app
Don't have the app?
Click to download
Click to download
.
Remember choice
Remember choice
Remember choice
Dismiss
Dismiss
Open in app
Open in app
Demo Workspace Syncing...
Demo Workspace
Syncing...
Lukáš Koválik
Search
All docs
All docs
Journals
Journals
Notifications
Intelligence
Intelligence
Settings
Favorites
Favorites
No favorites
Organize
Organize
First Folder
Tags
Tags
Collections
Collections
Others
Others
Trash
Trash
Import
Invite members
Template
Learn more
Learn more
Download App
Download App
Docs
Docs
Collections
Collections
Tags
Tags
Display
Display
New doc
New doc
All
May 8, 2026
·
1
Select all
Select all
Getting Started Welcome to AFFiNE! You can start with a normal page, with richly formatted text, markdown support, links, and a lot of other blocks.Click anywhere to start typingClick to edit this line, and then drag the handle on the left to reorder blocks / for more blocks @ for linking and mentioning (docs | users | dates)e.g. select a date to leave a note for that day, view by date in Journalsnested lists can be toggledto expand and collapseSo is headingsClick + in left navigation for new docsData-intensive blocksTableTry the Edgeless CanvasObserve what AFFiNE can doVisit AFFiNE Invite and collaborateExamples for advanced blocksFor example, Latex math. To explore more, type / To solve the equation , follow these steps:Start by simplifying the equation:Add 1 to both sides to isolate the term:Take the square root of both sides to solve for :Therefore, the solutions to the equation are and .Continue with the rabbit holeToggle the switch next to the title, to continue into the rabbit hole of intuitive organizational prowess ~ ! 3d ago 3d ago Lukáš Koválik
Getting Started
Welcome to AFFiNE! You can start with a normal page, with richly formatted text, markdown support, links, and a lot of other blocks.Click anywhere to start typingClick to edit this line, and then drag the handle on the left to reorder blocks / for more blocks @ for linking and mentioning (docs | users | dates)e.g. select a date to leave a note for that day, view by date in Journalsnested lists can be toggledto expand and collapseSo is headingsClick + in left navigation for new docsData-intensive blocksTableTry the Edgeless CanvasObserve what AFFiNE can doVisit AFFiNE Invite and collaborateExamples for advanced blocksFor example, Latex math. To explore more, type / To solve the equation , follow these steps:Start by simplifying the equation:Add 1 to both sides to isolate the term:Take the square root of both sides to solve for :Therefore, the solutions to the equation are and .Continue with the rabbit holeToggle the switch next to the title, to continue into the rabbit hole of intuitive organizational prowess ~ !
3d ago
3d ago
Never Updated
·
1
Select all
Select all
How to use folder and Tags Create folder, and move docs into folders (dragging works as well)Docs can belong to multiple foldersExpand info to view and manage tags 3d ago Lukáš Koválik
How to use folder and Tags
Create folder, and move docs into folders (dragging works as well)Docs can belong to multiple foldersExpand info to view and manage tags
3d ago
May 8, 2026
·
1
Select all
Select all...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"New Tab","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"New Tab","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Screenpipe — Archive","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Screenpipe — Archive","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"All docs · AFFiNE","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"All docs · AFFiNE","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"New Tab","depth":4,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"bounds":{"left":0.05590278,"top":0.0,"width":0.022222223,"height":0.035555556},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"bounds":{"left":0.078819446,"top":0.0,"width":0.022222223,"height":0.035555556},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open history (⇧⌘H)","depth":6,"bounds":{"left":0.10208333,"top":0.0,"width":0.022222223,"height":0.035555556},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open bookmarks (⌘B)","depth":6,"bounds":{"left":0.12534723,"top":0.0,"width":0.022222223,"height":0.035555556},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Bitwarden","depth":6,"bounds":{"left":0.14861111,"top":0.0,"width":0.022222223,"height":0.035555556},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Open this doc in AFFiNE app","depth":9,"bounds":{"left":0.79965276,"top":0.115,"width":0.13194445,"height":0.019444445},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Don't have the app?","depth":10,"bounds":{"left":0.79965276,"top":0.14222223,"width":0.08090278,"height":0.016666668},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Click to download","depth":10,"bounds":{"left":0.88055557,"top":0.14222223,"width":0.07048611,"height":0.016666668},"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Click to download","depth":11,"bounds":{"left":0.88055557,"top":0.14222223,"width":0.07048611,"height":0.016666668},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":10,"bounds":{"left":0.95104164,"top":0.14222223,"width":0.0020833334,"height":0.016666668},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Remember choice","depth":10,"bounds":{"left":0.79965276,"top":0.1711111,"width":0.013888889,"height":0.022222223},"on_screen":false,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Remember choice","depth":11,"bounds":{"left":0.79965276,"top":0.1711111,"width":0.013888889,"height":0.022222223},"on_screen":false,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Remember choice","depth":10,"bounds":{"left":0.81631947,"top":0.17388889,"width":0.071875,"height":0.016666668},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Dismiss","depth":9,"bounds":{"left":0.934375,"top":0.16777778,"width":0.04826389,"height":0.028888889},"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Dismiss","depth":11,"bounds":{"left":0.94340277,"top":0.17388889,"width":0.030208332,"height":0.016666668},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open in app","depth":9,"bounds":{"left":0.98541665,"top":0.16777778,"width":0.014583349,"height":0.028888889},"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Open in app","depth":11,"bounds":{"left":0.99444443,"top":0.17388889,"width":0.00555557,"height":0.016666668},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Demo Workspace Syncing...","depth":10,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Demo Workspace","depth":16,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Syncing...","depth":16,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Lukáš Koválik","depth":10,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Search","depth":12,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"All docs","depth":10,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"All docs","depth":13,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Journals","depth":10,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Journals","depth":13,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Notifications","depth":12,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Intelligence","depth":10,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Intelligence","depth":13,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Settings","depth":12,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Favorites","depth":11,"on_screen":true,"help_text":"","role_description":"switch","subrole":"AXSwitch","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Favorites","depth":12,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"No favorites","depth":14,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Organize","depth":11,"on_screen":true,"help_text":"","role_description":"switch","subrole":"AXSwitch","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Organize","depth":12,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"First Folder","depth":16,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Tags","depth":11,"on_screen":true,"help_text":"","role_description":"switch","subrole":"AXSwitch","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Tags","depth":12,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Collections","depth":11,"on_screen":true,"help_text":"","role_description":"switch","subrole":"AXSwitch","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Collections","depth":12,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Others","depth":11,"on_screen":true,"help_text":"","role_description":"switch","subrole":"AXSwitch","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Others","depth":12,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Trash","depth":12,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Trash","depth":15,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Import","depth":14,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Invite members","depth":14,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Template","depth":15,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Learn more","depth":12,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Learn more","depth":15,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Download App","depth":10,"bounds":{"left":0.29722223,"top":0.0,"width":0.15243055,"height":0.057777777},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Download App","depth":12,"bounds":{"left":0.33958334,"top":0.0,"width":0.06701389,"height":0.019444445},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Docs","depth":11,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Docs","depth":12,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Collections","depth":11,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Collections","depth":12,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Tags","depth":11,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Tags","depth":12,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Display","depth":11,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Display","depth":13,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"New doc","depth":12,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"New doc","depth":15,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"All","depth":11,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"May 8, 2026","depth":14,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"·","depth":14,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1","depth":14,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Select all","depth":13,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Select all","depth":15,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Getting Started Welcome to AFFiNE! You can start with a normal page, with richly formatted text, markdown support, links, and a lot of other blocks.Click anywhere to start typingClick to edit this line, and then drag the handle on the left to reorder blocks / for more blocks @ for linking and mentioning (docs | users | dates)e.g. select a date to leave a note for that day, view by date in Journalsnested lists can be toggledto expand and collapseSo is headingsClick + in left navigation for new docsData-intensive blocksTableTry the Edgeless CanvasObserve what AFFiNE can doVisit AFFiNE Invite and collaborateExamples for advanced blocksFor example, Latex math. To explore more, type / To solve the equation , follow these steps:Start by simplifying the equation:Add 1 to both sides to isolate the term:Take the square root of both sides to solve for :Therefore, the solutions to the equation are and .Continue with the rabbit holeToggle the switch next to the title, to continue into the rabbit hole of intuitive organizational prowess ~ ! 3d ago 3d ago Lukáš Koválik","depth":13,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Getting Started","depth":16,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Welcome to AFFiNE! You can start with a normal page, with richly formatted text, markdown support, links, and a lot of other blocks.Click anywhere to start typingClick to edit this line, and then drag the handle on the left to reorder blocks / for more blocks @ for linking and mentioning (docs | users | dates)e.g. select a date to leave a note for that day, view by date in Journalsnested lists can be toggledto expand and collapseSo is headingsClick + in left navigation for new docsData-intensive blocksTableTry the Edgeless CanvasObserve what AFFiNE can doVisit AFFiNE Invite and collaborateExamples for advanced blocksFor example, Latex math. To explore more, type / To solve the equation , follow these steps:Start by simplifying the equation:Add 1 to both sides to isolate the term:Take the square root of both sides to solve for :Therefore, the solutions to the equation are and .Continue with the rabbit holeToggle the switch next to the title, to continue into the rabbit hole of intuitive organizational prowess ~ !","depth":16,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"3d ago","depth":16,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"3d ago","depth":16,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Never Updated","depth":14,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"·","depth":14,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1","depth":14,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Select all","depth":13,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Select all","depth":15,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"How to use folder and Tags Create folder, and move docs into folders (dragging works as well)Docs can belong to multiple foldersExpand info to view and manage tags 3d ago Lukáš Koválik","depth":13,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"How to use folder and Tags","depth":16,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Create folder, and move docs into folders (dragging works as well)Docs can belong to multiple foldersExpand info to view and manage tags","depth":16,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"3d ago","depth":16,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"May 8, 2026","depth":13,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"·","depth":13,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1","depth":13,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Select all","depth":12,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Select all","depth":14,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
3259511910026715672
|
8681629596791964228
|
visual_change
|
accessibility
|
NULL
|
New Tab
New Tab
Screenpipe — Archive
Screenpipe — New Tab
New Tab
Screenpipe — Archive
Screenpipe — Archive
All docs · AFFiNE
All docs · AFFiNE
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Open history (⇧⌘H)
Open bookmarks (⌘B)
Bitwarden
Open this doc in AFFiNE app
Don't have the app?
Click to download
Click to download
.
Remember choice
Remember choice
Remember choice
Dismiss
Dismiss
Open in app
Open in app
Demo Workspace Syncing...
Demo Workspace
Syncing...
Lukáš Koválik
Search
All docs
All docs
Journals
Journals
Notifications
Intelligence
Intelligence
Settings
Favorites
Favorites
No favorites
Organize
Organize
First Folder
Tags
Tags
Collections
Collections
Others
Others
Trash
Trash
Import
Invite members
Template
Learn more
Learn more
Download App
Download App
Docs
Docs
Collections
Collections
Tags
Tags
Display
Display
New doc
New doc
All
May 8, 2026
·
1
Select all
Select all
Getting Started Welcome to AFFiNE! You can start with a normal page, with richly formatted text, markdown support, links, and a lot of other blocks.Click anywhere to start typingClick to edit this line, and then drag the handle on the left to reorder blocks / for more blocks @ for linking and mentioning (docs | users | dates)e.g. select a date to leave a note for that day, view by date in Journalsnested lists can be toggledto expand and collapseSo is headingsClick + in left navigation for new docsData-intensive blocksTableTry the Edgeless CanvasObserve what AFFiNE can doVisit AFFiNE Invite and collaborateExamples for advanced blocksFor example, Latex math. To explore more, type / To solve the equation , follow these steps:Start by simplifying the equation:Add 1 to both sides to isolate the term:Take the square root of both sides to solve for :Therefore, the solutions to the equation are and .Continue with the rabbit holeToggle the switch next to the title, to continue into the rabbit hole of intuitive organizational prowess ~ ! 3d ago 3d ago Lukáš Koválik
Getting Started
Welcome to AFFiNE! You can start with a normal page, with richly formatted text, markdown support, links, and a lot of other blocks.Click anywhere to start typingClick to edit this line, and then drag the handle on the left to reorder blocks / for more blocks @ for linking and mentioning (docs | users | dates)e.g. select a date to leave a note for that day, view by date in Journalsnested lists can be toggledto expand and collapseSo is headingsClick + in left navigation for new docsData-intensive blocksTableTry the Edgeless CanvasObserve what AFFiNE can doVisit AFFiNE Invite and collaborateExamples for advanced blocksFor example, Latex math. To explore more, type / To solve the equation , follow these steps:Start by simplifying the equation:Add 1 to both sides to isolate the term:Take the square root of both sides to solve for :Therefore, the solutions to the equation are and .Continue with the rabbit holeToggle the switch next to the title, to continue into the rabbit hole of intuitive organizational prowess ~ !
3d ago
3d ago
Never Updated
·
1
Select all
Select all
How to use folder and Tags Create folder, and move docs into folders (dragging works as well)Docs can belong to multiple foldersExpand info to view and manage tags 3d ago Lukáš Koválik
How to use folder and Tags
Create folder, and move docs into folders (dragging works as well)Docs can belong to multiple foldersExpand info to view and manage tags
3d ago
May 8, 2026
·
1
Select all
Select all...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20683
|
901
|
3
|
2026-05-11T16:11:11.098297+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515871098_m2.jpg...
|
Firefox
|
All docs · AFFiNE — Personal
|
1
|
app.affine.pro/workspace/e90fb5c6-df16-48cd-ab2d-f app.affine.pro/workspace/e90fb5c6-df16-48cd-ab2d-fe7a6cb103bc/all...
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
New Tab
New Tab
Screenpipe — Archive
Screenpipe — New Tab
New Tab
Screenpipe — Archive
Screenpipe — Archive
All docs · AFFiNE
All docs · AFFiNE
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Open history (⇧⌘H)
Open bookmarks (⌘B)
Bitwarden
Open this doc in AFFiNE app
Don't have the app?
Click to download
Click to download
.
Remember choice
Remember choice
Remember choice
Dismiss
Dismiss
Open in app
Open in app
Demo Workspace Syncing...
Demo Workspace
Syncing...
Lukáš Koválik
Search
All docs
All docs
Journals
Journals
Notifications
Intelligence
Intelligence
Settings
Favorites
Favorites
No favorites
Organize
Organize
First Folder
Tags
Tags
Collections
Collections
Others
Others
Trash
Trash
Import
Invite members
Template
Learn more
Learn more
Download App
Download App
Docs
Docs
Collections
Collections
Tags
Tags
Display
Display
New doc
New doc
All
May 8, 2026
·
1
Select all
Select all
Getting Started Welcome to AFFiNE! You can start with a normal page, with richly formatted text, markdown support, links, and a lot of other blocks.Click anywhere to start typingClick to edit this line, and then drag the handle on the left to reorder blocks / for more blocks @ for linking and mentioning (docs | users | dates)e.g. select a date to leave a note for that day, view by date in Journalsnested lists can be toggledto expand and collapseSo is headingsClick + in left navigation for new docsData-intensive blocksTableTry the Edgeless CanvasObserve what AFFiNE can doVisit AFFiNE Invite and collaborateExamples for advanced blocksFor example, Latex math. To explore more, type / To solve the equation , follow these steps:Start by simplifying the equation:Add 1 to both sides to isolate the term:Take the square root of both sides to solve for :Therefore, the solutions to the equation are and .Continue with the rabbit holeToggle the switch next to the title, to continue into the rabbit hole of intuitive organizational prowess ~ ! 3d ago 3d ago Lukáš Koválik
Getting Started
Welcome to AFFiNE! You can start with a normal page, with richly formatted text, markdown support, links, and a lot of other blocks.Click anywhere to start typingClick to edit this line, and then drag the handle on the left to reorder blocks / for more blocks @ for linking and mentioning (docs | users | dates)e.g. select a date to leave a note for that day, view by date in Journalsnested lists can be toggledto expand and collapseSo is headingsClick + in left navigation for new docsData-intensive blocksTableTry the Edgeless CanvasObserve what AFFiNE can doVisit AFFiNE Invite and collaborateExamples for advanced blocksFor example, Latex math. To explore more, type / To solve the equation , follow these steps:Start by simplifying the equation:Add 1 to both sides to isolate the term:Take the square root of both sides to solve for :Therefore, the solutions to the equation are and .Continue with the rabbit holeToggle the switch next to the title, to continue into the rabbit hole of intuitive organizational prowess ~ !
3d ago
3d ago
Never Updated
·
1
Select all
Select all
How to use folder and Tags Create folder, and move docs into folders (dragging works as well)Docs can belong to multiple foldersExpand info to view and manage tags 3d ago Lukáš Koválik
How to use folder and Tags
Create folder, and move docs into folders (dragging works as well)Docs can belong to multiple foldersExpand info to view and manage tags
3d ago
May 8, 2026
·
1
Select all
Select all...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"New Tab","depth":4,"bounds":{"left":0.2942154,"top":0.0518755,"width":0.113696806,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"New Tab","depth":5,"bounds":{"left":0.3075133,"top":0.06304868,"width":0.014960106,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Screenpipe — Archive","depth":4,"bounds":{"left":0.2942154,"top":0.08459697,"width":0.113696806,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Screenpipe — Archive","depth":5,"bounds":{"left":0.3075133,"top":0.09577015,"width":0.037898935,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"All docs · AFFiNE","depth":4,"bounds":{"left":0.2942154,"top":0.11731844,"width":0.113696806,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"All docs · AFFiNE","depth":5,"bounds":{"left":0.3075133,"top":0.12849163,"width":0.029587766,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.3956117,"top":0.1245012,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.29704124,"top":0.15163608,"width":0.108211435,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"bounds":{"left":0.29704124,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"bounds":{"left":0.30801198,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open history (⇧⌘H)","depth":6,"bounds":{"left":0.31914893,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open bookmarks (⌘B)","depth":6,"bounds":{"left":0.3302859,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Bitwarden","depth":6,"bounds":{"left":0.3414229,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Open this doc in AFFiNE app","depth":9,"bounds":{"left":0.6530917,"top":1.0,"width":0.0631649,"height":-0.082601786},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Don't have the app?","depth":10,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Click to download","depth":10,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Click to download","depth":11,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":10,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Remember choice","depth":10,"on_screen":false,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Remember choice","depth":11,"on_screen":false,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Remember choice","depth":10,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Dismiss","depth":9,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Dismiss","depth":11,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open in app","depth":9,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Open in app","depth":11,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Demo Workspace Syncing...","depth":10,"bounds":{"left":0.4105718,"top":0.101356745,"width":0.06565824,"height":0.023942538},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Demo Workspace","depth":16,"bounds":{"left":0.421875,"top":0.10654429,"width":0.0390625,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Syncing...","depth":16,"bounds":{"left":0.42785904,"top":0.12410215,"width":0.02144282,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Lukáš Koválik","depth":10,"bounds":{"left":0.4788896,"top":0.105347164,"width":0.0066489363,"height":0.015961692},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Search","depth":12,"bounds":{"left":0.42320478,"top":0.13846768,"width":0.015292553,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"All docs","depth":10,"bounds":{"left":0.41256648,"top":0.16041501,"width":0.072972074,"height":0.023942538},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"All docs","depth":13,"bounds":{"left":0.42320478,"top":0.16560255,"width":0.017287234,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Journals","depth":10,"bounds":{"left":0.41256648,"top":0.18435754,"width":0.072972074,"height":0.027134877},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Journals","depth":13,"bounds":{"left":0.42320478,"top":0.19273743,"width":0.01861702,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Notifications","depth":12,"bounds":{"left":0.42320478,"top":0.21987231,"width":0.027759308,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Intelligence","depth":10,"bounds":{"left":0.41256648,"top":0.2386273,"width":0.072972074,"height":0.027134877},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Intelligence","depth":13,"bounds":{"left":0.42320478,"top":0.24700718,"width":0.025265958,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Settings","depth":12,"bounds":{"left":0.42320478,"top":0.27414206,"width":0.018118352,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Favorites","depth":11,"bounds":{"left":0.4105718,"top":0.30247405,"width":0.076961435,"height":0.015961692},"on_screen":true,"help_text":"","role_description":"switch","subrole":"AXSwitch","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Favorites","depth":12,"bounds":{"left":0.41323137,"top":0.3044693,"width":0.01761968,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"No favorites","depth":14,"bounds":{"left":0.43567154,"top":0.36671987,"width":0.026761968,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Organize","depth":11,"bounds":{"left":0.4105718,"top":0.3982442,"width":0.076961435,"height":0.015961692},"on_screen":true,"help_text":"","role_description":"switch","subrole":"AXSwitch","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Organize","depth":12,"bounds":{"left":0.41323137,"top":0.40023944,"width":0.017121011,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"First Folder","depth":16,"bounds":{"left":0.42320478,"top":0.42577812,"width":0.024767287,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Tags","depth":11,"bounds":{"left":0.4105718,"top":0.4509178,"width":0.076961435,"height":0.015961692},"on_screen":true,"help_text":"","role_description":"switch","subrole":"AXSwitch","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Tags","depth":12,"bounds":{"left":0.41323137,"top":0.45291302,"width":0.009142287,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Collections","depth":11,"bounds":{"left":0.4105718,"top":0.47326416,"width":0.076961435,"height":0.015961692},"on_screen":true,"help_text":"","role_description":"switch","subrole":"AXSwitch","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Collections","depth":12,"bounds":{"left":0.41323137,"top":0.47525936,"width":0.021276595,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Others","depth":11,"bounds":{"left":0.4105718,"top":0.49561054,"width":0.076961435,"height":0.015961692},"on_screen":true,"help_text":"","role_description":"switch","subrole":"AXSwitch","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Others","depth":12,"bounds":{"left":0.41323137,"top":0.49760574,"width":0.012965426,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Trash","depth":12,"bounds":{"left":0.41323137,"top":0.52394253,"width":0.07164229,"height":0.01556265},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Trash","depth":15,"bounds":{"left":0.42386967,"top":0.5215483,"width":0.012300532,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Import","depth":14,"bounds":{"left":0.42386967,"top":0.54868317,"width":0.014461436,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Invite members","depth":14,"bounds":{"left":0.42386967,"top":0.57581806,"width":0.03374335,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Template","depth":15,"bounds":{"left":0.42386967,"top":0.6029529,"width":0.020279255,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Learn more","depth":12,"bounds":{"left":0.41323137,"top":0.63248205,"width":0.07164229,"height":0.01556265},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Learn more","depth":15,"bounds":{"left":0.42386967,"top":0.6300878,"width":0.024933511,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Download App","depth":10,"bounds":{"left":0.41256648,"top":0.9489226,"width":0.072972074,"height":0.0415004},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Download App","depth":12,"bounds":{"left":0.43284574,"top":0.9628891,"width":0.032081116,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Docs","depth":11,"bounds":{"left":0.49833778,"top":0.0622506,"width":0.014793883,"height":0.0207502},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Docs","depth":12,"bounds":{"left":0.49833778,"top":0.06384677,"width":0.014793883,"height":0.017557861},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Collections","depth":11,"bounds":{"left":0.517121,"top":0.0622506,"width":0.032579787,"height":0.0207502},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Collections","depth":12,"bounds":{"left":0.517121,"top":0.06384677,"width":0.032579787,"height":0.017557861},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Tags","depth":11,"bounds":{"left":0.55369014,"top":0.0622506,"width":0.013962766,"height":0.0207502},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Tags","depth":12,"bounds":{"left":0.55369014,"top":0.06384677,"width":0.013962766,"height":0.017557861},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Display","depth":11,"bounds":{"left":0.91855055,"top":0.061452515,"width":0.022606382,"height":0.022346368},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Display","depth":13,"bounds":{"left":0.92287236,"top":0.066640064,"width":0.013962766,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"New doc","depth":12,"bounds":{"left":0.94647604,"top":0.061452515,"width":0.032247342,"height":0.022346368},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"New doc","depth":15,"bounds":{"left":0.95013297,"top":0.066640064,"width":0.016788565,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"All","depth":11,"bounds":{"left":0.49833778,"top":0.10295291,"width":0.015292553,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"May 8, 2026","depth":14,"bounds":{"left":0.50099736,"top":0.14325619,"width":0.029421542,"height":0.015163607},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"·","depth":14,"bounds":{"left":0.53174865,"top":0.14325619,"width":0.0013297872,"height":0.015163607},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1","depth":14,"bounds":{"left":0.5344083,"top":0.14325619,"width":0.0023271276,"height":0.015163607},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Select all","depth":13,"bounds":{"left":0.9709109,"top":0.14285715,"width":0.019780586,"height":0.015961692},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Select all","depth":15,"bounds":{"left":0.9722407,"top":0.14485236,"width":0.017121011,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Getting Started Welcome to AFFiNE! You can start with a normal page, with richly formatted text, markdown support, links, and a lot of other blocks.Click anywhere to start typingClick to edit this line, and then drag the handle on the left to reorder blocks / for more blocks @ for linking and mentioning (docs | users | dates)e.g. select a date to leave a note for that day, view by date in Journalsnested lists can be toggledto expand and collapseSo is headingsClick + in left navigation for new docsData-intensive blocksTableTry the Edgeless CanvasObserve what AFFiNE can doVisit AFFiNE Invite and collaborateExamples for advanced blocksFor example, Latex math. To explore more, type / To solve the equation , follow these steps:Start by simplifying the equation:Add 1 to both sides to isolate the term:Take the square root of both sides to solve for :Therefore, the solutions to the equation are and .Continue with the rabbit holeToggle the switch next to the title, to continue into the rabbit hole of intuitive organizational prowess ~ ! 3d ago 3d ago Lukáš Koválik","depth":13,"bounds":{"left":0.49833778,"top":0.16999201,"width":0.49368352,"height":0.033519555},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Getting Started","depth":16,"bounds":{"left":0.51163566,"top":0.17198724,"width":0.034408245,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Welcome to AFFiNE! You can start with a normal page, with richly formatted text, markdown support, links, and a lot of other blocks.Click anywhere to start typingClick to edit this line, and then drag the handle on the left to reorder blocks / for more blocks @ for linking and mentioning (docs | users | dates)e.g. select a date to leave a note for that day, view by date in Journalsnested lists can be toggledto expand and collapseSo is headingsClick + in left navigation for new docsData-intensive blocksTableTry the Edgeless CanvasObserve what AFFiNE can doVisit AFFiNE Invite and collaborateExamples for advanced blocksFor example, Latex math. To explore more, type / To solve the equation , follow these steps:Start by simplifying the equation:Add 1 to both sides to isolate the term:Take the square root of both sides to solve for :Therefore, the solutions to the equation are and .Continue with the rabbit holeToggle the switch next to the title, to continue into the rabbit hole of intuitive organizational prowess ~ !","depth":16,"bounds":{"left":0.51163566,"top":0.188747,"width":0.48836434,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"3d ago","depth":16,"bounds":{"left":0.92087764,"top":0.18076617,"width":0.013297873,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"3d ago","depth":16,"bounds":{"left":0.94348407,"top":0.18076617,"width":0.013297873,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Never Updated","depth":14,"bounds":{"left":0.50099736,"top":0.2150838,"width":0.035904255,"height":0.015163607},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"·","depth":14,"bounds":{"left":0.5382314,"top":0.2150838,"width":0.0013297872,"height":0.015163607},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1","depth":14,"bounds":{"left":0.54089093,"top":0.2150838,"width":0.0023271276,"height":0.015163607},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Select all","depth":13,"bounds":{"left":0.9709109,"top":0.21468475,"width":0.019780586,"height":0.015961692},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Select all","depth":15,"bounds":{"left":0.9722407,"top":0.21667998,"width":0.017121011,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"How to use folder and Tags Create folder, and move docs into folders (dragging works as well)Docs can belong to multiple foldersExpand info to view and manage tags 3d ago Lukáš Koválik","depth":13,"bounds":{"left":0.49833778,"top":0.24181964,"width":0.49368352,"height":0.033519555},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"How to use folder and Tags","depth":16,"bounds":{"left":0.51163566,"top":0.24381484,"width":0.060837764,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Create folder, and move docs into folders (dragging works as well)Docs can belong to multiple foldersExpand info to view and manage tags","depth":16,"bounds":{"left":0.51163566,"top":0.26137272,"width":0.2621343,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"3d ago","depth":16,"bounds":{"left":0.94348407,"top":0.2525938,"width":0.013297873,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"May 8, 2026","depth":13,"bounds":{"left":0.50099736,"top":0.14325619,"width":0.029421542,"height":0.015163607},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"·","depth":13,"bounds":{"left":0.53174865,"top":0.14325619,"width":0.0013297872,"height":0.015163607},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1","depth":13,"bounds":{"left":0.5344083,"top":0.14325619,"width":0.0023271276,"height":0.015163607},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Select all","depth":12,"bounds":{"left":0.9709109,"top":0.14285715,"width":0.019780586,"height":0.015961692},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Select all","depth":14,"bounds":{"left":0.9722407,"top":0.14485236,"width":0.017121011,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
3259511910026715672
|
8681629596791964228
|
visual_change
|
accessibility
|
NULL
|
New Tab
New Tab
Screenpipe — Archive
Screenpipe — New Tab
New Tab
Screenpipe — Archive
Screenpipe — Archive
All docs · AFFiNE
All docs · AFFiNE
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Open history (⇧⌘H)
Open bookmarks (⌘B)
Bitwarden
Open this doc in AFFiNE app
Don't have the app?
Click to download
Click to download
.
Remember choice
Remember choice
Remember choice
Dismiss
Dismiss
Open in app
Open in app
Demo Workspace Syncing...
Demo Workspace
Syncing...
Lukáš Koválik
Search
All docs
All docs
Journals
Journals
Notifications
Intelligence
Intelligence
Settings
Favorites
Favorites
No favorites
Organize
Organize
First Folder
Tags
Tags
Collections
Collections
Others
Others
Trash
Trash
Import
Invite members
Template
Learn more
Learn more
Download App
Download App
Docs
Docs
Collections
Collections
Tags
Tags
Display
Display
New doc
New doc
All
May 8, 2026
·
1
Select all
Select all
Getting Started Welcome to AFFiNE! You can start with a normal page, with richly formatted text, markdown support, links, and a lot of other blocks.Click anywhere to start typingClick to edit this line, and then drag the handle on the left to reorder blocks / for more blocks @ for linking and mentioning (docs | users | dates)e.g. select a date to leave a note for that day, view by date in Journalsnested lists can be toggledto expand and collapseSo is headingsClick + in left navigation for new docsData-intensive blocksTableTry the Edgeless CanvasObserve what AFFiNE can doVisit AFFiNE Invite and collaborateExamples for advanced blocksFor example, Latex math. To explore more, type / To solve the equation , follow these steps:Start by simplifying the equation:Add 1 to both sides to isolate the term:Take the square root of both sides to solve for :Therefore, the solutions to the equation are and .Continue with the rabbit holeToggle the switch next to the title, to continue into the rabbit hole of intuitive organizational prowess ~ ! 3d ago 3d ago Lukáš Koválik
Getting Started
Welcome to AFFiNE! You can start with a normal page, with richly formatted text, markdown support, links, and a lot of other blocks.Click anywhere to start typingClick to edit this line, and then drag the handle on the left to reorder blocks / for more blocks @ for linking and mentioning (docs | users | dates)e.g. select a date to leave a note for that day, view by date in Journalsnested lists can be toggledto expand and collapseSo is headingsClick + in left navigation for new docsData-intensive blocksTableTry the Edgeless CanvasObserve what AFFiNE can doVisit AFFiNE Invite and collaborateExamples for advanced blocksFor example, Latex math. To explore more, type / To solve the equation , follow these steps:Start by simplifying the equation:Add 1 to both sides to isolate the term:Take the square root of both sides to solve for :Therefore, the solutions to the equation are and .Continue with the rabbit holeToggle the switch next to the title, to continue into the rabbit hole of intuitive organizational prowess ~ !
3d ago
3d ago
Never Updated
·
1
Select all
Select all
How to use folder and Tags Create folder, and move docs into folders (dragging works as well)Docs can belong to multiple foldersExpand info to view and manage tags 3d ago Lukáš Koválik
How to use folder and Tags
Create folder, and move docs into folders (dragging works as well)Docs can belong to multiple foldersExpand info to view and manage tags
3d ago
May 8, 2026
·
1
Select all
Select all...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20684
|
901
|
4
|
2026-05-11T16:11:29.247550+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515889247_m2.jpg...
|
Firefox
|
Personal — Mozilla Firefox
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
New Tab
New Tab
Screenpipe — Archive
Screenpipe — New Tab
New Tab
Screenpipe — Archive
Screenpipe — Archive
All docs · AFFiNE
All docs · AFFiNE
New Tab
New Tab
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Open history (⇧⌘H)
Open bookmarks (⌘B)
Bitwarden
22°
C
New York City
Open menu
Mozilla Firefox
Search with Google or enter address
Search with Google or enter address
Inbox - [EMAIL] - Gmail
Inbox - [EMAIL] - Gmail
Open context menu for Inbox - [EMAIL] - Gmail
Table: transaction_imports - db - Adminer
Table: transaction_imports - db - Adminer
Open context menu for Table: transaction_imports - db - Adminer
(137) YouTube
(137) YouTube
Open context menu for (137) YouTube
All docs · AFFiNE
All docs · AFFiNE
Open context menu for All docs · AFFiNE
GitHub - screenpipe/screenpipe: Run agents that work based on what you do. 24/7 local screen & mic recording for the superintelligence era · GitHub
GitHub - screenpipe/screenpipe: Run agents that work based on what you do. 24/7 local screen & mic recording for the superintelligence era · GitHub
Open context menu for GitHub - screenpipe/screenpipe: Run agents that work based on what you do. 24/7 local screen & mic recording for the superintelligence era · GitHub
authentik
authentik
Open context menu for authentik
DXP4800PLUS-B5F8
DXP4800PLUS-B5F8
Open context menu for DXP4800PLUS-B5F8
Screenpipe — Archive
Screenpipe — Archive
Open context menu for Screenpipe — Archive
Customize
Customize
nas.lakylak.xyz/desktop/#/...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"New Tab","depth":4,"bounds":{"left":0.2942154,"top":0.074221864,"width":0.113696806,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"New Tab","depth":5,"bounds":{"left":0.3075133,"top":0.08539505,"width":0.014960106,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Screenpipe — Archive","depth":4,"bounds":{"left":0.2942154,"top":0.10694334,"width":0.113696806,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Screenpipe — Archive","depth":5,"bounds":{"left":0.3075133,"top":0.11811652,"width":0.037898935,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"All docs · AFFiNE","depth":4,"bounds":{"left":0.2942154,"top":0.1396648,"width":0.113696806,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"All docs · AFFiNE","depth":5,"bounds":{"left":0.3075133,"top":0.15083799,"width":0.029587766,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"New Tab","depth":4,"bounds":{"left":0.2942154,"top":0.17238627,"width":0.113696806,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"New Tab","depth":5,"bounds":{"left":0.3075133,"top":0.18355946,"width":0.014960106,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.3956117,"top":0.17956904,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.29704124,"top":0.20670392,"width":0.108211435,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"bounds":{"left":0.29704124,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"bounds":{"left":0.30801198,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open history (⇧⌘H)","depth":6,"bounds":{"left":0.31914893,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open bookmarks (⌘B)","depth":6,"bounds":{"left":0.3302859,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Bitwarden","depth":6,"bounds":{"left":0.3414229,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"22°","depth":8,"bounds":{"left":0.94148934,"top":0.10415004,"width":0.00930851,"height":0.016360734},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"C","depth":8,"bounds":{"left":0.95079786,"top":0.10415004,"width":0.0039893617,"height":0.016360734},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"New York City","depth":8,"bounds":{"left":0.94148934,"top":0.12051077,"width":0.02825798,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Open menu","depth":7,"bounds":{"left":0.9734042,"top":0.0981644,"width":0.01662234,"height":0.043894652},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Mozilla Firefox","depth":8,"bounds":{"left":0.48603722,"top":0.40782124,"width":0.43583778,"height":0.051077414},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXButton","text":"Search with Google or enter address","depth":8,"bounds":{"left":0.58427525,"top":0.4828412,"width":0.2393617,"height":0.0415004},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Search with Google or enter address","depth":10,"bounds":{"left":0.6008976,"top":0.4960096,"width":0.08344415,"height":0.014764565},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Inbox - kovaliklukas@gmail.com - Gmail","depth":11,"bounds":{"left":0.5443817,"top":0.5482841,"width":0.039893616,"height":0.09736632},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Inbox - kovaliklukas@gmail.com - Gmail","depth":13,"bounds":{"left":0.5490359,"top":0.61731845,"width":0.030585106,"height":0.051077414},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open context menu for Inbox - kovaliklukas@gmail.com - Gmail","depth":11,"bounds":{"left":0.57629657,"top":0.55706304,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"Open menu","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Table: transaction_imports - db - Adminer","depth":11,"bounds":{"left":0.58427525,"top":0.5482841,"width":0.039893616,"height":0.09736632},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Table: transaction_imports - db - Adminer","depth":13,"bounds":{"left":0.58926195,"top":0.61731845,"width":0.029920213,"height":0.051077414},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open context menu for Table: transaction_imports - db - Adminer","depth":11,"bounds":{"left":0.61619014,"top":0.55706304,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"Open menu","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"(137) YouTube","depth":11,"bounds":{"left":0.6241689,"top":0.5482841,"width":0.039893616,"height":0.09736632},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"(137) YouTube","depth":13,"bounds":{"left":0.62982047,"top":0.61731845,"width":0.028590426,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open context menu for (137) YouTube","depth":11,"bounds":{"left":0.65608376,"top":0.55706304,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"Open menu","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"All docs · AFFiNE","depth":11,"bounds":{"left":0.6640625,"top":0.5482841,"width":0.039893616,"height":0.09736632},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"All docs · AFFiNE","depth":13,"bounds":{"left":0.6747008,"top":0.61731845,"width":0.01861702,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open context menu for All docs · AFFiNE","depth":11,"bounds":{"left":0.6959774,"top":0.55706304,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"Open menu","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"GitHub - screenpipe/screenpipe: Run agents that work based on what you do. 24/7 local screen & mic recording for the superintelligence era · GitHub","depth":11,"bounds":{"left":0.7039561,"top":0.5482841,"width":0.039893616,"height":0.09736632},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"GitHub - screenpipe/screenpipe: Run agents that work based on what you do. 24/7 local screen & mic recording for the superintelligence era · GitHub","depth":13,"bounds":{"left":0.7087766,"top":0.61731845,"width":0.03025266,"height":0.15323225},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open context menu for GitHub - screenpipe/screenpipe: Run agents that work based on what you do. 24/7 local screen & mic recording for the superintelligence era · GitHub","depth":11,"bounds":{"left":0.735871,"top":0.55706304,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"Open menu","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"authentik","depth":11,"bounds":{"left":0.74384975,"top":0.5482841,"width":0.039893616,"height":0.09736632},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"authentik","depth":13,"bounds":{"left":0.75448805,"top":0.61731845,"width":0.01861702,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open context menu for authentik","depth":11,"bounds":{"left":0.77576464,"top":0.55706304,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"Open menu","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"DXP4800PLUS-B5F8","depth":11,"bounds":{"left":0.7837433,"top":0.5482841,"width":0.039893616,"height":0.09736632},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"DXP4800PLUS-B5F8","depth":13,"bounds":{"left":0.78856385,"top":0.61731845,"width":0.03025266,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open context menu for DXP4800PLUS-B5F8","depth":11,"bounds":{"left":0.8156583,"top":0.55706304,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"Open menu","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Screenpipe — Archive","depth":11,"bounds":{"left":0.82363695,"top":0.5482841,"width":0.039893616,"height":0.09736632},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Screenpipe — Archive","depth":13,"bounds":{"left":0.82978725,"top":0.61731845,"width":0.027593086,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open context menu for Screenpipe — Archive","depth":11,"bounds":{"left":0.85555184,"top":0.55706304,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"Open menu","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Customize","depth":8,"bounds":{"left":0.97955453,"top":0.9509178,"width":0.012965426,"height":0.0311253},"on_screen":true,"help_text":"Customize this page","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Customize","depth":10,"bounds":{"left":0.98271275,"top":0.95929766,"width":0.017287254,"height":0.014764565},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"nas.lakylak.xyz/desktop/#/","depth":5,"bounds":{"left":0.40924203,"top":0.9876297,"width":0.046043884,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
-4027566663192108494
|
-6348918661072513728
|
visual_change
|
accessibility
|
NULL
|
New Tab
New Tab
Screenpipe — Archive
Screenpipe — New Tab
New Tab
Screenpipe — Archive
Screenpipe — Archive
All docs · AFFiNE
All docs · AFFiNE
New Tab
New Tab
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Open history (⇧⌘H)
Open bookmarks (⌘B)
Bitwarden
22°
C
New York City
Open menu
Mozilla Firefox
Search with Google or enter address
Search with Google or enter address
Inbox - [EMAIL] - Gmail
Inbox - [EMAIL] - Gmail
Open context menu for Inbox - [EMAIL] - Gmail
Table: transaction_imports - db - Adminer
Table: transaction_imports - db - Adminer
Open context menu for Table: transaction_imports - db - Adminer
(137) YouTube
(137) YouTube
Open context menu for (137) YouTube
All docs · AFFiNE
All docs · AFFiNE
Open context menu for All docs · AFFiNE
GitHub - screenpipe/screenpipe: Run agents that work based on what you do. 24/7 local screen & mic recording for the superintelligence era · GitHub
GitHub - screenpipe/screenpipe: Run agents that work based on what you do. 24/7 local screen & mic recording for the superintelligence era · GitHub
Open context menu for GitHub - screenpipe/screenpipe: Run agents that work based on what you do. 24/7 local screen & mic recording for the superintelligence era · GitHub
authentik
authentik
Open context menu for authentik
DXP4800PLUS-B5F8
DXP4800PLUS-B5F8
Open context menu for DXP4800PLUS-B5F8
Screenpipe — Archive
Screenpipe — Archive
Open context menu for Screenpipe — Archive
Customize
Customize
nas.lakylak.xyz/desktop/#/...
|
20683
|
NULL
|
NULL
|
NULL
|
|
20686
|
901
|
5
|
2026-05-11T16:11:32.280675+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515892280_m2.jpg...
|
Firefox
|
DXP4800PLUS-B5F8 — Personal
|
1
|
nas.lakylak.xyz/desktop/#/
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
New Tab
New Tab
Screenpipe — Archive
Screenpipe — New Tab
New Tab
Screenpipe — Archive
Screenpipe — Archive
All docs · AFFiNE
All docs · AFFiNE
DXP4800PLUS-B5F8
DXP4800PLUS-B5F8
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Open history (⇧⌘H)
Open bookmarks (⌘B)
Bitwarden
3
KB/s
515
B/s
Files
Control Panel
Storage
App Center
Logs
Support...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"New Tab","depth":4,"bounds":{"left":0.2942154,"top":0.0518755,"width":0.113696806,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"New Tab","depth":5,"bounds":{"left":0.3075133,"top":0.06304868,"width":0.014960106,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Screenpipe — Archive","depth":4,"bounds":{"left":0.2942154,"top":0.08459697,"width":0.113696806,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Screenpipe — Archive","depth":5,"bounds":{"left":0.3075133,"top":0.09577015,"width":0.037898935,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"All docs · AFFiNE","depth":4,"bounds":{"left":0.2942154,"top":0.11731844,"width":0.113696806,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"All docs · AFFiNE","depth":5,"bounds":{"left":0.3075133,"top":0.12849163,"width":0.029587766,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"DXP4800PLUS-B5F8","depth":4,"bounds":{"left":0.2942154,"top":0.15003991,"width":0.113696806,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"DXP4800PLUS-B5F8","depth":5,"bounds":{"left":0.3075133,"top":0.16121309,"width":0.036901597,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.3956117,"top":0.15722266,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.29704124,"top":0.18435754,"width":0.108211435,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"bounds":{"left":0.29704124,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"bounds":{"left":0.30801198,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open history (⇧⌘H)","depth":6,"bounds":{"left":0.31914893,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open bookmarks (⌘B)","depth":6,"bounds":{"left":0.3302859,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Bitwarden","depth":6,"bounds":{"left":0.3414229,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"","depth":18,"bounds":{"left":0.97706115,"top":0.06304868,"width":0.0066489363,"height":0.015961692},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"3","depth":16,"bounds":{"left":0.92769283,"top":0.06264964,"width":0.0019946808,"height":0.008379889},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"KB/s","depth":16,"bounds":{"left":0.9296875,"top":0.06304868,"width":0.006150266,"height":0.0075818035},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"515","depth":16,"bounds":{"left":0.92769283,"top":0.07222666,"width":0.005984043,"height":0.008379889},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"B/s","depth":16,"bounds":{"left":0.93367684,"top":0.0726257,"width":0.004155585,"height":0.0075818035},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Files","depth":13,"bounds":{"left":0.43085107,"top":0.1707901,"width":0.009973404,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Control Panel","depth":13,"bounds":{"left":0.42170876,"top":0.2697526,"width":0.02825798,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Storage","depth":13,"bounds":{"left":0.42769283,"top":0.36871508,"width":0.016289894,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"App Center","depth":13,"bounds":{"left":0.4240359,"top":0.46767756,"width":0.023603724,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Logs","depth":13,"bounds":{"left":0.43085107,"top":0.5666401,"width":0.009973404,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Support","depth":13,"bounds":{"left":0.42769283,"top":0.66560256,"width":0.016289894,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
-7285738501172999100
|
4851876638052369216
|
visual_change
|
accessibility
|
NULL
|
New Tab
New Tab
Screenpipe — Archive
Screenpipe — New Tab
New Tab
Screenpipe — Archive
Screenpipe — Archive
All docs · AFFiNE
All docs · AFFiNE
DXP4800PLUS-B5F8
DXP4800PLUS-B5F8
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Open history (⇧⌘H)
Open bookmarks (⌘B)
Bitwarden
3
KB/s
515
B/s
Files
Control Panel
Storage
App Center
Logs
Support...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20689
|
901
|
7
|
2026-05-11T16:11:38.320146+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515898320_m2.jpg...
|
Firefox
|
JY-20725 add HS rate limit handling on activities JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app — Work...
|
1
|
github.com/jiminny/app/pull/12066
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
New Tab
New Tab
Jy 20820 es reindex stream model h New Tab
New Tab
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS]
Show options
LakyLak commented 3 hours ago
LakyLak
LakyLak
commented
3 hours ago
3 hours ago
JIRA: JY-20725
JIRA:
JY-20725
JY-20725
Changes:
Changes:
Adds
HandleHubspotRateLimit
middleware (release jobs with delay)
Adds
executeRequest
wrapper in Hubspot Client. It handles 429 errors and throws
RateLimitException
Add or remove reactions
LakyLak
LakyLak
added
2
commits
3 hours ago
3 hours ago
@LakyLak
JY-20725
JY-20725
add HS rate limit handling on activities rematching
add HS rate limit handling on activities rematching
8 / 10 checks OK
44d5678
44d5678
@LakyLak
JY-20725
JY-20725
add tests
add tests
11 / 12 checks OK
cbed2c9
cbed2c9
@sonarqubecloud
Show options
sonarqubecloud Bot commented 2 hours ago
sonarqubecloud
sonarqubecloud
Bot
commented
2 hours ago
2 hours ago
Quality Gate Failed Quality Gate failed
Quality Gate Failed
Quality Gate failed
Failed conditions
34.4% Coverage on New Code
34.4% Coverage on New Code
(required ≥ 70%)
See analysis details on SonarQube Cloud
See analysis details on SonarQube Cloud
Add or remove reactions
@LakyLak
Show options
Author
LakyLak commented 2 hours ago
LakyLak
LakyLak
commented
2 hours ago
2 hours ago
@claude
@claude
Add or remove reactions
react with eyes
👀
1
@claude
Show options
claude Bot commented 2 hours ago •
claude
claude
Bot
commented
2 hours ago
2 hours ago
•
edited
edited
Claude finished
@LakyLak
@LakyLak
's task in 1m 42s
——
View job
View job
Code Review
Code Review
Completed task
Gathering context
Completed task
Reading changed files
Completed task
Analyzing code quality
Completed task
Posting review feedback
Overall this is a well-thought-out implementation. The circuit-breaker pattern using Redis NX, the jitter for thundering-herd prevention, and the log-sampling strategy are all solid. A few issues worth addressing before merge:
Issues
Issues
1. Batch API calls bypass rate-limit handling
batchReadObjects()
in
Client.php
(lines 296–319) calls the HubSpot batch APIs directly without going through
executeRequest()
. If the batch endpoints return 429s, those exceptions will not set the Redis circuit-breaker and will not be wrapped in
RateLimitException
, so the middleware won't catch them.
// batchReadObjects — no executeRequest() wrapping
$
response
=
$
batchConfig
[
'
api
'
]->
read
(
$
batchReadRequest
);
Copy...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"New Tab","depth":4,"bounds":{"left":0.0,"top":0.0518755,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"New Tab","depth":5,"bounds":{"left":0.013297873,"top":0.06304868,"width":0.014960106,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.08459697,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.09577015,"width":0.16888298,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira","depth":4,"bounds":{"left":0.0,"top":0.11731844,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.12849163,"width":0.16140293,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":4,"bounds":{"left":0.0,"top":0.15003991,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":5,"bounds":{"left":0.013297873,"top":0.16121309,"width":0.4644282,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.18276137,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.19393456,"width":0.18816489,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.06732048,"top":0.18994413,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"Pipelines - jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.21548285,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pipelines - jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.22665602,"width":0.039228722,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Pull requests · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.2482043,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pull requests · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.25937748,"width":0.04537899,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.0028257978,"top":0.28252193,"width":0.07413564,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"bounds":{"left":0.0028257978,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"bounds":{"left":0.013796543,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Tabs from other devices","depth":6,"bounds":{"left":0.024933511,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open history (⇧⌘H)","depth":6,"bounds":{"left":0.036070477,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open bookmarks (⌘B)","depth":6,"bounds":{"left":0.04720745,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Skip to content","depth":6,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Skip to content","depth":7,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open menu","depth":10,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Homepage (g then d)","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"jiminny","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"jiminny","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"app","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"app","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Search or jump to…","depth":9,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Type","depth":12,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":12,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"to search","depth":12,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Chat with Copilot","depth":10,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXMenuButton","text":"Open Copilot…","depth":9,"on_screen":false,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXMenuButton","text":"Create new...","depth":9,"on_screen":false,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"All issues(g then i)","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"All pull requests","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"All repositories","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"You have unread notifications(g then n)","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Open user navigation menu","depth":9,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Repository navigation","depth":9,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Repository navigation","depth":10,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Code","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Code","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Pull requests (34)","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pull requests","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"34","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Agents","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Agents","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Actions","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Actions","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Wiki","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Wiki","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Security and quality (4)","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Security and quality","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"4","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Insights","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Insights","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Settings","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Settings","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Important update","depth":10,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Important update","depth":11,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"On April 24 we'll start using GitHub Copilot interaction data for AI model training unless you opt out.","depth":10,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Review this update","depth":10,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Review this update","depth":11,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"and manage your preferences in your","depth":10,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"GitHub account settings","depth":10,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"GitHub account settings","depth":11,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":10,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Dismiss banner","depth":9,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"JY-20725 add HS rate limit handling on activities rematching #12066 Edit title","depth":13,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"#","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"12066","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Edit title","depth":14,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Checks pending","depth":13,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Checks pending","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Code","depth":13,"on_screen":false,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Code","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Open","depth":13,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"LakyLak","depth":15,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"wants to merge 4 commits into","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"master","depth":15,"on_screen":false,"role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"master","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"from","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"JY-20725-handle-HS-search-rate-limit","depth":16,"on_screen":false,"role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy head branch name to clipboard","depth":16,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Lines changed: 757 additions & 249 deletions","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Conversation (3)","depth":16,"on_screen":false,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"Conversation","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"3","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Commits (4)","depth":16,"on_screen":false,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Commits","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"4","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Checks (2)","depth":16,"on_screen":false,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Checks","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"2","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Files changed (12)","depth":16,"on_screen":false,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Files changed","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"12","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Open","depth":14,"bounds":{"left":0.1008976,"top":0.0726257,"width":0.011968086,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"JY-20725 add HS rate limit handling on activities rematching #12066 LakyLak wants to merge 4 commits into master from JY-20725-handle-HS-search-rate-limit Copy head branch name to clipboard","depth":14,"bounds":{"left":0.11951463,"top":0.058260176,"width":0.21077128,"height":0.042298485},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"JY-20725 add HS rate limit handling on activities rematching","depth":16,"bounds":{"left":0.11951463,"top":0.05865922,"width":0.13663563,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching","depth":17,"bounds":{"left":0.11951463,"top":0.06304868,"width":0.13663563,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"#","depth":16,"bounds":{"left":0.25880983,"top":0.06304868,"width":0.0028257978,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"12066","depth":16,"bounds":{"left":0.26163563,"top":0.06304868,"width":0.013630319,"height":0.013567438},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"LakyLak","depth":18,"bounds":{"left":0.11951463,"top":0.08339984,"width":0.016289894,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":19,"bounds":{"left":0.11951463,"top":0.08339984,"width":0.016289894,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"wants to merge 4 commits into","depth":18,"bounds":{"left":0.13713431,"top":0.08339984,"width":0.058011968,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"master","depth":18,"bounds":{"left":0.19647606,"top":0.08180367,"width":0.018450798,"height":0.015163607},"on_screen":true,"role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"master","depth":19,"bounds":{"left":0.19847074,"top":0.083798885,"width":0.014461436,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"from","depth":19,"bounds":{"left":0.21625665,"top":0.08339984,"width":0.008643617,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"JY-20725-handle-HS-search-rate-limit","depth":19,"bounds":{"left":0.22623006,"top":0.08180367,"width":0.09042553,"height":0.015163607},"on_screen":true,"role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit","depth":20,"bounds":{"left":0.22822474,"top":0.083798885,"width":0.08643617,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy head branch name to clipboard","depth":19,"bounds":{"left":0.3179854,"top":0.07821229,"width":0.00930851,"height":0.022346368},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Conversation","depth":12,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Conversation","depth":13,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@LakyLak","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":15,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"LakyLak commented 3 hours ago","depth":14,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"LakyLak","depth":16,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"3 hours ago","depth":15,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"3 hours ago","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"JIRA: JY-20725","depth":16,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"JIRA:","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"JY-20725","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Changes:","depth":16,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Changes:","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Adds","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"HandleHubspotRateLimit","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"middleware (release jobs with delay)","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Adds","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"executeRequest","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"wrapper in Hubspot Client. It handles 429 errors and throws","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"RateLimitException","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add or remove reactions","depth":16,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"LakyLak","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"added","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"2","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commits","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"3 hours ago","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"3 hours ago","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@LakyLak","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"JY-20725","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"add HS rate limit handling on activities rematching","depth":14,"on_screen":false,"help_text":"JY-20725 add HS rate limit handling on activities rematching","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"add HS rate limit handling on activities rematching","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"8 / 10 checks OK","depth":14,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"44d5678","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"44d5678","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@LakyLak","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"JY-20725","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"add tests","depth":14,"on_screen":false,"help_text":"JY-20725 add tests","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"add tests","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"11 / 12 checks OK","depth":14,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"cbed2c9","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"cbed2c9","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@sonarqubecloud","depth":13,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":14,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"sonarqubecloud Bot commented 2 hours ago","depth":13,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"sonarqubecloud","depth":15,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"sonarqubecloud","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Bot","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"2 hours ago","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"2 hours ago","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Quality Gate Failed Quality Gate failed","depth":16,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"Quality Gate Failed","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Quality Gate failed","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Failed conditions","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"34.4% Coverage on New Code","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"34.4% Coverage on New Code","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(required ≥ 70%)","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"See analysis details on SonarQube Cloud","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"See analysis details on SonarQube Cloud","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add or remove reactions","depth":15,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"@LakyLak","depth":13,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":14,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Author","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"LakyLak commented 2 hours ago","depth":13,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"LakyLak","depth":15,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"LakyLak","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"2 hours ago","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"2 hours ago","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@claude","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"@claude","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add or remove reactions","depth":15,"on_screen":false,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"react with eyes","depth":14,"on_screen":false,"role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"👀","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@claude","depth":13,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":14,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"claude Bot commented 2 hours ago •","depth":13,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"claude","depth":15,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"claude","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Bot","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"2 hours ago","depth":14,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"2 hours ago","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"•","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"edited","depth":16,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"edited","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Claude finished","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@LakyLak","depth":18,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"@LakyLak","depth":19,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'s task in 1m 42s","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"——","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"View job","depth":17,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"View job","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Code Review","depth":16,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Code Review","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Completed task","depth":18,"on_screen":false,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Gathering context","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Completed task","depth":18,"on_screen":false,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Reading changed files","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Completed task","depth":18,"on_screen":false,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Analyzing code quality","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Completed task","depth":18,"on_screen":false,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Posting review feedback","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Overall this is a well-thought-out implementation. The circuit-breaker pattern using Redis NX, the jitter for thundering-herd prevention, and the log-sampling strategy are all solid. A few issues worth addressing before merge:","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Issues","depth":16,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Issues","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1. Batch API calls bypass rate-limit handling","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"batchReadObjects()","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"in","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Client.php","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(lines 296–319) calls the HubSpot batch APIs directly without going through","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"executeRequest()","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":". If the batch endpoints return 429s, those exceptions will not set the Redis circuit-breaker and will not be wrapped in","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"RateLimitException","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", so the middleware won't catch them.","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"// batchReadObjects — no executeRequest() wrapping","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"response","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"=","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"batchConfig","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"[","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"api","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"]->","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"read","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"batchReadRequest","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":");","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy","depth":17,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false}]...
|
290127343986173798
|
7206612560222922628
|
visual_change
|
accessibility
|
NULL
|
New Tab
New Tab
Jy 20820 es reindex stream model h New Tab
New Tab
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS]
Show options
LakyLak commented 3 hours ago
LakyLak
LakyLak
commented
3 hours ago
3 hours ago
JIRA: JY-20725
JIRA:
JY-20725
JY-20725
Changes:
Changes:
Adds
HandleHubspotRateLimit
middleware (release jobs with delay)
Adds
executeRequest
wrapper in Hubspot Client. It handles 429 errors and throws
RateLimitException
Add or remove reactions
LakyLak
LakyLak
added
2
commits
3 hours ago
3 hours ago
@LakyLak
JY-20725
JY-20725
add HS rate limit handling on activities rematching
add HS rate limit handling on activities rematching
8 / 10 checks OK
44d5678
44d5678
@LakyLak
JY-20725
JY-20725
add tests
add tests
11 / 12 checks OK
cbed2c9
cbed2c9
@sonarqubecloud
Show options
sonarqubecloud Bot commented 2 hours ago
sonarqubecloud
sonarqubecloud
Bot
commented
2 hours ago
2 hours ago
Quality Gate Failed Quality Gate failed
Quality Gate Failed
Quality Gate failed
Failed conditions
34.4% Coverage on New Code
34.4% Coverage on New Code
(required ≥ 70%)
See analysis details on SonarQube Cloud
See analysis details on SonarQube Cloud
Add or remove reactions
@LakyLak
Show options
Author
LakyLak commented 2 hours ago
LakyLak
LakyLak
commented
2 hours ago
2 hours ago
@claude
@claude
Add or remove reactions
react with eyes
👀
1
@claude
Show options
claude Bot commented 2 hours ago •
claude
claude
Bot
commented
2 hours ago
2 hours ago
•
edited
edited
Claude finished
@LakyLak
@LakyLak
's task in 1m 42s
——
View job
View job
Code Review
Code Review
Completed task
Gathering context
Completed task
Reading changed files
Completed task
Analyzing code quality
Completed task
Posting review feedback
Overall this is a well-thought-out implementation. The circuit-breaker pattern using Redis NX, the jitter for thundering-herd prevention, and the log-sampling strategy are all solid. A few issues worth addressing before merge:
Issues
Issues
1. Batch API calls bypass rate-limit handling
batchReadObjects()
in
Client.php
(lines 296–319) calls the HubSpot batch APIs directly without going through
executeRequest()
. If the batch endpoints return 429s, those exceptions will not set the Redis circuit-breaker and will not be wrapped in
RateLimitException
, so the middleware won't catch them.
// batchReadObjects — no executeRequest() wrapping
$
response
=
$
batchConfig
[
'
api
'
]->
read
(
$
batchReadRequest
);
Copy...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20690
|
901
|
8
|
2026-05-11T16:11:41.383755+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515901383_m2.jpg...
|
Firefox
|
DXP4800PLUS-B5F8 — Personal
|
1
|
nas.lakylak.xyz/desktop/#/
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
New Tab
New Tab
Screenpipe — Archive
Screenpipe — New Tab
New Tab
Screenpipe — Archive
Screenpipe — Archive
All docs · AFFiNE
All docs · AFFiNE
DXP4800PLUS-B5F8
DXP4800PLUS-B5F8
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Open history (⇧⌘H)
Open bookmarks (⌘B)
Bitwarden
1.4
KB/s
390
B/s
Files
Control Panel
Storage
App Center
Logs
Support
Task Manager
Music
Cloud Drives
Theater
Photos
Online Office
TextEdit
Virtual Machine
Downloads
DLNA
File Version Explorer
Security
Jellyfin-HT
SAN Manager
Vault
Snapshot
Comics
Sync & Backup
UGREEN AI
Recycle Bin...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"New Tab","depth":4,"bounds":{"left":0.5,"top":0.0518755,"width":0.113696806,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"New Tab","depth":5,"bounds":{"left":0.51329786,"top":0.06304868,"width":0.014960106,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Screenpipe — Archive","depth":4,"bounds":{"left":0.5,"top":0.08459697,"width":0.113696806,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Screenpipe — Archive","depth":5,"bounds":{"left":0.51329786,"top":0.09577015,"width":0.037898935,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"All docs · AFFiNE","depth":4,"bounds":{"left":0.5,"top":0.11731844,"width":0.113696806,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"All docs · AFFiNE","depth":5,"bounds":{"left":0.51329786,"top":0.12849163,"width":0.029587766,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"DXP4800PLUS-B5F8","depth":4,"bounds":{"left":0.5,"top":0.15003991,"width":0.113696806,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"DXP4800PLUS-B5F8","depth":5,"bounds":{"left":0.51329786,"top":0.16121309,"width":0.036901597,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.60139626,"top":0.15722266,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.5028258,"top":0.18435754,"width":0.108211435,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"bounds":{"left":0.5028258,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"bounds":{"left":0.51379657,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open history (⇧⌘H)","depth":6,"bounds":{"left":0.5249335,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open bookmarks (⌘B)","depth":6,"bounds":{"left":0.53607047,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Bitwarden","depth":6,"bounds":{"left":0.5472075,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"","depth":18,"bounds":{"left":0.97706115,"top":0.06304868,"width":0.0066489363,"height":0.015961692},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1.4","depth":16,"bounds":{"left":0.92669547,"top":0.06264964,"width":0.0051529254,"height":0.008379889},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"KB/s","depth":16,"bounds":{"left":0.9318484,"top":0.06304868,"width":0.005984043,"height":0.0075818035},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"390","depth":16,"bounds":{"left":0.92669547,"top":0.07222666,"width":0.005984043,"height":0.008379889},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"B/s","depth":16,"bounds":{"left":0.93267953,"top":0.0726257,"width":0.0039893617,"height":0.0075818035},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Files","depth":13,"bounds":{"left":0.63663566,"top":0.1707901,"width":0.009973404,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Control Panel","depth":13,"bounds":{"left":0.6274933,"top":0.2697526,"width":0.02825798,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Storage","depth":13,"bounds":{"left":0.6334774,"top":0.36871508,"width":0.016289894,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"App Center","depth":13,"bounds":{"left":0.62982047,"top":0.46767756,"width":0.023603724,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Logs","depth":13,"bounds":{"left":0.63663566,"top":0.5666401,"width":0.009973404,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Support","depth":13,"bounds":{"left":0.6334774,"top":0.66560256,"width":0.016289894,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Task Manager","depth":13,"bounds":{"left":0.62699467,"top":0.76456505,"width":0.02925532,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Music","depth":13,"bounds":{"left":0.63547206,"top":0.86352754,"width":0.012300532,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Cloud Drives","depth":13,"bounds":{"left":0.6761968,"top":0.1707901,"width":0.026595745,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Theater","depth":13,"bounds":{"left":0.68151593,"top":0.2697526,"width":0.015957447,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Photos","depth":13,"bounds":{"left":0.6821808,"top":0.36871508,"width":0.01462766,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Online Office","depth":13,"bounds":{"left":0.6760306,"top":0.46767756,"width":0.026928192,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"TextEdit","depth":13,"bounds":{"left":0.6811835,"top":0.5666401,"width":0.01662234,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Virtual Machine","depth":13,"bounds":{"left":0.67353725,"top":0.66560256,"width":0.031914894,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Downloads","depth":13,"bounds":{"left":0.67802525,"top":0.76456505,"width":0.022938829,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"DLNA","depth":13,"bounds":{"left":0.6833444,"top":0.86352754,"width":0.012300532,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"File Version Explorer","depth":13,"bounds":{"left":0.7159242,"top":0.1707901,"width":0.04288564,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Security","depth":13,"bounds":{"left":0.72888964,"top":0.2697526,"width":0.016954787,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Jellyfin-HT","depth":13,"bounds":{"left":0.72639626,"top":0.36871508,"width":0.021941489,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"SAN Manager","depth":13,"bounds":{"left":0.72273934,"top":0.46767756,"width":0.02925532,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Vault","depth":13,"bounds":{"left":0.7322141,"top":0.5666401,"width":0.010305851,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Snapshot","depth":13,"bounds":{"left":0.72755986,"top":0.66560256,"width":0.019614361,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Comics","depth":13,"bounds":{"left":0.72955453,"top":0.76456505,"width":0.015625,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Sync & Backup","depth":13,"bounds":{"left":0.7215758,"top":0.86352754,"width":0.03158245,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"UGREEN AI","depth":13,"bounds":{"left":0.77244014,"top":0.1707901,"width":0.025598405,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Recycle Bin","depth":13,"bounds":{"left":0.77293885,"top":0.2697526,"width":0.024601065,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
4730769289170661067
|
4850884879088407368
|
visual_change
|
accessibility
|
NULL
|
New Tab
New Tab
Screenpipe — Archive
Screenpipe — New Tab
New Tab
Screenpipe — Archive
Screenpipe — Archive
All docs · AFFiNE
All docs · AFFiNE
DXP4800PLUS-B5F8
DXP4800PLUS-B5F8
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Open history (⇧⌘H)
Open bookmarks (⌘B)
Bitwarden
1.4
KB/s
390
B/s
Files
Control Panel
Storage
App Center
Logs
Support
Task Manager
Music
Cloud Drives
Theater
Photos
Online Office
TextEdit
Virtual Machine
Downloads
DLNA
File Version Explorer
Security
Jellyfin-HT
SAN Manager
Vault
Snapshot
Comics
Sync & Backup
UGREEN AI
Recycle Bin...
|
20689
|
NULL
|
NULL
|
NULL
|
|
20692
|
901
|
9
|
2026-05-11T16:11:44.396630+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515904396_m2.jpg...
|
Firefox
|
DXP4800PLUS-B5F8 — Personal
|
1
|
nas.lakylak.xyz/desktop/#/
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
New Tab
New Tab
Screenpipe — Archive
Screenpipe — New Tab
New Tab
Screenpipe — Archive
Screenpipe — Archive
All docs · AFFiNE
All docs · AFFiNE
DXP4800PLUS-B5F8
DXP4800PLUS-B5F8
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Open history (⇧⌘H)
Open bookmarks (⌘B)
Bitwarden
1
KB/s
503
B/s
Files
Control Panel
Storage
App Center
Logs
Support
Task Manager
Music
Cloud Drives
Theater
Photos
Online Office
TextEdit
Virtual Machine
Downloads
DLNA
File Version Explorer
Security
Jellyfin-HT
SAN Manager
Vault
Snapshot
Comics
Sync & Backup
UGREEN AI
Recycle Bin...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"New Tab","depth":4,"bounds":{"left":0.5,"top":0.0518755,"width":0.06881649,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"New Tab","depth":5,"bounds":{"left":0.51329786,"top":0.06304868,"width":0.014960106,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Screenpipe — Archive","depth":4,"bounds":{"left":0.5,"top":0.08459697,"width":0.06881649,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Screenpipe — Archive","depth":5,"bounds":{"left":0.51329786,"top":0.09577015,"width":0.037898935,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"All docs · AFFiNE","depth":4,"bounds":{"left":0.5,"top":0.11731844,"width":0.06881649,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"All docs · AFFiNE","depth":5,"bounds":{"left":0.51329786,"top":0.12849163,"width":0.029587766,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"DXP4800PLUS-B5F8","depth":4,"bounds":{"left":0.5,"top":0.15003991,"width":0.06881649,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"DXP4800PLUS-B5F8","depth":5,"bounds":{"left":0.51329786,"top":0.16121309,"width":0.036901597,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.55651593,"top":0.15722266,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.5028258,"top":0.18435754,"width":0.06333112,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"bounds":{"left":0.5028258,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"bounds":{"left":0.51379657,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open history (⇧⌘H)","depth":6,"bounds":{"left":0.5249335,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open bookmarks (⌘B)","depth":6,"bounds":{"left":0.53607047,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Bitwarden","depth":6,"bounds":{"left":0.5472075,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"","depth":18,"bounds":{"left":0.97706115,"top":0.06304868,"width":0.0066489363,"height":0.015961692},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1","depth":16,"bounds":{"left":0.92769283,"top":0.06264964,"width":0.0019946808,"height":0.008379889},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"KB/s","depth":16,"bounds":{"left":0.9296875,"top":0.06304868,"width":0.006150266,"height":0.0075818035},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"503","depth":16,"bounds":{"left":0.92769283,"top":0.07222666,"width":0.005984043,"height":0.008379889},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"B/s","depth":16,"bounds":{"left":0.93367684,"top":0.0726257,"width":0.004155585,"height":0.0075818035},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Files","depth":13,"bounds":{"left":0.59175533,"top":0.1707901,"width":0.009973404,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Control Panel","depth":13,"bounds":{"left":0.58261305,"top":0.2697526,"width":0.02825798,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Storage","depth":13,"bounds":{"left":0.58859706,"top":0.36871508,"width":0.016289894,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"App Center","depth":13,"bounds":{"left":0.58494014,"top":0.46767756,"width":0.023603724,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Logs","depth":13,"bounds":{"left":0.59175533,"top":0.5666401,"width":0.009973404,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Support","depth":13,"bounds":{"left":0.58859706,"top":0.66560256,"width":0.016289894,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Task Manager","depth":13,"bounds":{"left":0.58211434,"top":0.76456505,"width":0.02925532,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Music","depth":13,"bounds":{"left":0.5905917,"top":0.86352754,"width":0.012300532,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Cloud Drives","depth":13,"bounds":{"left":0.6313165,"top":0.1707901,"width":0.026595745,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Theater","depth":13,"bounds":{"left":0.63663566,"top":0.2697526,"width":0.015957447,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Photos","depth":13,"bounds":{"left":0.63730055,"top":0.36871508,"width":0.01462766,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Online Office","depth":13,"bounds":{"left":0.63115025,"top":0.46767756,"width":0.026928192,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"TextEdit","depth":13,"bounds":{"left":0.6363032,"top":0.5666401,"width":0.01662234,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Virtual Machine","depth":13,"bounds":{"left":0.6286569,"top":0.66560256,"width":0.031914894,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Downloads","depth":13,"bounds":{"left":0.633145,"top":0.76456505,"width":0.022938829,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"DLNA","depth":13,"bounds":{"left":0.6384641,"top":0.86352754,"width":0.012300532,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"File Version Explorer","depth":13,"bounds":{"left":0.6710439,"top":0.1707901,"width":0.04288564,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Security","depth":13,"bounds":{"left":0.6840093,"top":0.2697526,"width":0.016954787,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Jellyfin-HT","depth":13,"bounds":{"left":0.68151593,"top":0.36871508,"width":0.021941489,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"SAN Manager","depth":13,"bounds":{"left":0.67785907,"top":0.46767756,"width":0.02925532,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Vault","depth":13,"bounds":{"left":0.68733376,"top":0.5666401,"width":0.010305851,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Snapshot","depth":13,"bounds":{"left":0.68267953,"top":0.66560256,"width":0.019614361,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Comics","depth":13,"bounds":{"left":0.6846742,"top":0.76456505,"width":0.015625,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Sync & Backup","depth":13,"bounds":{"left":0.67669547,"top":0.86352754,"width":0.03158245,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"UGREEN AI","depth":13,"bounds":{"left":0.72755986,"top":0.1707901,"width":0.025598405,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Recycle Bin","depth":13,"bounds":{"left":0.7280585,"top":0.2697526,"width":0.024601065,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
-7575255281038734090
|
-8981921372085107892
|
visual_change
|
accessibility
|
NULL
|
New Tab
New Tab
Screenpipe — Archive
Screenpipe — New Tab
New Tab
Screenpipe — Archive
Screenpipe — Archive
All docs · AFFiNE
All docs · AFFiNE
DXP4800PLUS-B5F8
DXP4800PLUS-B5F8
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Open history (⇧⌘H)
Open bookmarks (⌘B)
Bitwarden
1
KB/s
503
B/s
Files
Control Panel
Storage
App Center
Logs
Support
Task Manager
Music
Cloud Drives
Theater
Photos
Online Office
TextEdit
Virtual Machine
Downloads
DLNA
File Version Explorer
Security
Jellyfin-HT
SAN Manager
Vault
Snapshot
Comics
Sync & Backup
UGREEN AI
Recycle Bin...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20697
|
901
|
12
|
2026-05-11T16:12:02.711805+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515922711_m2.jpg...
|
Firefox
|
Screenpipe — Archive — Personal
|
1
|
app.screenpipe.lakylak.xyz
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
New Tab
New Tab
Screenpipe — Archive
Screenpipe — New Tab
New Tab
Screenpipe — Archive
Screenpipe — Archive
Close tab
All docs · AFFiNE
All docs · AFFiNE
DXP4800PLUS-B5F8
DXP4800PLUS-B5F8
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Open history (⇧⌘H)
Open bookmarks (⌘B)
Bitwarden
Screenpipe [archive.db · 12323.6MB]
Screenpipe
[archive.db · 12323.6MB]
Activity
Search
Audio
Work Report
Timetable
AI Summary
Date
07
/
05
/
2026
Calendar
Monitor
Jump to
--
:
--
Go
APP TIMELINE · CLICK TO PLAY · DRAG SCROLLBAR TO PAN
−
1×
+
Follow
Follow
10:00
10:30
11:00
11:30
12:00
12:30
13:00
13:30
14:00
14:30
15:00
15:30
16:00
16:30
17:00
17:30
18:00
18:30
19:00
19:30
20:00
20:30
21:00
21:30
7 May 16:19 · PhpStorm / faVsco.js – laravel.log
⏮ 30s
◀ 10s
▶ Play
10s ▶
30s ⏭
16:19
iTerm2
Firefox
CleanShot X
Finder
QuickTime Player
PhpStorm
Music
Control Centre
Claude
Slack
Alfred
Raycast
System Information...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"New Tab","depth":4,"bounds":{"left":0.5,"top":0.0518755,"width":0.06881649,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"New Tab","depth":5,"bounds":{"left":0.51329786,"top":0.06304868,"width":0.014960106,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Screenpipe — Archive","depth":4,"bounds":{"left":0.5,"top":0.08459697,"width":0.06881649,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"Screenpipe — Archive","depth":5,"bounds":{"left":0.51329786,"top":0.09577015,"width":0.037898935,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.55651593,"top":0.09177973,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"All docs · AFFiNE","depth":4,"bounds":{"left":0.5,"top":0.11731844,"width":0.06881649,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"All docs · AFFiNE","depth":5,"bounds":{"left":0.51329786,"top":0.12849163,"width":0.029587766,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"DXP4800PLUS-B5F8","depth":4,"bounds":{"left":0.5,"top":0.15003991,"width":0.06881649,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"DXP4800PLUS-B5F8","depth":5,"bounds":{"left":0.51329786,"top":0.16121309,"width":0.036901597,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.5028258,"top":0.18435754,"width":0.06333112,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"bounds":{"left":0.5028258,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"bounds":{"left":0.51379657,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open history (⇧⌘H)","depth":6,"bounds":{"left":0.5249335,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open bookmarks (⌘B)","depth":6,"bounds":{"left":0.53607047,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Bitwarden","depth":6,"bounds":{"left":0.5472075,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Screenpipe [archive.db · 12323.6MB]","depth":7,"bounds":{"left":0.57546544,"top":0.061452515,"width":0.06698803,"height":0.017956903},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Screenpipe","depth":8,"bounds":{"left":0.57546544,"top":0.06304868,"width":0.027759308,"height":0.014764565},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"[archive.db · 12323.6MB]","depth":9,"bounds":{"left":0.60455453,"top":0.06703911,"width":0.037898935,"height":0.009976057},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Activity","depth":7,"bounds":{"left":0.6471077,"top":0.059856344,"width":0.024767287,"height":0.0207502},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Search","depth":7,"bounds":{"left":0.6725399,"top":0.059856344,"width":0.023769947,"height":0.0207502},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Audio","depth":7,"bounds":{"left":0.69697475,"top":0.059856344,"width":0.020944148,"height":0.0207502},"on_screen":true,"help_text":"No audio data in this database","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Work Report","depth":7,"bounds":{"left":0.71858376,"top":0.059856344,"width":0.03507314,"height":0.0207502},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Timetable","depth":7,"bounds":{"left":0.7543218,"top":0.059856344,"width":0.029753989,"height":0.0207502},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"AI Summary","depth":7,"bounds":{"left":0.7847407,"top":0.059856344,"width":0.034075797,"height":0.0207502},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Date","depth":8,"bounds":{"left":0.93849736,"top":0.0650439,"width":0.008144947,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"07","depth":9,"bounds":{"left":0.9552859,"top":0.06464485,"width":0.0048204786,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":8,"bounds":{"left":0.96110374,"top":0.06464485,"width":0.0023271276,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"05","depth":9,"bounds":{"left":0.9644282,"top":0.06464485,"width":0.0048204786,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":8,"bounds":{"left":0.970246,"top":0.06464485,"width":0.002493351,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"2026","depth":9,"bounds":{"left":0.9737367,"top":0.06464485,"width":0.009474734,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Calendar","depth":8,"bounds":{"left":0.98454124,"top":0.0650439,"width":0.0051529254,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Monitor","depth":9,"bounds":{"left":0.57546544,"top":0.10853951,"width":0.013464096,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Jump to","depth":9,"bounds":{"left":0.93301195,"top":0.10853951,"width":0.01412899,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"--","depth":10,"bounds":{"left":0.953125,"top":0.10814046,"width":0.0048204786,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":":","depth":9,"bounds":{"left":0.95894283,"top":0.10814046,"width":0.0023271276,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"--","depth":10,"bounds":{"left":0.9622673,"top":0.10814046,"width":0.0048204786,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Go","depth":8,"bounds":{"left":0.98105055,"top":0.10454908,"width":0.012300532,"height":0.018754989},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"APP TIMELINE · CLICK TO PLAY · DRAG SCROLLBAR TO PAN","depth":10,"bounds":{"left":0.58045214,"top":0.14964086,"width":0.10571808,"height":0.009976057},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"−","depth":9,"bounds":{"left":0.93583775,"top":0.1452514,"width":0.009807181,"height":0.018754989},"on_screen":true,"help_text":"Zoom out","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"1×","depth":10,"bounds":{"left":0.94980055,"top":0.14924182,"width":0.004155585,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"+","depth":9,"bounds":{"left":0.95827794,"top":0.1452514,"width":0.009640957,"height":0.018754989},"on_screen":true,"help_text":"Zoom in","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Follow","depth":10,"bounds":{"left":0.9712433,"top":0.14924182,"width":0.004654255,"height":0.011173184},"on_screen":true,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Follow","depth":10,"bounds":{"left":0.9772274,"top":0.14924182,"width":0.011136968,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"10:00","depth":13,"bounds":{"left":0.58577126,"top":0.21947326,"width":0.008144947,"height":0.008778931},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"10:30","depth":13,"bounds":{"left":0.6028923,"top":0.21947326,"width":0.008144947,"height":0.008778931},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"11:00","depth":13,"bounds":{"left":0.62017953,"top":0.21947326,"width":0.0076462766,"height":0.008778931},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"11:30","depth":13,"bounds":{"left":0.63730055,"top":0.21947326,"width":0.0078125,"height":0.008778931},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"12:00","depth":13,"bounds":{"left":0.65425533,"top":0.21947326,"width":0.008144947,"height":0.008778931},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"12:30","depth":13,"bounds":{"left":0.67137635,"top":0.21947326,"width":0.008144947,"height":0.008778931},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"13:00","depth":13,"bounds":{"left":0.68849736,"top":0.21947326,"width":0.008144947,"height":0.008778931},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"13:30","depth":13,"bounds":{"left":0.7056183,"top":0.21947326,"width":0.008144947,"height":0.008778931},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"14:00","depth":13,"bounds":{"left":0.72273934,"top":0.21947326,"width":0.008144947,"height":0.008778931},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"14:30","depth":13,"bounds":{"left":0.73986036,"top":0.21947326,"width":0.008144947,"height":0.008778931},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"15:00","depth":13,"bounds":{"left":0.7569814,"top":0.21947326,"width":0.008144947,"height":0.008778931},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"15:30","depth":13,"bounds":{"left":0.7741024,"top":0.21947326,"width":0.008144947,"height":0.008778931},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"16:00","depth":13,"bounds":{"left":0.7912234,"top":0.21947326,"width":0.008144947,"height":0.008778931},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"16:30","depth":13,"bounds":{"left":0.8083444,"top":0.21947326,"width":0.00831117,"height":0.008778931},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"17:00","depth":13,"bounds":{"left":0.8256317,"top":0.21947326,"width":0.007978723,"height":0.008778931},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"17:30","depth":13,"bounds":{"left":0.84275264,"top":0.21947326,"width":0.007978723,"height":0.008778931},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"18:00","depth":13,"bounds":{"left":0.8597075,"top":0.21947326,"width":0.00831117,"height":0.008778931},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"18:30","depth":13,"bounds":{"left":0.87682843,"top":0.21947326,"width":0.00831117,"height":0.008778931},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"19:00","depth":13,"bounds":{"left":0.89394945,"top":0.21947326,"width":0.00831117,"height":0.008778931},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"19:30","depth":13,"bounds":{"left":0.9112367,"top":0.21947326,"width":0.008144947,"height":0.008778931},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"20:00","depth":13,"bounds":{"left":0.92802525,"top":0.21947326,"width":0.008643617,"height":0.008778931},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"20:30","depth":13,"bounds":{"left":0.94514626,"top":0.21947326,"width":0.008643617,"height":0.008778931},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"21:00","depth":13,"bounds":{"left":0.96259975,"top":0.21947326,"width":0.008144947,"height":0.008778931},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"21:30","depth":13,"bounds":{"left":0.9797208,"top":0.21947326,"width":0.008144947,"height":0.008778931},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"7 May 16:19 · PhpStorm / faVsco.js – laravel.log","depth":10,"bounds":{"left":0.57912236,"top":0.2661612,"width":0.08843085,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"⏮ 30s","depth":9,"bounds":{"left":0.57978725,"top":0.7178771,"width":0.023936171,"height":0.02434158},"on_screen":true,"help_text":"Ctrl+←","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"◀ 10s","depth":9,"bounds":{"left":0.60638297,"top":0.7178771,"width":0.02244016,"height":0.023942538},"on_screen":true,"help_text":"←","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"▶ Play","depth":9,"bounds":{"left":0.6314827,"top":0.7178771,"width":0.027925532,"height":0.023942538},"on_screen":true,"help_text":"Space","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":true,"is_selected":false},{"role":"AXButton","text":"10s ▶","depth":9,"bounds":{"left":0.66206783,"top":0.7178771,"width":0.022273935,"height":0.023942538},"on_screen":true,"help_text":"→","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"30s ⏭","depth":9,"bounds":{"left":0.68700135,"top":0.7178771,"width":0.024102394,"height":0.02434158},"on_screen":true,"help_text":"Ctrl+→","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"16:19","depth":10,"bounds":{"left":0.9797208,"top":0.7246608,"width":0.00930851,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"iTerm2","depth":9,"bounds":{"left":0.58045214,"top":0.7601756,"width":0.011801862,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Firefox","depth":9,"bounds":{"left":0.60056514,"top":0.7601756,"width":0.011801862,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"CleanShot X","depth":9,"bounds":{"left":0.6206782,"top":0.7601756,"width":0.021609042,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Finder","depth":9,"bounds":{"left":0.6505984,"top":0.7601756,"width":0.010970744,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"QuickTime Player","depth":9,"bounds":{"left":0.66988033,"top":0.7601756,"width":0.030086435,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"PhpStorm","depth":9,"bounds":{"left":0.70827794,"top":0.7601756,"width":0.017287234,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Music","depth":9,"bounds":{"left":0.73387635,"top":0.7601756,"width":0.010305851,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Control Centre","depth":9,"bounds":{"left":0.7524933,"top":0.7601756,"width":0.025598405,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Claude","depth":9,"bounds":{"left":0.78640294,"top":0.7601756,"width":0.012134309,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Slack","depth":9,"bounds":{"left":0.8068484,"top":0.7601756,"width":0.009474734,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Alfred","depth":9,"bounds":{"left":0.8246343,"top":0.7601756,"width":0.010472074,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Raycast","depth":9,"bounds":{"left":0.8434175,"top":0.7601756,"width":0.013630319,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"System Information","depth":9,"bounds":{"left":0.86535907,"top":0.7601756,"width":0.033909574,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
-2702723308700187857
|
1319893520531358681
|
visual_change
|
accessibility
|
NULL
|
New Tab
New Tab
Screenpipe — Archive
Screenpipe — New Tab
New Tab
Screenpipe — Archive
Screenpipe — Archive
Close tab
All docs · AFFiNE
All docs · AFFiNE
DXP4800PLUS-B5F8
DXP4800PLUS-B5F8
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Open history (⇧⌘H)
Open bookmarks (⌘B)
Bitwarden
Screenpipe [archive.db · 12323.6MB]
Screenpipe
[archive.db · 12323.6MB]
Activity
Search
Audio
Work Report
Timetable
AI Summary
Date
07
/
05
/
2026
Calendar
Monitor
Jump to
--
:
--
Go
APP TIMELINE · CLICK TO PLAY · DRAG SCROLLBAR TO PAN
−
1×
+
Follow
Follow
10:00
10:30
11:00
11:30
12:00
12:30
13:00
13:30
14:00
14:30
15:00
15:30
16:00
16:30
17:00
17:30
18:00
18:30
19:00
19:30
20:00
20:30
21:00
21:30
7 May 16:19 · PhpStorm / faVsco.js – laravel.log
⏮ 30s
◀ 10s
▶ Play
10s ▶
30s ⏭
16:19
iTerm2
Firefox
CleanShot X
Finder
QuickTime Player
PhpStorm
Music
Control Centre
Claude
Slack
Alfred
Raycast
System Information...
|
20696
|
NULL
|
NULL
|
NULL
|
|
20700
|
901
|
14
|
2026-05-11T16:12:08.631181+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515928631_m2.jpg...
|
PhpStorm
|
faVsco.js – HandleHubspotRateLimitTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
declare(strict_types=1);
namespace Tests\Unit\Jobs\Middleware;
use Exception;
use Illuminate\Contracts\Queue\Job;
use Illuminate\Support\Facades\Log;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
#[CoversClass(HandleHubspotRateLimit::class)]
class HandleHubspotRateLimitTest extends TestCase
{
private HandleHubspotRateLimit $middleware;
protected function setUp(): void
{
parent::setUp();
$this->middleware = new HandleHubspotRateLimit();
}
public function testPassesThroughWhenNoExceptionThrown(): void
{
$job = $this->createMock(Job::class);
$job->expects($this->never())->method('release');
$called = false;
$next = function (object $passed) use ($job, &$called): void {
$this->assertSame($job, $passed);
$called = true;
};
$this->middleware->handle($job, $next);
$this->assertTrue($called);
}
public function testPropagatesNonRateLimitExceptions(): void
{
$job = $this->createMock(Job::class);
$job->expects($this->never())->method('release');
$next = static function (): void {
throw new Exception('Database is down');
};
$this->expectException(Exception::class);
$this->expectExceptionMessage('Database is down');
$this->middleware->handle($job, $next);
}
/**
* @return array<string, array{retryAfter: int, expectedMin: int, expectedMax: int}>
*/
public static function delayClampingProvider(): array
{
return [
'short retry passes through' => [
'retryAfter' => 1,
'expectedMin' => 1,
'expectedMax' => 6, // 1 + 5 jitter
],
'medium retry passes through' => [
'retryAfter' => 30,
'expectedMin' => 30,
'expectedMax' => 35, // 30 + 5 jitter
],
'large retry clamped to 600s max' => [
'retryAfter' => 86400,
'expectedMin' => 600,
'expectedMax' => 605, // 600 + 5 jitter
],
];
}
#[DataProvider('delayClampingProvider')]
public function testReleasesJobWithClampedDelay(int $retryAfter, int $expectedMin, int $expectedMax): void
{
Log::shouldReceive('info')->zeroOrMoreTimes();
/** @var Job&MockObject $job */
$job = $this->createMock(Job::class);
$job->method('attempts')->willReturn(1);
$job->expects($this->once())
->method('release')
->with($this->callback(static function (int $delay) use ($expectedMin, $expectedMax): bool {
return $delay >= $expectedMin && $delay <= $expectedMax;
}));
$next = static function () use ($retryAfter): void {
throw new RateLimitException('rate limited', $retryAfter);
};
$this->middleware->handle($job, $next);
}
/**
* @return array<string, array{attempts: int, shouldLog: bool}>
*/
public static function logSamplingProvider(): array
{
return [
'first attempt logs' => ['attempts' => 1, 'shouldLog' => true],
'second attempt logs' => ['attempts' => 2, 'shouldLog' => true],
'third attempt logs' => ['attempts' => 3, 'shouldLog' => true],
'fourth attempt skipped' => ['attempts' => 4, 'shouldLog' => false],
'ninth attempt skipped' => ['attempts' => 9, 'shouldLog' => false],
'tenth attempt logs (multiple of 10)' => ['attempts' => 10, 'shouldLog' => true],
'eleventh attempt skipped' => ['attempts' => 11, 'shouldLog' => false],
'twentieth attempt logs' => ['attempts' => 20, 'shouldLog' => true],
];
}
#[DataProvider('logSamplingProvider')]
public function testLogSampling(int $attempts, bool $shouldLog): void
{
if ($shouldLog) {
Log::shouldReceive('info')
->once()
->with(
'[HandleHubspotRateLimit] Rate limit caught, releasing job with delay',
$this->callback(static function (array $context) use ($attempts): bool {
return $context['attempts'] === $attempts
&& $context['retry_after'] === 1
&& isset($context['delay']);
})
);
} else {
Log::shouldReceive('info')->never();
}
/** @var Job&MockObject $job */
$job = $this->createMock(Job::class);
$job->method('attempts')->willReturn($attempts);
$job->expects($this->once())->method('release');
$next = static function (): void {
throw new RateLimitException('rate limited', 1);
};
$this->middleware->handle($job, $next);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.82413566,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.8394282,"top":0.019952115,"width":0.076130316,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Jobs\\Middleware;\n\nuse Exception;\nuse Illuminate\\Contracts\\Queue\\Job;\nuse Illuminate\\Support\\Facades\\Log;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Jobs\\Middleware\\HandleHubspotRateLimit;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse PHPUnit\\Framework\\Attributes\\DataProvider;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\n\n#[CoversClass(HandleHubspotRateLimit::class)]\nclass HandleHubspotRateLimitTest extends TestCase\n{\n private HandleHubspotRateLimit $middleware;\n\n protected function setUp(): void\n {\n parent::setUp();\n\n $this->middleware = new HandleHubspotRateLimit();\n }\n\n public function testPassesThroughWhenNoExceptionThrown(): void\n {\n $job = $this->createMock(Job::class);\n $job->expects($this->never())->method('release');\n\n $called = false;\n $next = function (object $passed) use ($job, &$called): void {\n $this->assertSame($job, $passed);\n $called = true;\n };\n\n $this->middleware->handle($job, $next);\n\n $this->assertTrue($called);\n }\n\n public function testPropagatesNonRateLimitExceptions(): void\n {\n $job = $this->createMock(Job::class);\n $job->expects($this->never())->method('release');\n\n $next = static function (): void {\n throw new Exception('Database is down');\n };\n\n $this->expectException(Exception::class);\n $this->expectExceptionMessage('Database is down');\n\n $this->middleware->handle($job, $next);\n }\n\n /**\n * @return array<string, array{retryAfter: int, expectedMin: int, expectedMax: int}>\n */\n public static function delayClampingProvider(): array\n {\n return [\n 'short retry passes through' => [\n 'retryAfter' => 1,\n 'expectedMin' => 1,\n 'expectedMax' => 6, // 1 + 5 jitter\n ],\n 'medium retry passes through' => [\n 'retryAfter' => 30,\n 'expectedMin' => 30,\n 'expectedMax' => 35, // 30 + 5 jitter\n ],\n 'large retry clamped to 600s max' => [\n 'retryAfter' => 86400,\n 'expectedMin' => 600,\n 'expectedMax' => 605, // 600 + 5 jitter\n ],\n ];\n }\n\n #[DataProvider('delayClampingProvider')]\n public function testReleasesJobWithClampedDelay(int $retryAfter, int $expectedMin, int $expectedMax): void\n {\n Log::shouldReceive('info')->zeroOrMoreTimes();\n\n /** @var Job&MockObject $job */\n $job = $this->createMock(Job::class);\n $job->method('attempts')->willReturn(1);\n $job->expects($this->once())\n ->method('release')\n ->with($this->callback(static function (int $delay) use ($expectedMin, $expectedMax): bool {\n return $delay >= $expectedMin && $delay <= $expectedMax;\n }));\n\n $next = static function () use ($retryAfter): void {\n throw new RateLimitException('rate limited', $retryAfter);\n };\n\n $this->middleware->handle($job, $next);\n }\n\n /**\n * @return array<string, array{attempts: int, shouldLog: bool}>\n */\n public static function logSamplingProvider(): array\n {\n return [\n 'first attempt logs' => ['attempts' => 1, 'shouldLog' => true],\n 'second attempt logs' => ['attempts' => 2, 'shouldLog' => true],\n 'third attempt logs' => ['attempts' => 3, 'shouldLog' => true],\n 'fourth attempt skipped' => ['attempts' => 4, 'shouldLog' => false],\n 'ninth attempt skipped' => ['attempts' => 9, 'shouldLog' => false],\n 'tenth attempt logs (multiple of 10)' => ['attempts' => 10, 'shouldLog' => true],\n 'eleventh attempt skipped' => ['attempts' => 11, 'shouldLog' => false],\n 'twentieth attempt logs' => ['attempts' => 20, 'shouldLog' => true],\n ];\n }\n\n #[DataProvider('logSamplingProvider')]\n public function testLogSampling(int $attempts, bool $shouldLog): void\n {\n if ($shouldLog) {\n Log::shouldReceive('info')\n ->once()\n ->with(\n '[HandleHubspotRateLimit] Rate limit caught, releasing job with delay',\n $this->callback(static function (array $context) use ($attempts): bool {\n return $context['attempts'] === $attempts\n && $context['retry_after'] === 1\n && isset($context['delay']);\n })\n );\n } else {\n Log::shouldReceive('info')->never();\n }\n\n /** @var Job&MockObject $job */\n $job = $this->createMock(Job::class);\n $job->method('attempts')->willReturn($attempts);\n $job->expects($this->once())->method('release');\n\n $next = static function (): void {\n throw new RateLimitException('rate limited', 1);\n };\n\n $this->middleware->handle($job, $next);\n }\n}","depth":4,"bounds":{"left":0.122340426,"top":0.17158818,"width":0.43085107,"height":0.8284118},"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Jobs\\Middleware;\n\nuse Exception;\nuse Illuminate\\Contracts\\Queue\\Job;\nuse Illuminate\\Support\\Facades\\Log;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Jobs\\Middleware\\HandleHubspotRateLimit;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse PHPUnit\\Framework\\Attributes\\DataProvider;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\n\n#[CoversClass(HandleHubspotRateLimit::class)]\nclass HandleHubspotRateLimitTest extends TestCase\n{\n private HandleHubspotRateLimit $middleware;\n\n protected function setUp(): void\n {\n parent::setUp();\n\n $this->middleware = new HandleHubspotRateLimit();\n }\n\n public function testPassesThroughWhenNoExceptionThrown(): void\n {\n $job = $this->createMock(Job::class);\n $job->expects($this->never())->method('release');\n\n $called = false;\n $next = function (object $passed) use ($job, &$called): void {\n $this->assertSame($job, $passed);\n $called = true;\n };\n\n $this->middleware->handle($job, $next);\n\n $this->assertTrue($called);\n }\n\n public function testPropagatesNonRateLimitExceptions(): void\n {\n $job = $this->createMock(Job::class);\n $job->expects($this->never())->method('release');\n\n $next = static function (): void {\n throw new Exception('Database is down');\n };\n\n $this->expectException(Exception::class);\n $this->expectExceptionMessage('Database is down');\n\n $this->middleware->handle($job, $next);\n }\n\n /**\n * @return array<string, array{retryAfter: int, expectedMin: int, expectedMax: int}>\n */\n public static function delayClampingProvider(): array\n {\n return [\n 'short retry passes through' => [\n 'retryAfter' => 1,\n 'expectedMin' => 1,\n 'expectedMax' => 6, // 1 + 5 jitter\n ],\n 'medium retry passes through' => [\n 'retryAfter' => 30,\n 'expectedMin' => 30,\n 'expectedMax' => 35, // 30 + 5 jitter\n ],\n 'large retry clamped to 600s max' => [\n 'retryAfter' => 86400,\n 'expectedMin' => 600,\n 'expectedMax' => 605, // 600 + 5 jitter\n ],\n ];\n }\n\n #[DataProvider('delayClampingProvider')]\n public function testReleasesJobWithClampedDelay(int $retryAfter, int $expectedMin, int $expectedMax): void\n {\n Log::shouldReceive('info')->zeroOrMoreTimes();\n\n /** @var Job&MockObject $job */\n $job = $this->createMock(Job::class);\n $job->method('attempts')->willReturn(1);\n $job->expects($this->once())\n ->method('release')\n ->with($this->callback(static function (int $delay) use ($expectedMin, $expectedMax): bool {\n return $delay >= $expectedMin && $delay <= $expectedMax;\n }));\n\n $next = static function () use ($retryAfter): void {\n throw new RateLimitException('rate limited', $retryAfter);\n };\n\n $this->middleware->handle($job, $next);\n }\n\n /**\n * @return array<string, array{attempts: int, shouldLog: bool}>\n */\n public static function logSamplingProvider(): array\n {\n return [\n 'first attempt logs' => ['attempts' => 1, 'shouldLog' => true],\n 'second attempt logs' => ['attempts' => 2, 'shouldLog' => true],\n 'third attempt logs' => ['attempts' => 3, 'shouldLog' => true],\n 'fourth attempt skipped' => ['attempts' => 4, 'shouldLog' => false],\n 'ninth attempt skipped' => ['attempts' => 9, 'shouldLog' => false],\n 'tenth attempt logs (multiple of 10)' => ['attempts' => 10, 'shouldLog' => true],\n 'eleventh attempt skipped' => ['attempts' => 11, 'shouldLog' => false],\n 'twentieth attempt logs' => ['attempts' => 20, 'shouldLog' => true],\n ];\n }\n\n #[DataProvider('logSamplingProvider')]\n public function testLogSampling(int $attempts, bool $shouldLog): void\n {\n if ($shouldLog) {\n Log::shouldReceive('info')\n ->once()\n ->with(\n '[HandleHubspotRateLimit] Rate limit caught, releasing job with delay',\n $this->callback(static function (array $context) use ($attempts): bool {\n return $context['attempts'] === $attempts\n && $context['retry_after'] === 1\n && isset($context['delay']);\n })\n );\n } else {\n Log::shouldReceive('info')->never();\n }\n\n /** @var Job&MockObject $job */\n $job = $this->createMock(Job::class);\n $job->method('attempts')->willReturn($attempts);\n $job->expects($this->once())->method('release');\n\n $next = static function (): void {\n throw new RateLimitException('rate limited', 1);\n };\n\n $this->middleware->handle($job, $next);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.96276593,"top":0.07581804,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.9740692,"top":0.074221864,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.98138297,"top":0.074221864,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-434899306801221671
|
-7141563512795342200
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
declare(strict_types=1);
namespace Tests\Unit\Jobs\Middleware;
use Exception;
use Illuminate\Contracts\Queue\Job;
use Illuminate\Support\Facades\Log;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
#[CoversClass(HandleHubspotRateLimit::class)]
class HandleHubspotRateLimitTest extends TestCase
{
private HandleHubspotRateLimit $middleware;
protected function setUp(): void
{
parent::setUp();
$this->middleware = new HandleHubspotRateLimit();
}
public function testPassesThroughWhenNoExceptionThrown(): void
{
$job = $this->createMock(Job::class);
$job->expects($this->never())->method('release');
$called = false;
$next = function (object $passed) use ($job, &$called): void {
$this->assertSame($job, $passed);
$called = true;
};
$this->middleware->handle($job, $next);
$this->assertTrue($called);
}
public function testPropagatesNonRateLimitExceptions(): void
{
$job = $this->createMock(Job::class);
$job->expects($this->never())->method('release');
$next = static function (): void {
throw new Exception('Database is down');
};
$this->expectException(Exception::class);
$this->expectExceptionMessage('Database is down');
$this->middleware->handle($job, $next);
}
/**
* @return array<string, array{retryAfter: int, expectedMin: int, expectedMax: int}>
*/
public static function delayClampingProvider(): array
{
return [
'short retry passes through' => [
'retryAfter' => 1,
'expectedMin' => 1,
'expectedMax' => 6, // 1 + 5 jitter
],
'medium retry passes through' => [
'retryAfter' => 30,
'expectedMin' => 30,
'expectedMax' => 35, // 30 + 5 jitter
],
'large retry clamped to 600s max' => [
'retryAfter' => 86400,
'expectedMin' => 600,
'expectedMax' => 605, // 600 + 5 jitter
],
];
}
#[DataProvider('delayClampingProvider')]
public function testReleasesJobWithClampedDelay(int $retryAfter, int $expectedMin, int $expectedMax): void
{
Log::shouldReceive('info')->zeroOrMoreTimes();
/** @var Job&MockObject $job */
$job = $this->createMock(Job::class);
$job->method('attempts')->willReturn(1);
$job->expects($this->once())
->method('release')
->with($this->callback(static function (int $delay) use ($expectedMin, $expectedMax): bool {
return $delay >= $expectedMin && $delay <= $expectedMax;
}));
$next = static function () use ($retryAfter): void {
throw new RateLimitException('rate limited', $retryAfter);
};
$this->middleware->handle($job, $next);
}
/**
* @return array<string, array{attempts: int, shouldLog: bool}>
*/
public static function logSamplingProvider(): array
{
return [
'first attempt logs' => ['attempts' => 1, 'shouldLog' => true],
'second attempt logs' => ['attempts' => 2, 'shouldLog' => true],
'third attempt logs' => ['attempts' => 3, 'shouldLog' => true],
'fourth attempt skipped' => ['attempts' => 4, 'shouldLog' => false],
'ninth attempt skipped' => ['attempts' => 9, 'shouldLog' => false],
'tenth attempt logs (multiple of 10)' => ['attempts' => 10, 'shouldLog' => true],
'eleventh attempt skipped' => ['attempts' => 11, 'shouldLog' => false],
'twentieth attempt logs' => ['attempts' => 20, 'shouldLog' => true],
];
}
#[DataProvider('logSamplingProvider')]
public function testLogSampling(int $attempts, bool $shouldLog): void
{
if ($shouldLog) {
Log::shouldReceive('info')
->once()
->with(
'[HandleHubspotRateLimit] Rate limit caught, releasing job with delay',
$this->callback(static function (array $context) use ($attempts): bool {
return $context['attempts'] === $attempts
&& $context['retry_after'] === 1
&& isset($context['delay']);
})
);
} else {
Log::shouldReceive('info')->never();
}
/** @var Job&MockObject $job */
$job = $this->createMock(Job::class);
$job->method('attempts')->willReturn($attempts);
$job->expects($this->once())->method('release');
$next = static function (): void {
throw new RateLimitException('rate limited', 1);
};
$this->middleware->handle($job, $next);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
20699
|
NULL
|
NULL
|
NULL
|
|
20701
|
901
|
15
|
2026-05-11T16:12:11.671551+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515931671_m2.jpg...
|
Notion Calendar
|
NULL
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Claude0, Chat+ New chatã Projectso0 Arutacts₴ Cust Claude0, Chat+ New chatã Projectso0 Arutacts₴ CustomizeBu garian cit zenshio apolication proces:Dawarich location tracking projectHubspot Badkequest headers debuggin.Screenpipe sync script failing after recerMonthly expense trackingExporting transaction data from Notion t® How much have I spent for groc...Code dift reviewHubSpot rate limit imolementation strateScreenpipe retention policy code locatioViewing retention policy in screenpipeclean snot x video recoraing terminaticHubSpot rate limit handling with executeUntitledg Screen pipe. Is there ability..SMB mount access inconsistency betwesWhat is the best switch I can..VIewWindowHubSpot rate limit implementation strategy=vent.onpSo just a solution for rate limit implementation. The situation currently is thatthere are some daily and secondary limits for HubSpot. I would like to makesure l never reach a 429 error rate limit. The requests can come from any flow. It• updating activity (engagement, meeting, call, note)• searching for a company or contact• updating dea• patching IDsReview what the limit is at the moment in the HubSpot documentation andthen think of the solution:where to store it• now toreae IT•how to work with multiple jobs• how to work with paginated reauests Let's consider a situation when thereare 600 opportunities to sync. What would be the flow? How would therate limit be recognized? How would it be handled? What are the potentialissues? [API usage guidelines and limits - HubSpot docs]([URL_WITH_CREDENTIALS] HS_local [jiminny@localhost]A console [PROD]« console [EU]A console [STAGING)"Set-Cookie" : ["__cf_bm=SIUrtdQgXVc¿k50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.UfZEXDZyHz2mBUFdzdo2gTHEs0KX[w19A07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],"keporc-10".""endpoints":N"urL\":1"https:(VNVa.net.cloudf Lare.com\V/report\V/v4?s=NYALsVTPotYm52qrSDJxYE4sd2RwRq15p5wHsmd=g<Lz@YdxLx2B1XVpHmsKnS0%2BKVA5mF1J2m/YRECD65nx2BW2LYT206FM4%2l v("group"; \"cf-nell","max age":604800,"J,"success traction".0.ol\"report_to\":\"cf-nel\","max age":604800-"1"Server": ["cloudflare"]}} {"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab","trace_id": "c7ab8365-903f-46d4-9403-0e5b551e3545"}1 differencecurrent version"driver' →> 'errorlog','Level' => env('LOG_LEVEL', 'info'),'path' => storage_path('Logs/Laravel. Log'),custom channel' =>nath' => storage pathc'Loas/custom.1o0'))''Level' => envc'L0G LEVEL', 'info').Last swimming outing with DaniDefinition of incarceratedSvnthesized HubSoot rate limit architecture with Redis storage and sliding window a gorithms >Cuirrent Pi1hcnot limire W4 nr?0?6For privately distributed apps (which is what Jiminny presumably uses with HubSpot), thelimits are layered: a per-app burst window of 100/10s on Free/Starter, 190/10s onPro/Enterprise, or 250/10s with the API Limit Increase add-on, plus a per-account daily cap of250k / 625k / 1M depending on tier - shared across all private apps in the account. The burstwindow 1s ner ann' the datlv altota 1c ner nortal. hubsootiThe Search API is special: five requests per second per account, capped at 200 records perpage, with a hard 10,000-result ceiling per query. That's the limit the 429 in your example ishitting (secondly limit on /crm/v3/objects/contact/search ). It's a separate bucket fromthe yonos, and criucallv. search responses doJ nclude the X-Hubspot-RateLimit-*headers - you can't read remaining capacity oft the response, you have to track it yourself.Write a message…Opus 4.7 Adaptive vik tukas. ProClaude is Al and can make mistakes. Please double-check responses.W Windsurf Teams 1'1UTF.Rf 4 spaces ©...
|
NULL
|
-5015277351360704260
|
NULL
|
visual_change
|
ocr
|
NULL
|
Claude0, Chat+ New chatã Projectso0 Arutacts₴ Cust Claude0, Chat+ New chatã Projectso0 Arutacts₴ CustomizeBu garian cit zenshio apolication proces:Dawarich location tracking projectHubspot Badkequest headers debuggin.Screenpipe sync script failing after recerMonthly expense trackingExporting transaction data from Notion t® How much have I spent for groc...Code dift reviewHubSpot rate limit imolementation strateScreenpipe retention policy code locatioViewing retention policy in screenpipeclean snot x video recoraing terminaticHubSpot rate limit handling with executeUntitledg Screen pipe. Is there ability..SMB mount access inconsistency betwesWhat is the best switch I can..VIewWindowHubSpot rate limit implementation strategy=vent.onpSo just a solution for rate limit implementation. The situation currently is thatthere are some daily and secondary limits for HubSpot. I would like to makesure l never reach a 429 error rate limit. The requests can come from any flow. It• updating activity (engagement, meeting, call, note)• searching for a company or contact• updating dea• patching IDsReview what the limit is at the moment in the HubSpot documentation andthen think of the solution:where to store it• now toreae IT•how to work with multiple jobs• how to work with paginated reauests Let's consider a situation when thereare 600 opportunities to sync. What would be the flow? How would therate limit be recognized? How would it be handled? What are the potentialissues? [API usage guidelines and limits - HubSpot docs]([URL_WITH_CREDENTIALS] HS_local [jiminny@localhost]A console [PROD]« console [EU]A console [STAGING)"Set-Cookie" : ["__cf_bm=SIUrtdQgXVc¿k50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.UfZEXDZyHz2mBUFdzdo2gTHEs0KX[w19A07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],"keporc-10".""endpoints":N"urL\":1"https:(VNVa.net.cloudf Lare.com\V/report\V/v4?s=NYALsVTPotYm52qrSDJxYE4sd2RwRq15p5wHsmd=g<Lz@YdxLx2B1XVpHmsKnS0%2BKVA5mF1J2m/YRECD65nx2BW2LYT206FM4%2l v("group"; \"cf-nell","max age":604800,"J,"success traction".0.ol\"report_to\":\"cf-nel\","max age":604800-"1"Server": ["cloudflare"]}} {"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab","trace_id": "c7ab8365-903f-46d4-9403-0e5b551e3545"}1 differencecurrent version"driver' →> 'errorlog','Level' => env('LOG_LEVEL', 'info'),'path' => storage_path('Logs/Laravel. Log'),custom channel' =>nath' => storage pathc'Loas/custom.1o0'))''Level' => envc'L0G LEVEL', 'info').Last swimming outing with DaniDefinition of incarceratedSvnthesized HubSoot rate limit architecture with Redis storage and sliding window a gorithms >Cuirrent Pi1hcnot limire W4 nr?0?6For privately distributed apps (which is what Jiminny presumably uses with HubSpot), thelimits are layered: a per-app burst window of 100/10s on Free/Starter, 190/10s onPro/Enterprise, or 250/10s with the API Limit Increase add-on, plus a per-account daily cap of250k / 625k / 1M depending on tier - shared across all private apps in the account. The burstwindow 1s ner ann' the datlv altota 1c ner nortal. hubsootiThe Search API is special: five requests per second per account, capped at 200 records perpage, with a hard 10,000-result ceiling per query. That's the limit the 429 in your example ishitting (secondly limit on /crm/v3/objects/contact/search ). It's a separate bucket fromthe yonos, and criucallv. search responses doJ nclude the X-Hubspot-RateLimit-*headers - you can't read remaining capacity oft the response, you have to track it yourself.Write a message…Opus 4.7 Adaptive vik tukas. ProClaude is Al and can make mistakes. Please double-check responses.W Windsurf Teams 1'1UTF.Rf 4 spaces ©...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20704
|
901
|
17
|
2026-05-11T16:12:17.867606+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515937867_m2.jpg...
|
Notion Calendar
|
NULL
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Q, Chat°= Cowork+ New chatã Projectso0 Arutacts₴ C Q, Chat°= Cowork+ New chatã Projectso0 Arutacts₴ CustomizeBulgarian citizenship application proces:Dawarich location tracking projectHubspot Badkequest headers debuggin.Screenpipe sunc script tailing after receiMonthly expense trackingEynorting transaction data from NotionHow much have I spent for groc…Code dift reviewHubSpot rate limit imolementation strateScreenpipe retention policy code locaticViewing retention policy in screenpipeclean snot x video recoraing terminaticHubSpot rate limit handling with executeUntitledgScreen pipe. Is there ability.SMB mount access inconsistency betweWhat is the best switch I can…rermission deniea on screenpipe volumeScreenpipe sync database attachment eLast swimming outing with DaniDefinition of incarceratedVIeWWindowScreenpipe sync script failing after recent migrationsThe previous run inserted frames (6262rows, v 2m05s) and video chunks (v OmOls)successfullv before failing on ocr text. If vou re-run as-is. the scridt will see "Date 2026-05-07 already has 6262 frames in archive" and skip everything. You need to clear the partialnraimesiuisusqlite3 /Volumes/screenpipe/archive.db "DELETE FROM frames WHERE date(timestamp) = '20uocoure"mio.~/. screenpipe/screenpipe_sync.sh 2026-05-07You don't need to delete video chunks — thev're tiny. and INSERT OR IGNORE will skipduplicates on re-insert. same for the schema columns added on the previous run; ALTERTABLE ADD COLUMN is idempotent via the existing-column check in ensure columns, so thenext run will just print"v in sync" for all six tablesWorth thinking about for laterThe "already synced — skipping" check only looks at frames . A failure mid-run (like this one)leaves a half-synced date that the script can t tell from a complete one. Two ways to hardenthis:1. Smarter completeness check — compare row counts across all tables to source. Adds arew seconas ana some lo%.c.2. A FORCE=1 envvar— FORCE=1 ~/.screenpipe/screenpipe sync.sh 2026-05-07 skipsthe earlv-exit. Cheaper. sufficient for manual recoverv.I'd lean toward option 2 — small, targeted, doesn't add complexity to the haddy path. Let meknow if you want it added now or queued behind the retention work.Screenpipe sync" Open in iTerm=vent.onpmotematch.phgcurrent version'driver' => 'errorlog','level' => env('LOG LEVEL', 'info')'path' => storage path('loas/laravel.100')custom channel' =>nath' => storage pathc'Loas/custom.1o0'))''Level' => envc'L0G LEVEL', 'info').ok l want to see the audio date form db.saliteAssessed audio setup and formulated diagnostic queAuint vamindor hofora vou co loatina vouylaunch commondic nnu caraanrincolatontWrite a message…Opus 4.7 AdaptivelK Lukas. ProCaudo ic Aland can mako mictakac Plesce double-chock recnoncodhihl100% Lz• Mon 11 May 19:12:17= laravel.logA SF [jiminny@localhost]4 HS_local [jiminny@localhost]A console [PROD]« console [EU]A console [STAGING]"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEs0kXi ~07-May-26 14:51:15 GMT: domain=.hubapi.com:Http0nly: Secure: SameSite=None"].w19A"keporc-10"."N"urL\":1"https:(VNVa.net.cloudf Lare.com\V/report\V/v4?s=NYALsVTPotYm52qrSDJxYE4sd2RwRq15p5wHsmd=g<Lz@YdxLx2B1XVpHmsKnS0%2BKVA5mF1J2m/YRECD65nx2BW2LYT206FM4%2l v("group"; \"cf-nell","max age":604800,"J,"NEL":""*"success traction".0.olg"max age":6048002"]"Serven":"cloudflare"?>4"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab","trace id"."c7ab8365-903f-46d4-9403-0e5b551e3545"}1 differenceW Windsurf Teams 1'1UTF.Rfo 4 spaces 0...
|
NULL
|
6407792291675475659
|
NULL
|
visual_change
|
ocr
|
NULL
|
Q, Chat°= Cowork+ New chatã Projectso0 Arutacts₴ C Q, Chat°= Cowork+ New chatã Projectso0 Arutacts₴ CustomizeBulgarian citizenship application proces:Dawarich location tracking projectHubspot Badkequest headers debuggin.Screenpipe sunc script tailing after receiMonthly expense trackingEynorting transaction data from NotionHow much have I spent for groc…Code dift reviewHubSpot rate limit imolementation strateScreenpipe retention policy code locaticViewing retention policy in screenpipeclean snot x video recoraing terminaticHubSpot rate limit handling with executeUntitledgScreen pipe. Is there ability.SMB mount access inconsistency betweWhat is the best switch I can…rermission deniea on screenpipe volumeScreenpipe sync database attachment eLast swimming outing with DaniDefinition of incarceratedVIeWWindowScreenpipe sync script failing after recent migrationsThe previous run inserted frames (6262rows, v 2m05s) and video chunks (v OmOls)successfullv before failing on ocr text. If vou re-run as-is. the scridt will see "Date 2026-05-07 already has 6262 frames in archive" and skip everything. You need to clear the partialnraimesiuisusqlite3 /Volumes/screenpipe/archive.db "DELETE FROM frames WHERE date(timestamp) = '20uocoure"mio.~/. screenpipe/screenpipe_sync.sh 2026-05-07You don't need to delete video chunks — thev're tiny. and INSERT OR IGNORE will skipduplicates on re-insert. same for the schema columns added on the previous run; ALTERTABLE ADD COLUMN is idempotent via the existing-column check in ensure columns, so thenext run will just print"v in sync" for all six tablesWorth thinking about for laterThe "already synced — skipping" check only looks at frames . A failure mid-run (like this one)leaves a half-synced date that the script can t tell from a complete one. Two ways to hardenthis:1. Smarter completeness check — compare row counts across all tables to source. Adds arew seconas ana some lo%.c.2. A FORCE=1 envvar— FORCE=1 ~/.screenpipe/screenpipe sync.sh 2026-05-07 skipsthe earlv-exit. Cheaper. sufficient for manual recoverv.I'd lean toward option 2 — small, targeted, doesn't add complexity to the haddy path. Let meknow if you want it added now or queued behind the retention work.Screenpipe sync" Open in iTerm=vent.onpmotematch.phgcurrent version'driver' => 'errorlog','level' => env('LOG LEVEL', 'info')'path' => storage path('loas/laravel.100')custom channel' =>nath' => storage pathc'Loas/custom.1o0'))''Level' => envc'L0G LEVEL', 'info').ok l want to see the audio date form db.saliteAssessed audio setup and formulated diagnostic queAuint vamindor hofora vou co loatina vouylaunch commondic nnu caraanrincolatontWrite a message…Opus 4.7 AdaptivelK Lukas. ProCaudo ic Aland can mako mictakac Plesce double-chock recnoncodhihl100% Lz• Mon 11 May 19:12:17= laravel.logA SF [jiminny@localhost]4 HS_local [jiminny@localhost]A console [PROD]« console [EU]A console [STAGING]"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEs0kXi ~07-May-26 14:51:15 GMT: domain=.hubapi.com:Http0nly: Secure: SameSite=None"].w19A"keporc-10"."N"urL\":1"https:(VNVa.net.cloudf Lare.com\V/report\V/v4?s=NYALsVTPotYm52qrSDJxYE4sd2RwRq15p5wHsmd=g<Lz@YdxLx2B1XVpHmsKnS0%2BKVA5mF1J2m/YRECD65nx2BW2LYT206FM4%2l v("group"; \"cf-nell","max age":604800,"J,"NEL":""*"success traction".0.olg"max age":6048002"]"Serven":"cloudflare"?>4"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab","trace id"."c7ab8365-903f-46d4-9403-0e5b551e3545"}1 differenceW Windsurf Teams 1'1UTF.Rfo 4 spaces 0...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20705
|
901
|
18
|
2026-05-11T16:12:20.900269+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515940900_m2.jpg...
|
Notion Calendar
|
NULL
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Claude•• еQ, Chat°= Cowork+ New chatã Projectso0 A Claude•• еQ, Chat°= Cowork+ New chatã Projectso0 Arutacts₴ CustomizeBu garian cit zenshio apolication proces:Dawarich location tracking projectKecentsHubspot Badkequest headers debuggin.Screenpipe sunc script tailing after receiMonthly expense trackingEynorting transaction data from Notion@ How much have I spent for groc…April 2026 spending by categoryCode dift reviewHubSpot rate limit imolementation strateScreenpipe retention policy code locaticViewing retention policy in screenpipeclean snot x video recoraing terminatiorHubSpot rate limit handling with executeUntitledaScreen oine. Is there ability.SMB mount access inconsistency betwea What is the hect switch Icanrermission deniea on screenpipe volumeScreenpipe sync database attachment eLast swimming outing with DanDefinition of incarceratedVIeWWindowmeltScreenpipe sync script failing after recent migrations v‹› Code=vent.onpmotematch.phghihl100% Lz• Mon 11 May 19:12:20= laravel.logA SF [jiminny@localhost]4 HS_local [jiminny@localhost]A console [PROD]« console [EU]A console [STAGING]"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEs0kXi ~07-May-26 14:51:15 GMT: domain=.hubapi.com: Http0nly: Secure: SameSite=None"].w19A"keporc-10".""endpoints":N"urL\":1"https:(VNVa.net.cloudf Lare.com\V/report\V/v4?s=NYALsVTPotYm52qrSDJxYE4sd2RwRq15p5wHsmd=g<Lz@YdxLx2B1XVpHmsKnS0%2BKVA5mF1J2m/YRECD65nx2BW2LYT206FM4%2l v("groupl"; \"cf-nel!","max age":604800,"J,"NEL":I"*"success traction".0.olg"max age":6048002"]"Serven":"cloudflare"?>4"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab","trace id"."c7ab8365-903f-46d4-9403-0e5b551e3545"}Inotice the user prompt is empty, so l cannot determine the primary language or provide an appropriate su...Confirmed both hypotheses - schemas are the smoking gun.What the schemas revealCREATE TABLE AS SELECT * FROM source WHERE 0 strips constraints. Look at the diffTableSource schemaNAS schemaframesid INTEGER PRIMARY KEY AUTOTNCREMENTid Ini olain column)plementeid TNTECEP PRTMARY KCY AlITOTNCREMENTSd TNTIul eventsid INTEGER PRIMARY KEY AUTOTNCREMENTid INTocr teytno id coluimn even in sourceno id column1 differencecurrent versionSo INsEki or crokz nas deensentv cono nouno - meesno unous constalttconflict on. Every "ignore" was actually an "insert anyway". This is a months-old bug, not a2026-05-08 issueAISo: ocr text has no id column at all — that's why my diagnostic talled at line 12. Apologiesfor the bad query.But: are there actually duplicates?Two competing explanations for 3312 vs 990 / 779k vs 545k:•A. NAS has accumulated duplicates from multiple svnc attempts (would mean entirearehive is hioated)• B. Source has grown since the sync ran (screenpipe's OCR/element queue runs asvnc —old dates can œet more rows added davs later )To distinouish. re-count the source right now from the Mac.'driver' => 'errorlog','level' => env('LOG LEVEL', 'info')'path' => storage path('loas/laravel.100')custom channel' =>nath' => storage pathc'Loas/custom.1o0'))''Level' => envc'L0G LEVEL', 'info').calito? ~ ccroonninoldh calite ccisoltWrite a message…Opus 4.7 Adaptiveik tukas. ProCiaudo ic Aland can mako mictakas Plesce double-chock racnoncodW Windsurf Teams 1'1UTF.Rfo 4 spaces 0...
|
NULL
|
-2811970641336029790
|
NULL
|
visual_change
|
ocr
|
NULL
|
Claude•• еQ, Chat°= Cowork+ New chatã Projectso0 A Claude•• еQ, Chat°= Cowork+ New chatã Projectso0 Arutacts₴ CustomizeBu garian cit zenshio apolication proces:Dawarich location tracking projectKecentsHubspot Badkequest headers debuggin.Screenpipe sunc script tailing after receiMonthly expense trackingEynorting transaction data from Notion@ How much have I spent for groc…April 2026 spending by categoryCode dift reviewHubSpot rate limit imolementation strateScreenpipe retention policy code locaticViewing retention policy in screenpipeclean snot x video recoraing terminatiorHubSpot rate limit handling with executeUntitledaScreen oine. Is there ability.SMB mount access inconsistency betwea What is the hect switch Icanrermission deniea on screenpipe volumeScreenpipe sync database attachment eLast swimming outing with DanDefinition of incarceratedVIeWWindowmeltScreenpipe sync script failing after recent migrations v‹› Code=vent.onpmotematch.phghihl100% Lz• Mon 11 May 19:12:20= laravel.logA SF [jiminny@localhost]4 HS_local [jiminny@localhost]A console [PROD]« console [EU]A console [STAGING]"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEs0kXi ~07-May-26 14:51:15 GMT: domain=.hubapi.com: Http0nly: Secure: SameSite=None"].w19A"keporc-10".""endpoints":N"urL\":1"https:(VNVa.net.cloudf Lare.com\V/report\V/v4?s=NYALsVTPotYm52qrSDJxYE4sd2RwRq15p5wHsmd=g<Lz@YdxLx2B1XVpHmsKnS0%2BKVA5mF1J2m/YRECD65nx2BW2LYT206FM4%2l v("groupl"; \"cf-nel!","max age":604800,"J,"NEL":I"*"success traction".0.olg"max age":6048002"]"Serven":"cloudflare"?>4"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab","trace id"."c7ab8365-903f-46d4-9403-0e5b551e3545"}Inotice the user prompt is empty, so l cannot determine the primary language or provide an appropriate su...Confirmed both hypotheses - schemas are the smoking gun.What the schemas revealCREATE TABLE AS SELECT * FROM source WHERE 0 strips constraints. Look at the diffTableSource schemaNAS schemaframesid INTEGER PRIMARY KEY AUTOTNCREMENTid Ini olain column)plementeid TNTECEP PRTMARY KCY AlITOTNCREMENTSd TNTIul eventsid INTEGER PRIMARY KEY AUTOTNCREMENTid INTocr teytno id coluimn even in sourceno id column1 differencecurrent versionSo INsEki or crokz nas deensentv cono nouno - meesno unous constalttconflict on. Every "ignore" was actually an "insert anyway". This is a months-old bug, not a2026-05-08 issueAISo: ocr text has no id column at all — that's why my diagnostic talled at line 12. Apologiesfor the bad query.But: are there actually duplicates?Two competing explanations for 3312 vs 990 / 779k vs 545k:•A. NAS has accumulated duplicates from multiple svnc attempts (would mean entirearehive is hioated)• B. Source has grown since the sync ran (screenpipe's OCR/element queue runs asvnc —old dates can œet more rows added davs later )To distinouish. re-count the source right now from the Mac.'driver' => 'errorlog','level' => env('LOG LEVEL', 'info')'path' => storage path('loas/laravel.100')custom channel' =>nath' => storage pathc'Loas/custom.1o0'))''Level' => envc'L0G LEVEL', 'info').calito? ~ ccroonninoldh calite ccisoltWrite a message…Opus 4.7 Adaptiveik tukas. ProCiaudo ic Aland can mako mictakas Plesce double-chock racnoncodW Windsurf Teams 1'1UTF.Rfo 4 spaces 0...
|
20704
|
NULL
|
NULL
|
NULL
|
|
20706
|
901
|
19
|
2026-05-11T16:12:23.884095+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515943884_m2.jpg...
|
Notion Calendar
|
NULL
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
ClaudeVIeWWindowmelt•• •Screenpipe sync script fai ClaudeVIeWWindowmelt•• •Screenpipe sync script failing after recent migrationsQ, Chat°= Cowork+ New chatã Projectso0 Arutacts₴ CustomizeBu garian cit zenshio apolication proces:Dawarich location tracking projectKecentsHubspot Badkequest headers debuggin.Screenpipe sunc script tailing after receiMonthly expense trackingEynorting transaction data from NotionHow much have I spent for groc…Code dift reviewHubSpot rate limit imolementation strateScreenpipe retention policy code locaticViewing retention policy in screenpipeclean snot x video recoraing terminaticHubSpot rate limit handling with executeUntitleda Screen oine. Is there ability.SMB mount access inconsistency betwea What is the hect switch Icanrermission deniea on screenpipe volumScreenpipe sync database attachment eLast swimming outing with DaniDefinition of incarceratedI hree things need to nappen:1. Backfill machine id on elementsandocr_text. NAS rowid order tracks sync chronology(April 14 syne urst, then May o+). I und the rowid doundary using non-overlap-zone trameids (frame ids < 8037 are unambiguously post-reinstall, › 11475 are unambiguously pre-remstalb. vervunmno delow tne voundarv sers te ola machine 1a, evervunine above gersthe new one.2. Rebuild tables with proper PKs. SOLite can't ALTER TABLE ADD CONSTRAINT. so thismeans: create new table with proper schema, copy data over, drop old, rename new.sandare soule micraron vauern.3. Rebuild PIS indexes with the new rowid scheme.The archive currently weighs ~11G, mostly in elements. Migration runtime estimate: 30-60minutes on the NAS. Done in a transaction so it's atomic - if anvthing fails. archive isunchangedIwo things to verity before writing code# 1. Confirm source uses AUTOINCREMENT (not just INTEGER PRIMARY KEY) - protects ids fsqlite3 ~.screenpipe/db.sqlite "SELECT sql FROM sqlite master WHERE name='frames' ANDsqlite3 ~/.screenpipe/db.sqlite "SELECT name FROM sqlite master WHERE name='sqlite sea#2. Confirm the two machine ids on NAS are actualllv different not al the same)sqlite3 /volume1/screenpipe/archive.db "SELECT machine id, COUNT(*) AS frames, MIN(datThe first confirms machine id survives Mac-side cleanup won't change when vou DELETEold rows). The second confirms my reinstall theory — should show two distinct machine ids.one deerIfboth check out. I'll write the migration as a Pvthon scrint (transactional idemnotent. with a--drv-run flag) plus the updated sync script with the new schema. Sound good, or want toaalust anvihino in the desion nrsiI am willing to start over again from scratch if we have strategy that can dealwith this issue, also I want to implement audio transcriots, those were removedfrom earlier version.Architected tresh-start strategy with combosite kevs11audio support›Great. Fresh start is genuinely cleaner here, and audio support changes the design enough thatWrite a message…Opus 4.7 AdaptivevlK Lukas. ProCaudo ic Aland can make mictakac Plence double-chock recnoncod=vent.onpmotematch.phg12123hihl100% Lz• Mon 11 May 19:12:23= laravel.logA SF [jiminny@localhost]4 HS_local [jiminny@localhost]A console [PROD]« console [EU]A console [STAGING]"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEs0kXi ~07-May-26 14:51:15 GMT: domain=.hubapi.com:Http0nly: Secure: SameSite=None"].w19A"keporc-10".""endpoints":N"urL\":1"https:(VNVa.net.cloudf Lare.com\V/report\V/v4?s=NYALsVTPotYm52qrSDJxYE4sd2RwRq15p5wHsmd=g<Lz@YdxLx2B1XVpHmsKnS0%2BKVA5mF1J2m/YRECD65nx2BW2LYT206FM4%2l v("group"; \"cf-nell","max age":604800,"J,"success traction".0.olg"report to ": "cf-nel"."max ade":6048002"."Serven":"cloudflare"?>4"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab","trace id"."c7ab8365-903f-46d4-9403-0e5b551e3545"}1 differencecurrent version'driver' => 'errorlog','level' => env('LOG LEVEL', 'info')'path' => storage path('loas/laravel.100')custom channel' =>nath' => storage pathc'Loas/custom.1o0'))''Level' => envc'L0G LEVEL', "info').W Windsurf Teams 1'1UTF.Rfo 4 spaces 0...
|
NULL
|
5196632734701256147
|
NULL
|
visual_change
|
ocr
|
NULL
|
ClaudeVIeWWindowmelt•• •Screenpipe sync script fai ClaudeVIeWWindowmelt•• •Screenpipe sync script failing after recent migrationsQ, Chat°= Cowork+ New chatã Projectso0 Arutacts₴ CustomizeBu garian cit zenshio apolication proces:Dawarich location tracking projectKecentsHubspot Badkequest headers debuggin.Screenpipe sunc script tailing after receiMonthly expense trackingEynorting transaction data from NotionHow much have I spent for groc…Code dift reviewHubSpot rate limit imolementation strateScreenpipe retention policy code locaticViewing retention policy in screenpipeclean snot x video recoraing terminaticHubSpot rate limit handling with executeUntitleda Screen oine. Is there ability.SMB mount access inconsistency betwea What is the hect switch Icanrermission deniea on screenpipe volumScreenpipe sync database attachment eLast swimming outing with DaniDefinition of incarceratedI hree things need to nappen:1. Backfill machine id on elementsandocr_text. NAS rowid order tracks sync chronology(April 14 syne urst, then May o+). I und the rowid doundary using non-overlap-zone trameids (frame ids < 8037 are unambiguously post-reinstall, › 11475 are unambiguously pre-remstalb. vervunmno delow tne voundarv sers te ola machine 1a, evervunine above gersthe new one.2. Rebuild tables with proper PKs. SOLite can't ALTER TABLE ADD CONSTRAINT. so thismeans: create new table with proper schema, copy data over, drop old, rename new.sandare soule micraron vauern.3. Rebuild PIS indexes with the new rowid scheme.The archive currently weighs ~11G, mostly in elements. Migration runtime estimate: 30-60minutes on the NAS. Done in a transaction so it's atomic - if anvthing fails. archive isunchangedIwo things to verity before writing code# 1. Confirm source uses AUTOINCREMENT (not just INTEGER PRIMARY KEY) - protects ids fsqlite3 ~.screenpipe/db.sqlite "SELECT sql FROM sqlite master WHERE name='frames' ANDsqlite3 ~/.screenpipe/db.sqlite "SELECT name FROM sqlite master WHERE name='sqlite sea#2. Confirm the two machine ids on NAS are actualllv different not al the same)sqlite3 /volume1/screenpipe/archive.db "SELECT machine id, COUNT(*) AS frames, MIN(datThe first confirms machine id survives Mac-side cleanup won't change when vou DELETEold rows). The second confirms my reinstall theory — should show two distinct machine ids.one deerIfboth check out. I'll write the migration as a Pvthon scrint (transactional idemnotent. with a--drv-run flag) plus the updated sync script with the new schema. Sound good, or want toaalust anvihino in the desion nrsiI am willing to start over again from scratch if we have strategy that can dealwith this issue, also I want to implement audio transcriots, those were removedfrom earlier version.Architected tresh-start strategy with combosite kevs11audio support›Great. Fresh start is genuinely cleaner here, and audio support changes the design enough thatWrite a message…Opus 4.7 AdaptivevlK Lukas. ProCaudo ic Aland can make mictakac Plence double-chock recnoncod=vent.onpmotematch.phg12123hihl100% Lz• Mon 11 May 19:12:23= laravel.logA SF [jiminny@localhost]4 HS_local [jiminny@localhost]A console [PROD]« console [EU]A console [STAGING]"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEs0kXi ~07-May-26 14:51:15 GMT: domain=.hubapi.com:Http0nly: Secure: SameSite=None"].w19A"keporc-10".""endpoints":N"urL\":1"https:(VNVa.net.cloudf Lare.com\V/report\V/v4?s=NYALsVTPotYm52qrSDJxYE4sd2RwRq15p5wHsmd=g<Lz@YdxLx2B1XVpHmsKnS0%2BKVA5mF1J2m/YRECD65nx2BW2LYT206FM4%2l v("group"; \"cf-nell","max age":604800,"J,"success traction".0.olg"report to ": "cf-nel"."max ade":6048002"."Serven":"cloudflare"?>4"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab","trace id"."c7ab8365-903f-46d4-9403-0e5b551e3545"}1 differencecurrent version'driver' => 'errorlog','level' => env('LOG LEVEL', 'info')'path' => storage path('loas/laravel.100')custom channel' =>nath' => storage pathc'Loas/custom.1o0'))''Level' => envc'L0G LEVEL', "info').W Windsurf Teams 1'1UTF.Rfo 4 spaces 0...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20710
|
901
|
21
|
2026-05-11T16:12:27.812194+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515947812_m2.jpg...
|
Notion Calendar
|
NULL
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
VIeWWindowmeltScreenpipe sync script failing after VIeWWindowmeltScreenpipe sync script failing after recent migrationsrememder. botn sare to interrupt; dotn sare to re-runWhatI need from you to write this=vent.onpQ, Chat= Cowork+ New chatã Projectso0 Arutacis₴ CustomizeBulgarian citizenship application proces:Dawarich location tracking projectKecentsHubspot Badkequest headers debuggin.Screenpipe sunc script tailing after receiMonthly expense trackingEynorting transaction data from Notion@ How much have I spent for groc…Code dift reviewHubSpot rate limit imolementation strateScreenoine retention policv code locaticViewing retention policy in screenpipeclean snot x video recoraing terminaticHubSpot rate limit handling with executeUntitleda Screen oine. Is there ability.SMB mount access inconsistency betwea What is the hect switch Icanrermission deniea on screenpipe volumeScreenpipe sync database attachment eLast swimming outing with DanDefinition of incarceratedhihl100% Lz• Mon 11 May 19:12:27= laravel.logA SF [jiminny@localhost]4 HS_local [jiminny@localhost]A console [PROD]« console [EU]A console [STAGING]"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEs0kXi ~07-May-26 14:51:15 GMT: domain=.hubapi.com: Http0nly: Secure: SameSite=None"].w19A"keporc-10"."N"urL\":1"https:(VNVa.net.cloudf Lare.com\V/report\V/v4?s=NYALsVTPotYm52qrSDJxYE4sd2RwRq15p5wHsmd=g<Lz@YdxLx2B1XVpHmsKnS0%2BKVA5mF1J2m/YRECD65nx2BW2LYT206FM4%2l v("group"; \"cf-nell","max age":604800,"J,"NEL":""*"success traction".0.olg"max ade":6048002""Serven":"cloudflare"?>4"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab","trace id"."c7ab8365-903f-46d4-9403-0e5b551e3545"}#1. Full source schema, including audio tables (run with audio still disabled - emptysqlite3 ~/.screenpipe/db.sqlite '.schema › /tmp/source_schemas.txtcat ump source schemas.cxc#2. Contirm the autorncrement storv holds: should show trames has AUTOINcREMENTIsqlite3 ~/.screenpipe/db.sqlite "SELECT name FROM sqlite master WHERE name='sqlite seqsqlite3 ~.screenpipe/db.sqlite "SELECT * FROM sqlite sequence;"#3. Urop --d1sable-audlo Trom vour launch command, chen capture -s min ot audlo talkOnce I have those, Ill write:1. screenpipe schema.sal — explicit DDL for the new archive (single source of truth)screenpipe_sync.sh - new sync, including audioscreenpipe prune.sh - Mac-side weekly cleanup4. A small Python diagnostic — checks NAS archive integrity (counts. PK uniqueness.ornhan rersThen the migration is just: stop cron, mv archive.db archive.db.old (or delete entirely ifvou con wanut kedt.etnenew svncruntor wrareversin sources/-cav rerendon wineow.resume eronOne thing to think about before vou commitAudio enabled means Jiminny customer calls get captured into transcripts on your local DB.The current PIl redactor in screendide doesn't cover audio transcriptions vet it touchesOCR/elements frames only). For your Jiminny work specifically, you might want to scopeaudio capture to onlv fire outside work hours. or add adp filters so audio onlv runs whenJiminny isn't focused. This isn't a blocker — just a flag worth thinking about now rather thanaftora thaucand tranccrihod colle accumulatoWant me to wait on the source schemas. or shall we sketch the script structure in parallel?1 differencecurrent version'driver' => 'errorlog','level' => env('LOG LEVEL', 'info')'path' => storage path('loqs/laravel.100')custom channel' =>nath' => storage pathc'Loas/custom.1o0'))''Level' => envc'L0G LEVEL', "info').Write a message…Opus 4.7 AdaptivelK Lukas. ProCiaudo ic Aland can mako mictakas Plesce double-chock racnoncodW Windsurf Teams 1'1UTF.Rfo 4 spaces 0...
|
NULL
|
-705113509101460303
|
NULL
|
visual_change
|
ocr
|
NULL
|
VIeWWindowmeltScreenpipe sync script failing after VIeWWindowmeltScreenpipe sync script failing after recent migrationsrememder. botn sare to interrupt; dotn sare to re-runWhatI need from you to write this=vent.onpQ, Chat= Cowork+ New chatã Projectso0 Arutacis₴ CustomizeBulgarian citizenship application proces:Dawarich location tracking projectKecentsHubspot Badkequest headers debuggin.Screenpipe sunc script tailing after receiMonthly expense trackingEynorting transaction data from Notion@ How much have I spent for groc…Code dift reviewHubSpot rate limit imolementation strateScreenoine retention policv code locaticViewing retention policy in screenpipeclean snot x video recoraing terminaticHubSpot rate limit handling with executeUntitleda Screen oine. Is there ability.SMB mount access inconsistency betwea What is the hect switch Icanrermission deniea on screenpipe volumeScreenpipe sync database attachment eLast swimming outing with DanDefinition of incarceratedhihl100% Lz• Mon 11 May 19:12:27= laravel.logA SF [jiminny@localhost]4 HS_local [jiminny@localhost]A console [PROD]« console [EU]A console [STAGING]"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEs0kXi ~07-May-26 14:51:15 GMT: domain=.hubapi.com: Http0nly: Secure: SameSite=None"].w19A"keporc-10"."N"urL\":1"https:(VNVa.net.cloudf Lare.com\V/report\V/v4?s=NYALsVTPotYm52qrSDJxYE4sd2RwRq15p5wHsmd=g<Lz@YdxLx2B1XVpHmsKnS0%2BKVA5mF1J2m/YRECD65nx2BW2LYT206FM4%2l v("group"; \"cf-nell","max age":604800,"J,"NEL":""*"success traction".0.olg"max ade":6048002""Serven":"cloudflare"?>4"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab","trace id"."c7ab8365-903f-46d4-9403-0e5b551e3545"}#1. Full source schema, including audio tables (run with audio still disabled - emptysqlite3 ~/.screenpipe/db.sqlite '.schema › /tmp/source_schemas.txtcat ump source schemas.cxc#2. Contirm the autorncrement storv holds: should show trames has AUTOINcREMENTIsqlite3 ~/.screenpipe/db.sqlite "SELECT name FROM sqlite master WHERE name='sqlite seqsqlite3 ~.screenpipe/db.sqlite "SELECT * FROM sqlite sequence;"#3. Urop --d1sable-audlo Trom vour launch command, chen capture -s min ot audlo talkOnce I have those, Ill write:1. screenpipe schema.sal — explicit DDL for the new archive (single source of truth)screenpipe_sync.sh - new sync, including audioscreenpipe prune.sh - Mac-side weekly cleanup4. A small Python diagnostic — checks NAS archive integrity (counts. PK uniqueness.ornhan rersThen the migration is just: stop cron, mv archive.db archive.db.old (or delete entirely ifvou con wanut kedt.etnenew svncruntor wrareversin sources/-cav rerendon wineow.resume eronOne thing to think about before vou commitAudio enabled means Jiminny customer calls get captured into transcripts on your local DB.The current PIl redactor in screendide doesn't cover audio transcriptions vet it touchesOCR/elements frames only). For your Jiminny work specifically, you might want to scopeaudio capture to onlv fire outside work hours. or add adp filters so audio onlv runs whenJiminny isn't focused. This isn't a blocker — just a flag worth thinking about now rather thanaftora thaucand tranccrihod colle accumulatoWant me to wait on the source schemas. or shall we sketch the script structure in parallel?1 differencecurrent version'driver' => 'errorlog','level' => env('LOG LEVEL', 'info')'path' => storage path('loqs/laravel.100')custom channel' =>nath' => storage pathc'Loas/custom.1o0'))''Level' => envc'L0G LEVEL', "info').Write a message…Opus 4.7 AdaptivelK Lukas. ProCiaudo ic Aland can mako mictakas Plesce double-chock racnoncodW Windsurf Teams 1'1UTF.Rfo 4 spaces 0...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20711
|
900
|
14
|
2026-05-11T16:12:56.817954+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515976817_m1.jpg...
|
Firefox
|
Screenpipe — Archive — Personal
|
1
|
app.screenpipe.lakylak.xyz
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
SlackFileEditViewGoHistoryWindowHelpAPP (|•• •ED→D SlackFileEditViewGoHistoryWindowHelpAPP (|•• •ED→DOCKER₴81DEV (docker)₴2APP (-zsh)-zshScrmService->syncOpportunity('374720564');ScrmService-›matchByName('Robot');Homeend diffFixed 4 of 5666 files in 146.870 seconds, 60.00 MBmemoryusedDMsWhat's next:Try DockerDebug forseamless,persistentdebuggingtools in any containerimage →Learn moreat [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20725-handle-HS-search-rate-lirdocker exec -it docker_lamp_1./vendor/bin/php-cs-fixer fix--config=.php-cs-fixer.dist.plPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminskiandcontributors.PHP runtime:8.3.30Running analysis on 7 cores with 10 files per process.Parallel runner is an experimental feature and may be unstable, use it at your own risk.Loadedconfig default from".php-cs-fixer.dist.php".5666/5666100%Fixed 0 of 5666 files in 66.457 seconds, 60.00 MBmemory usedNrormcnyurmguura®. Galya Dimitrova E&. Petko Kashinski&. Stefka StoyanovaEo Vasil Vasilev. Nikolay Ivanov(3 Aneliya Angelova, ...Stoyan Tanev EC. VesLukas Kovalik y...What's next:Try Docker Debug for seamless, persistentdebugging tools in any containerimage +Learn more at https://docs.docker.com/go/debug-cli/lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-lir::: AppsJira CloudToast(abl100% <78• Mon 11 May 19:12:56Describe what you are looking for# releases8 226 0• MessagesC FilesBookmarks+9353b609 - JYToday~bee402b7- JY-20395 Stop fetching ALLheaders (including file) for email importd6a0fe5a - JY-20395 lintdaa0373e - JY-20395 lint fixesaf59d609 - JY-20395 Removed no longer usedmethodsShow morejiminny/app| Added by GitHubView JobCircleCl APP5:35 PMDeployment Successful!Project: appWhen:05/11/202614:35:58Tag:View JobMessage #releases+Аа...
|
NULL
|
1682825924369191178
|
NULL
|
visual_change
|
ocr
|
NULL
|
SlackFileEditViewGoHistoryWindowHelpAPP (|•• •ED→D SlackFileEditViewGoHistoryWindowHelpAPP (|•• •ED→DOCKER₴81DEV (docker)₴2APP (-zsh)-zshScrmService->syncOpportunity('374720564');ScrmService-›matchByName('Robot');Homeend diffFixed 4 of 5666 files in 146.870 seconds, 60.00 MBmemoryusedDMsWhat's next:Try DockerDebug forseamless,persistentdebuggingtools in any containerimage →Learn moreat [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20725-handle-HS-search-rate-lirdocker exec -it docker_lamp_1./vendor/bin/php-cs-fixer fix--config=.php-cs-fixer.dist.plPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminskiandcontributors.PHP runtime:8.3.30Running analysis on 7 cores with 10 files per process.Parallel runner is an experimental feature and may be unstable, use it at your own risk.Loadedconfig default from".php-cs-fixer.dist.php".5666/5666100%Fixed 0 of 5666 files in 66.457 seconds, 60.00 MBmemory usedNrormcnyurmguura®. Galya Dimitrova E&. Petko Kashinski&. Stefka StoyanovaEo Vasil Vasilev. Nikolay Ivanov(3 Aneliya Angelova, ...Stoyan Tanev EC. VesLukas Kovalik y...What's next:Try Docker Debug for seamless, persistentdebugging tools in any containerimage +Learn more at https://docs.docker.com/go/debug-cli/lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-lir::: AppsJira CloudToast(abl100% <78• Mon 11 May 19:12:56Describe what you are looking for# releases8 226 0• MessagesC FilesBookmarks+9353b609 - JYToday~bee402b7- JY-20395 Stop fetching ALLheaders (including file) for email importd6a0fe5a - JY-20395 lintdaa0373e - JY-20395 lint fixesaf59d609 - JY-20395 Removed no longer usedmethodsShow morejiminny/app| Added by GitHubView JobCircleCl APP5:35 PMDeployment Successful!Project: appWhen:05/11/202614:35:58Tag:View JobMessage #releases+Аа...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20712
|
901
|
22
|
2026-05-11T16:12:58.150422+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515978150_m2.jpg...
|
Firefox
|
Screenpipe — Archive — Personal
|
1
|
app.screenpipe.lakylak.xyz
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
New Tab
New Tab
Screenpipe — Archive
Screenpipe — New Tab
New Tab
Screenpipe — Archive
Screenpipe — Archive
Close tab
All docs · AFFiNE
All docs · AFFiNE
DXP4800PLUS-B5F8
DXP4800PLUS-B5F8
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Open history (⇧⌘H)
Open bookmarks (⌘B)
Bitwarden
Screenpipe [archive.db · 12323.6MB]
Screenpipe
[archive.db · 12323.6MB]
Activity
Search
Audio
Work Report
Timetable
AI Summary
Date
07
/
05
/
2026
Calendar
Monitor
Jump to
--
:
--
Go
APP TIMELINE · CLICK TO PLAY · DRAG SCROLLBAR TO PAN
−
1×
+
Follow
Follow...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"New Tab","depth":4,"bounds":{"left":0.5,"top":0.0518755,"width":0.06881649,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"New Tab","depth":5,"bounds":{"left":0.51329786,"top":0.06304868,"width":0.014960106,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Screenpipe — Archive","depth":4,"bounds":{"left":0.5,"top":0.08459697,"width":0.06881649,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"Screenpipe — Archive","depth":5,"bounds":{"left":0.51329786,"top":0.09577015,"width":0.037898935,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.55651593,"top":0.09177973,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"All docs · AFFiNE","depth":4,"bounds":{"left":0.5,"top":0.11731844,"width":0.06881649,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"All docs · AFFiNE","depth":5,"bounds":{"left":0.51329786,"top":0.12849163,"width":0.029587766,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"DXP4800PLUS-B5F8","depth":4,"bounds":{"left":0.5,"top":0.15003991,"width":0.06881649,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"DXP4800PLUS-B5F8","depth":5,"bounds":{"left":0.51329786,"top":0.16121309,"width":0.036901597,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.5028258,"top":0.18435754,"width":0.06333112,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"bounds":{"left":0.5028258,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"bounds":{"left":0.51379657,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open history (⇧⌘H)","depth":6,"bounds":{"left":0.5249335,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open bookmarks (⌘B)","depth":6,"bounds":{"left":0.53607047,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Bitwarden","depth":6,"bounds":{"left":0.5472075,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Screenpipe [archive.db · 12323.6MB]","depth":7,"bounds":{"left":0.57546544,"top":0.061452515,"width":0.06698803,"height":0.017956903},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Screenpipe","depth":8,"bounds":{"left":0.57546544,"top":0.06304868,"width":0.027759308,"height":0.014764565},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"[archive.db · 12323.6MB]","depth":9,"bounds":{"left":0.60455453,"top":0.06703911,"width":0.037898935,"height":0.009976057},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Activity","depth":7,"bounds":{"left":0.6471077,"top":0.059856344,"width":0.024767287,"height":0.0207502},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Search","depth":7,"bounds":{"left":0.6725399,"top":0.059856344,"width":0.023769947,"height":0.0207502},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Audio","depth":7,"bounds":{"left":0.69697475,"top":0.059856344,"width":0.020944148,"height":0.0207502},"on_screen":true,"help_text":"No audio data in this database","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Work Report","depth":7,"bounds":{"left":0.71858376,"top":0.059856344,"width":0.03507314,"height":0.0207502},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Timetable","depth":7,"bounds":{"left":0.7543218,"top":0.059856344,"width":0.029753989,"height":0.0207502},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"AI Summary","depth":7,"bounds":{"left":0.7847407,"top":0.059856344,"width":0.034075797,"height":0.0207502},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Date","depth":8,"bounds":{"left":0.93849736,"top":0.0650439,"width":0.008144947,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"07","depth":9,"bounds":{"left":0.9552859,"top":0.06464485,"width":0.0048204786,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":8,"bounds":{"left":0.96110374,"top":0.06464485,"width":0.0023271276,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"05","depth":9,"bounds":{"left":0.9644282,"top":0.06464485,"width":0.0048204786,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":8,"bounds":{"left":0.970246,"top":0.06464485,"width":0.002493351,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"2026","depth":9,"bounds":{"left":0.9737367,"top":0.06464485,"width":0.009474734,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Calendar","depth":8,"bounds":{"left":0.98454124,"top":0.0650439,"width":0.0051529254,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Monitor","depth":9,"bounds":{"left":0.57546544,"top":0.10853951,"width":0.013464096,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Jump to","depth":9,"bounds":{"left":0.93301195,"top":0.10853951,"width":0.01412899,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"--","depth":10,"bounds":{"left":0.953125,"top":0.10814046,"width":0.0048204786,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":":","depth":9,"bounds":{"left":0.95894283,"top":0.10814046,"width":0.0023271276,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"--","depth":10,"bounds":{"left":0.9622673,"top":0.10814046,"width":0.0048204786,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Go","depth":8,"bounds":{"left":0.98105055,"top":0.10454908,"width":0.012300532,"height":0.018754989},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"APP TIMELINE · CLICK TO PLAY · DRAG SCROLLBAR TO PAN","depth":10,"bounds":{"left":0.58045214,"top":0.14964086,"width":0.10571808,"height":0.009976057},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"−","depth":9,"bounds":{"left":0.93583775,"top":0.1452514,"width":0.009807181,"height":0.018754989},"on_screen":true,"help_text":"Zoom out","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"1×","depth":10,"bounds":{"left":0.94980055,"top":0.14924182,"width":0.004155585,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"+","depth":9,"bounds":{"left":0.95827794,"top":0.1452514,"width":0.009640957,"height":0.018754989},"on_screen":true,"help_text":"Zoom in","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Follow","depth":10,"bounds":{"left":0.9712433,"top":0.14924182,"width":0.004654255,"height":0.011173184},"on_screen":true,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Follow","depth":10,"bounds":{"left":0.9772274,"top":0.14924182,"width":0.011136968,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
9057706330969825432
|
3625464812935940057
|
visual_change
|
accessibility
|
NULL
|
New Tab
New Tab
Screenpipe — Archive
Screenpipe — New Tab
New Tab
Screenpipe — Archive
Screenpipe — Archive
Close tab
All docs · AFFiNE
All docs · AFFiNE
DXP4800PLUS-B5F8
DXP4800PLUS-B5F8
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Open history (⇧⌘H)
Open bookmarks (⌘B)
Bitwarden
Screenpipe [archive.db · 12323.6MB]
Screenpipe
[archive.db · 12323.6MB]
Activity
Search
Audio
Work Report
Timetable
AI Summary
Date
07
/
05
/
2026
Calendar
Monitor
Jump to
--
:
--
Go
APP TIMELINE · CLICK TO PLAY · DRAG SCROLLBAR TO PAN
−
1×
+
Follow
Follow...
|
20710
|
NULL
|
NULL
|
NULL
|
|
20713
|
901
|
23
|
2026-05-11T16:13:01.086835+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515981086_m2.jpg...
|
Firefox
|
JY-20725 add HS rate limit handling on activities JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app — Work...
|
1
|
github.com/jiminny/app/pull/12066
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
New Tab
New Tab
Jy 20820 es reindex stream model h New Tab
New Tab
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app
JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app
Close tab
Pipelines - jiminny/app
Pipelines - jiminny/app
Pull requests · jiminny/app
Pull requests · jiminny/app
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Skip to content
Skip to content
Open menu
Homepage (g then d)
jiminny
jiminny
app
app
Search or jump to…
Type
/
to search
Chat with Copilot
Open Copilot…
Create new...
All issues(g then i)
All pull requests
All repositories
You have unread notifications(g then n)
Open user navigation menu
Repository navigation
Repository navigation
Code
Code
Pull requests (34)
Pull requests
(
34
)
Agents
Agents
Actions
Actions
Wiki...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"New Tab","depth":4,"bounds":{"left":0.0,"top":0.0518755,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"New Tab","depth":5,"bounds":{"left":0.013297873,"top":0.06304868,"width":0.014960106,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.08459697,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.09577015,"width":0.16888298,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira","depth":4,"bounds":{"left":0.0,"top":0.11731844,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.12849163,"width":0.16140293,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":4,"bounds":{"left":0.0,"top":0.15003991,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":5,"bounds":{"left":0.013297873,"top":0.16121309,"width":0.4644282,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.18276137,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.19393456,"width":0.18816489,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.06732048,"top":0.18994413,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"Pipelines - jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.21548285,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pipelines - jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.22665602,"width":0.039228722,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Pull requests · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.2482043,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pull requests · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.25937748,"width":0.04537899,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.06732048,"top":0.25538707,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.0028257978,"top":0.28252193,"width":0.07413564,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"bounds":{"left":0.0028257978,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"bounds":{"left":0.013796543,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Tabs from other devices","depth":6,"bounds":{"left":0.024933511,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open history (⇧⌘H)","depth":6,"bounds":{"left":0.036070477,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open bookmarks (⌘B)","depth":6,"bounds":{"left":0.04720745,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Skip to content","depth":6,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Skip to content","depth":7,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open menu","depth":10,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Homepage (g then d)","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"jiminny","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"jiminny","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"app","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"app","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Search or jump to…","depth":9,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Type","depth":12,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":12,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"to search","depth":12,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Chat with Copilot","depth":10,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXMenuButton","text":"Open Copilot…","depth":9,"on_screen":false,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXMenuButton","text":"Create new...","depth":9,"on_screen":false,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"All issues(g then i)","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"All pull requests","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"All repositories","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"You have unread notifications(g then n)","depth":9,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Open user navigation menu","depth":9,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Repository navigation","depth":9,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Repository navigation","depth":10,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Code","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Code","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Pull requests (34)","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pull requests","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"34","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Agents","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Agents","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Actions","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Actions","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Wiki","depth":12,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false}]...
|
6248559763296280155
|
-4430108738825169270
|
visual_change
|
accessibility
|
NULL
|
New Tab
New Tab
Jy 20820 es reindex stream model h New Tab
New Tab
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app
JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app
Close tab
Pipelines - jiminny/app
Pipelines - jiminny/app
Pull requests · jiminny/app
Pull requests · jiminny/app
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Skip to content
Skip to content
Open menu
Homepage (g then d)
jiminny
jiminny
app
app
Search or jump to…
Type
/
to search
Chat with Copilot
Open Copilot…
Create new...
All issues(g then i)
All pull requests
All repositories
You have unread notifications(g then n)
Open user navigation menu
Repository navigation
Repository navigation
Code
Code
Pull requests (34)
Pull requests
(
34
)
Agents
Agents
Actions
Actions
Wiki...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20715
|
901
|
24
|
2026-05-11T16:13:04.147397+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515984147_m2.jpg...
|
Firefox
|
Pipelines - jiminny/app — Work
|
1
|
app.circleci.com/pipelines/github/jiminny/app
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
New Tab
New Tab
Jy 20820 es reindex stream model h New Tab
New Tab
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app
JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
Close tab
Pull requests · jiminny/app
Pull requests · jiminny/app
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Go to home page
Auto theme
Open notifications
Open support menu
Open user menu
org avatar Current organization: jiminny
Home
Home
Pipelines
Pipelines
Projects
Projects
Deploys
Deploys
Insights
Insights
Runners
Runners
Org
Org
Plan
Plan
Chunk sidecars
Chunk sidecars
PREVIEW
Chunk
Chunk
Smarter Testing is now in beta
Smarter Testing is now in beta
Get faster feedback on your tests while maintaining full confidence
Dismiss
Get Started
Get Started
Dashboard All Pipelines
All Pipelines
Project Outline app
app
app
app
Overview
Overview
Settings
Settings
Deploys
Deploys
Lightning Manage triggers
Manage triggers
Trigger Pipeline
Pipelines All pipelines my-pipelines-filter
All pipelines
app Project Filter. Selected "app"
app
All branches Branch Filter. Selected "All branches"
All branches
Start Time Cutoff date Arrow Drop Down
Cutoff date
All statuses Arrow Drop Down
All
statuses
Filter
Pipeline
Status
Workflow
Checkout source
Trigger event
Start/Duration
Actions
app
58213
58213
FAILED workflow build_accept_deploy. Collapse the workflow jobs list.
Status Failed Failed
Failed
build_accept_deploy
build_accept_deploy
JY-20742-mcp-poc-OAuth-DCR
JY-20742-mcp-poc-OAuth-DCR
Open commit on version control site
7dd5c29
JY-20625 | JY-20742 | debug consent screen
Copy timestamp to clipboard
1h ago
Copy timestamp duration to clipboard...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"New Tab","depth":4,"bounds":{"left":0.0,"top":0.0518755,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"New Tab","depth":5,"bounds":{"left":0.013297873,"top":0.06304868,"width":0.014960106,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.08459697,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.09577015,"width":0.16888298,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira","depth":4,"bounds":{"left":0.0,"top":0.11731844,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.12849163,"width":0.16140293,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":4,"bounds":{"left":0.0,"top":0.15003991,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":5,"bounds":{"left":0.013297873,"top":0.16121309,"width":0.4644282,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.18276137,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.19393456,"width":0.18816489,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Pipelines - jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.21548285,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"Pipelines - jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.22665602,"width":0.039228722,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.06732048,"top":0.22266561,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"Pull requests · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.2482043,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pull requests · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.25937748,"width":0.04537899,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.0028257978,"top":0.28252193,"width":0.07413564,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"bounds":{"left":0.0028257978,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"bounds":{"left":0.013796543,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Tabs from other devices","depth":6,"bounds":{"left":0.024933511,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open history (⇧⌘H)","depth":6,"bounds":{"left":0.036070477,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open bookmarks (⌘B)","depth":6,"bounds":{"left":0.04720745,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Go to home page","depth":9,"bounds":{"left":0.08726729,"top":0.061452515,"width":0.044215426,"height":0.031923383},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXMenuButton","text":"Auto theme","depth":9,"bounds":{"left":0.4375,"top":0.061452515,"width":0.013297873,"height":0.031923383},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open notifications","depth":9,"bounds":{"left":0.45212767,"top":0.061452515,"width":0.013297873,"height":0.031923383},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXMenuButton","text":"Open support menu","depth":9,"bounds":{"left":0.46675533,"top":0.061452515,"width":0.013297873,"height":0.031923383},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXMenuButton","text":"Open user menu","depth":9,"bounds":{"left":0.48138297,"top":0.061452515,"width":0.013297873,"height":0.031923383},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"org avatar Current organization: jiminny","depth":9,"bounds":{"left":0.08693484,"top":0.10295291,"width":0.01462766,"height":0.035115723},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Home","depth":10,"bounds":{"left":0.08494016,"top":0.15083799,"width":0.01861702,"height":0.046288908},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Home","depth":12,"bounds":{"left":0.087765954,"top":0.1839585,"width":0.012965426,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Pipelines","depth":10,"bounds":{"left":0.08494016,"top":0.21308859,"width":0.01861702,"height":0.046288908},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pipelines","depth":12,"bounds":{"left":0.083942816,"top":0.2462091,"width":0.020611702,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Projects","depth":10,"bounds":{"left":0.08494016,"top":0.2753392,"width":0.01861702,"height":0.04668795},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Projects","depth":12,"bounds":{"left":0.0852726,"top":0.3084597,"width":0.017952127,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Deploys","depth":10,"bounds":{"left":0.08494016,"top":0.33798882,"width":0.01861702,"height":0.046288908},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Deploys","depth":12,"bounds":{"left":0.08543883,"top":0.37071028,"width":0.01761968,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Insights","depth":10,"bounds":{"left":0.08494016,"top":0.40023944,"width":0.01861702,"height":0.046288908},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Insights","depth":12,"bounds":{"left":0.085605055,"top":0.4329609,"width":0.017287234,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Runners","depth":10,"bounds":{"left":0.08494016,"top":0.46249002,"width":0.01861702,"height":0.046288908},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Runners","depth":12,"bounds":{"left":0.0852726,"top":0.49561054,"width":0.017952127,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Org","depth":10,"bounds":{"left":0.08494016,"top":0.52474064,"width":0.01861702,"height":0.046288908},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Org","depth":12,"bounds":{"left":0.090259306,"top":0.55786115,"width":0.007978723,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Plan","depth":10,"bounds":{"left":0.08494016,"top":0.58699125,"width":0.01861702,"height":0.04668795},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Plan","depth":12,"bounds":{"left":0.08959442,"top":0.6201117,"width":0.00930851,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Chunk sidecars","depth":11,"bounds":{"left":0.07962101,"top":0.8591381,"width":0.02925532,"height":0.059457302},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Chunk sidecars","depth":13,"bounds":{"left":0.08494016,"top":0.8922586,"width":0.01861702,"height":0.026735835},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"PREVIEW","depth":12,"bounds":{"left":0.08743351,"top":0.8567438,"width":0.013630319,"height":0.009177973},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Chunk","depth":10,"bounds":{"left":0.07962101,"top":0.9345571,"width":0.02925532,"height":0.046288908},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Chunk","depth":12,"bounds":{"left":0.08726729,"top":0.96727854,"width":0.013962766,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Smarter Testing is now in beta","depth":9,"bounds":{"left":0.1221742,"top":0.8707103,"width":0.10638298,"height":0.014764565},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Smarter Testing is now in beta","depth":10,"bounds":{"left":0.1221742,"top":0.87031126,"width":0.07795878,"height":0.01556265},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Get faster feedback on your tests while maintaining full confidence","depth":10,"bounds":{"left":0.1221742,"top":0.8934557,"width":0.098902926,"height":0.030327214},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Dismiss","depth":9,"bounds":{"left":0.16156915,"top":0.93615323,"width":0.027925532,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Get Started","depth":9,"bounds":{"left":0.19215426,"top":0.93615323,"width":0.036402926,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Get Started","depth":10,"bounds":{"left":0.1974734,"top":0.94213885,"width":0.025764627,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Dashboard All Pipelines","depth":15,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"All Pipelines","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Project Outline app","depth":15,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":false,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"app","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"app","depth":13,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"app","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Overview","depth":13,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Overview","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Settings","depth":13,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Settings","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Deploys","depth":13,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Deploys","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Lightning Manage triggers","depth":13,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Manage triggers","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Trigger Pipeline","depth":13,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Pipelines All pipelines my-pipelines-filter","depth":13,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"All pipelines","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"app Project Filter. Selected \"app\"","depth":13,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"app","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"All branches Branch Filter. Selected \"All branches\"","depth":13,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"All branches","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Start Time Cutoff date Arrow Drop Down","depth":13,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Cutoff date","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"All statuses Arrow Drop Down","depth":13,"on_screen":false,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"All","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"statuses","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Filter","depth":13,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Pipeline","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Status","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Workflow","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Checkout source","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Trigger event","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Start/Duration","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Actions","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"app","depth":13,"bounds":{"left":0.12283909,"top":0.188747,"width":0.00831117,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"58213","depth":12,"bounds":{"left":0.12283909,"top":0.20510775,"width":0.013630319,"height":0.015961692},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"58213","depth":13,"bounds":{"left":0.12283909,"top":0.20630486,"width":0.013630319,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"FAILED workflow build_accept_deploy. Collapse the workflow jobs list.","depth":13,"bounds":{"left":0.15807846,"top":0.19074222,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Status Failed Failed","depth":12,"bounds":{"left":0.17004654,"top":0.19233839,"width":0.027925532,"height":0.023144454},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Failed","depth":13,"bounds":{"left":0.18068483,"top":0.1971269,"width":0.013297873,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"build_accept_deploy","depth":12,"bounds":{"left":0.22490026,"top":0.19233839,"width":0.031914894,"height":0.03152434},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"build_accept_deploy","depth":13,"bounds":{"left":0.22490026,"top":0.19313647,"width":0.030585106,"height":0.029928172},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"JY-20742-mcp-poc-OAuth-DCR","depth":13,"bounds":{"left":0.2621343,"top":0.18914606,"width":0.07197473,"height":0.01556265},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20742-mcp-poc-OAuth-DCR","depth":14,"bounds":{"left":0.2621343,"top":0.18994413,"width":0.07197473,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Open commit on version control site","depth":14,"bounds":{"left":0.2621343,"top":0.20790103,"width":0.07330452,"height":0.033519555},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"7dd5c29","depth":16,"bounds":{"left":0.2621343,"top":0.20949721,"width":0.019614361,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"JY-20625 | JY-20742 | debug consent screen","depth":16,"bounds":{"left":0.2621343,"top":0.20949721,"width":0.07130984,"height":0.030726258},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy timestamp to clipboard","depth":13,"bounds":{"left":0.4034242,"top":0.18914606,"width":0.025764627,"height":0.031923383},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"1h ago","depth":15,"bounds":{"left":0.4090758,"top":0.19832402,"width":0.014461436,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy timestamp duration to clipboard","depth":13,"bounds":{"left":0.40259308,"top":0.22426178,"width":0.026595745,"height":0.031923383},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false}]...
|
6325766753655648738
|
6270138792946546304
|
visual_change
|
accessibility
|
NULL
|
New Tab
New Tab
Jy 20820 es reindex stream model h New Tab
New Tab
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app
JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
Close tab
Pull requests · jiminny/app
Pull requests · jiminny/app
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Go to home page
Auto theme
Open notifications
Open support menu
Open user menu
org avatar Current organization: jiminny
Home
Home
Pipelines
Pipelines
Projects
Projects
Deploys
Deploys
Insights
Insights
Runners
Runners
Org
Org
Plan
Plan
Chunk sidecars
Chunk sidecars
PREVIEW
Chunk
Chunk
Smarter Testing is now in beta
Smarter Testing is now in beta
Get faster feedback on your tests while maintaining full confidence
Dismiss
Get Started
Get Started
Dashboard All Pipelines
All Pipelines
Project Outline app
app
app
app
Overview
Overview
Settings
Settings
Deploys
Deploys
Lightning Manage triggers
Manage triggers
Trigger Pipeline
Pipelines All pipelines my-pipelines-filter
All pipelines
app Project Filter. Selected "app"
app
All branches Branch Filter. Selected "All branches"
All branches
Start Time Cutoff date Arrow Drop Down
Cutoff date
All statuses Arrow Drop Down
All
statuses
Filter
Pipeline
Status
Workflow
Checkout source
Trigger event
Start/Duration
Actions
app
58213
58213
FAILED workflow build_accept_deploy. Collapse the workflow jobs list.
Status Failed Failed
Failed
build_accept_deploy
build_accept_deploy
JY-20742-mcp-poc-OAuth-DCR
JY-20742-mcp-poc-OAuth-DCR
Open commit on version control site
7dd5c29
JY-20625 | JY-20742 | debug consent screen
Copy timestamp to clipboard
1h ago
Copy timestamp duration to clipboard...
|
20713
|
NULL
|
NULL
|
NULL
|
|
20716
|
901
|
25
|
2026-05-11T16:13:07.228098+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515987228_m2.jpg...
|
Firefox
|
CircleCI — Work
|
1
|
app.circleci.com/pipelines/github/jiminny/app
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
New Tab
New Tab
Jy 20820 es reindex stream model h New Tab
New Tab
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app
JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app
CircleCI
CircleCI
Close tab
Pull requests · jiminny/app
Pull requests · jiminny/app
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"New Tab","depth":4,"bounds":{"left":0.0,"top":0.0518755,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"New Tab","depth":5,"bounds":{"left":0.013297873,"top":0.06304868,"width":0.014960106,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.08459697,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.09577015,"width":0.16888298,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira","depth":4,"bounds":{"left":0.0,"top":0.11731844,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.12849163,"width":0.16140293,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":4,"bounds":{"left":0.0,"top":0.15003991,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":5,"bounds":{"left":0.013297873,"top":0.16121309,"width":0.4644282,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.18276137,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.19393456,"width":0.18816489,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"CircleCI","depth":4,"bounds":{"left":0.0,"top":0.21548285,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"CircleCI","depth":5,"bounds":{"left":0.013297873,"top":0.22665602,"width":0.013630319,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.06732048,"top":0.22266561,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"Pull requests · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.2482043,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pull requests · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.25937748,"width":0.04537899,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.0028257978,"top":0.28252193,"width":0.07413564,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"bounds":{"left":0.0028257978,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"bounds":{"left":0.013796543,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Tabs from other devices","depth":6,"bounds":{"left":0.024933511,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open history (⇧⌘H)","depth":6,"bounds":{"left":0.036070477,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open bookmarks (⌘B)","depth":6,"bounds":{"left":0.04720745,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false}]...
|
4703466810087668801
|
-3200649130029305202
|
visual_change
|
accessibility
|
NULL
|
New Tab
New Tab
Jy 20820 es reindex stream model h New Tab
New Tab
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app
JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app
CircleCI
CircleCI
Close tab
Pull requests · jiminny/app
Pull requests · jiminny/app
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20717
|
901
|
26
|
2026-05-11T16:13:10.172252+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515990172_m2.jpg...
|
Firefox
|
Pipelines - jiminny/app — Work
|
1
|
app.circleci.com/pipelines/github/jiminny/app
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
New Tab
New Tab
Jy 20820 es reindex stream model h New Tab
New Tab
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app
JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
Close tab
Pull requests · jiminny/app
Pull requests · jiminny/app
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Go to home page
Auto theme
Open notifications
Open support menu
Open user menu
org avatar Current organization: jiminny
Home
Home
Pipelines
Pipelines
Projects
Projects
Deploys
Deploys
Insights
Insights
Runners
Runners
Org
Org
Plan
Plan
Chunk sidecars
Chunk sidecars
PREVIEW
Chunk
Chunk
Smarter Testing is now in beta
Smarter Testing is now in beta
Get faster feedback on your tests while maintaining full confidence
Dismiss
Get Started
Get Started
Dashboard All Pipelines
All Pipelines
Project Outline app
app
app
app
Overview...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"New Tab","depth":4,"bounds":{"left":0.0,"top":0.0518755,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"New Tab","depth":5,"bounds":{"left":0.013297873,"top":0.06304868,"width":0.014960106,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.08459697,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.09577015,"width":0.16888298,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira","depth":4,"bounds":{"left":0.0,"top":0.11731844,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.12849163,"width":0.16140293,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":4,"bounds":{"left":0.0,"top":0.15003991,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":5,"bounds":{"left":0.013297873,"top":0.16121309,"width":0.4644282,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.18276137,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.19393456,"width":0.18816489,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Pipelines - jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.21548285,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"Pipelines - jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.22665602,"width":0.039228722,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.06732048,"top":0.22266561,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"Pull requests · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.2482043,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pull requests · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.25937748,"width":0.04537899,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.0028257978,"top":0.28252193,"width":0.07413564,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"bounds":{"left":0.0028257978,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"bounds":{"left":0.013796543,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Tabs from other devices","depth":6,"bounds":{"left":0.024933511,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open history (⇧⌘H)","depth":6,"bounds":{"left":0.036070477,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open bookmarks (⌘B)","depth":6,"bounds":{"left":0.04720745,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Go to home page","depth":9,"bounds":{"left":0.08726729,"top":0.061452515,"width":0.044215426,"height":0.031923383},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXMenuButton","text":"Auto theme","depth":9,"bounds":{"left":0.4375,"top":0.061452515,"width":0.013297873,"height":0.031923383},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open notifications","depth":9,"bounds":{"left":0.45212767,"top":0.061452515,"width":0.013297873,"height":0.031923383},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXMenuButton","text":"Open support menu","depth":9,"bounds":{"left":0.46675533,"top":0.061452515,"width":0.013297873,"height":0.031923383},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXMenuButton","text":"Open user menu","depth":9,"bounds":{"left":0.48138297,"top":0.061452515,"width":0.013297873,"height":0.031923383},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"org avatar Current organization: jiminny","depth":9,"bounds":{"left":0.08693484,"top":0.10295291,"width":0.01462766,"height":0.035115723},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Home","depth":10,"bounds":{"left":0.08494016,"top":0.15083799,"width":0.01861702,"height":0.046288908},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Home","depth":12,"bounds":{"left":0.087765954,"top":0.1839585,"width":0.012965426,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Pipelines","depth":10,"bounds":{"left":0.08494016,"top":0.21308859,"width":0.01861702,"height":0.046288908},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pipelines","depth":12,"bounds":{"left":0.083942816,"top":0.2462091,"width":0.020611702,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Projects","depth":10,"bounds":{"left":0.08494016,"top":0.2753392,"width":0.01861702,"height":0.04668795},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Projects","depth":12,"bounds":{"left":0.0852726,"top":0.3084597,"width":0.017952127,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Deploys","depth":10,"bounds":{"left":0.08494016,"top":0.33798882,"width":0.01861702,"height":0.046288908},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Deploys","depth":12,"bounds":{"left":0.08543883,"top":0.37071028,"width":0.01761968,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Insights","depth":10,"bounds":{"left":0.08494016,"top":0.40023944,"width":0.01861702,"height":0.046288908},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Insights","depth":12,"bounds":{"left":0.085605055,"top":0.4329609,"width":0.017287234,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Runners","depth":10,"bounds":{"left":0.08494016,"top":0.46249002,"width":0.01861702,"height":0.046288908},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Runners","depth":12,"bounds":{"left":0.0852726,"top":0.49561054,"width":0.017952127,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Org","depth":10,"bounds":{"left":0.08494016,"top":0.52474064,"width":0.01861702,"height":0.046288908},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Org","depth":12,"bounds":{"left":0.090259306,"top":0.55786115,"width":0.007978723,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Plan","depth":10,"bounds":{"left":0.08494016,"top":0.58699125,"width":0.01861702,"height":0.04668795},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Plan","depth":12,"bounds":{"left":0.08959442,"top":0.6201117,"width":0.00930851,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Chunk sidecars","depth":11,"bounds":{"left":0.07962101,"top":0.8591381,"width":0.02925532,"height":0.059457302},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Chunk sidecars","depth":13,"bounds":{"left":0.08494016,"top":0.8922586,"width":0.01861702,"height":0.026735835},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"PREVIEW","depth":12,"bounds":{"left":0.08743351,"top":0.8567438,"width":0.013630319,"height":0.009177973},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Chunk","depth":10,"bounds":{"left":0.07962101,"top":0.9345571,"width":0.02925532,"height":0.046288908},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Chunk","depth":12,"bounds":{"left":0.08726729,"top":0.96727854,"width":0.013962766,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Smarter Testing is now in beta","depth":9,"bounds":{"left":0.1221742,"top":0.8707103,"width":0.10638298,"height":0.014764565},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Smarter Testing is now in beta","depth":10,"bounds":{"left":0.1221742,"top":0.87031126,"width":0.07795878,"height":0.01556265},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Get faster feedback on your tests while maintaining full confidence","depth":10,"bounds":{"left":0.1221742,"top":0.8934557,"width":0.098902926,"height":0.030327214},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Dismiss","depth":9,"bounds":{"left":0.16156915,"top":0.93615323,"width":0.027925532,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Get Started","depth":9,"bounds":{"left":0.19215426,"top":0.93615323,"width":0.036402926,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Get Started","depth":10,"bounds":{"left":0.1974734,"top":0.94213885,"width":0.025764627,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Dashboard All Pipelines","depth":14,"bounds":{"left":0.1171875,"top":0.1245012,"width":0.042054523,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"All Pipelines","depth":15,"bounds":{"left":0.1278258,"top":0.12609737,"width":0.030086435,"height":0.01556265},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Project Outline app","depth":14,"bounds":{"left":0.16472739,"top":0.1245012,"width":0.021775266,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":false,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"app","depth":15,"bounds":{"left":0.17536569,"top":0.12609737,"width":0.009807181,"height":0.01556265},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"app","depth":12,"bounds":{"left":0.1278258,"top":0.16400638,"width":0.016954787,"height":0.029130088},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"app","depth":13,"bounds":{"left":0.1278258,"top":0.16480447,"width":0.016954787,"height":0.027533919},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Overview","depth":12,"bounds":{"left":0.1171875,"top":0.19952115,"width":0.024102394,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false}]...
|
-2839448159866963461
|
-2912700228786669950
|
visual_change
|
accessibility
|
NULL
|
New Tab
New Tab
Jy 20820 es reindex stream model h New Tab
New Tab
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
[JY-20725] [HubSpot] Optimise CRM rematching on delete hubspot accounts/contacts - Jira
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app
JY-20725 add HS rate limit handling on activities rematching by LakyLak · Pull Request #12066 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
Close tab
Pull requests · jiminny/app
Pull requests · jiminny/app
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Go to home page
Auto theme
Open notifications
Open support menu
Open user menu
org avatar Current organization: jiminny
Home
Home
Pipelines
Pipelines
Projects
Projects
Deploys
Deploys
Insights
Insights
Runners
Runners
Org
Org
Plan
Plan
Chunk sidecars
Chunk sidecars
PREVIEW
Chunk
Chunk
Smarter Testing is now in beta
Smarter Testing is now in beta
Get faster feedback on your tests while maintaining full confidence
Dismiss
Get Started
Get Started
Dashboard All Pipelines
All Pipelines
Project Outline app
app
app
app
Overview...
|
20716
|
NULL
|
NULL
|
NULL
|