|
20315
|
NULL
|
0
|
2026-05-11T14:59:55.511621+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778511595511_m2.jpg...
|
Code
|
Client.php (Working Tree) (Client.php) — app — 9 p Client.php (Working Tree) (Client.php) — app — 9 problems in this file • Modified...
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G) - 17 pending changes
17
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Testing
Claude Code
SOURCE CONTROL
SOURCE CONTROL
Changes Section
CHANGES
CHANGES
Commit Changes on "JY-20725-handle-HS-search-rate-limit"
Commit
More Actions...
Changes
17
.env.local
M
.env.nikilocal
U
.env.other
U
ids.txt
U
raw_sql_query.sql
U
WEBHOOK_FILTERING_IMPLEMENTATION.md
U
JiminnyDebugCommand.php
app/Console/Commands
M
SimulateWebhooksCommand.php
app/Console/Commands/Crm/Hubspot
U
CreateMockAskJiminnyReportResultCommand.php
app/Console/Commands/Reports
U
RateLimitException.php
app/Exceptions
M
MatchActivityCrmData.php
app/Jobs/Crm
M
Client.php
app/Services/Crm/Hubspot
9, M
logging.php
config
M
favicon.ico
public
U
HandleHubspotRateLimitTest.php
tests/Unit/Jobs/Middleware
M
CanAccessAiReportsTest.php
tests/Unit/Policies
U
ClientTest.php
tests/Unit/Services/Crm/Hubspot
M
Graph Section
GRAPH
GRAPH
JY-20725 add more tests
Lukas Kovalik
JY-20725-handle-HS-search-rate-limit
JY-20725 add tests
Lukas Kovalik
JY-20725 add HS rate limit handling on activities rematching
Lukas Kovalik
Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
origin/master
Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit
mihailmihaylovjiminny
JY-20819: Increase download transcription rate limit
mihail.mihaylov
JY-20818 move ask jiminny reports to its own datadog metric
Lukas Kovalik
Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge branch 'master' into JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge pull request #12047 from jiminny/JY-20662-remove-word-boost
ilian-jiminny
JY-20662 return void
ilian
JY-20662 Removed word_boost parameter from Assembly transcription request
ilian
JY-20817: Fix deleting old tracks
mihail.mihaylov
Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge branch 'master' into JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update
ilian-jiminny
JY-19938 Added consent information and recording error reason to Elastic Search mapping
ilian
JY-19938 reorder array map
ilian
JY-19938: Address PR review comments
ilian
JY-19938 lint fixes
ilian
HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1
ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1
Client.php (Working Tree) (Client.php), Editor Group 1
MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1
RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1
Review rate limit handli…, Editor Group 2
remote
app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...
JY-20725-handle-HS-search-rate-limit*
app (Git) - Synchronize Changes
Errors: 6, Warnings: 11, Infos: 28
6
11
28
Notifications
key, PHP extension: Premium features not active.
8.3, Language Level: 8.3
Sign In
Sign In
PHP
LF
UTF-8
Spaces: 4
Ln 120, Col 1
git-commit Not Committed Yet
Not Committed Yet
Diff editor
Clear
Review rate limit handling for HubSpot job queue
Session history
New session
Message actions
RateLimitException.php#9-9
RateLimitException.php#9-9
Reveiw the diff. Mostly
@app/Jobs/Crm/MatchActivityCrmData.php
,
@app/Exceptions/RateLimitException.php
,
@app/Jobs/Middleware/HandleHubspotRateLimit.php
,
@app/Services/Crm/Hubspot/Client.php
and
@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php
. The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?
Thought for 5s
Thought for 5s
I'll review the implementation carefully. Let me first check how
RateLimitException
is used elsewhere and look at the test files and
PaginationState
.
Bash
Find all usages of RateLimitException outside the changed files
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/app --include="*.php" | grep -v "HandleHubspotRateLimit\|MatchActivityCrmData\|Client.php\|RateLimitException.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is
Read
PaginationState.php
PaginationState.php
Read
HandleHubspotRateLimitTest.php
HandleHubspotRateLimitTest.php
Bash
Check how RateLimitException is used in GenerateActionItemsJob
IN
grep -rn "RateLimitException\|LogicException\|RuntimeException" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
Bash
Find all catch blocks or instanceof checks for RateLimitException
IN
grep -rn "catch.*RateLimitException\|instanceof RateLimitException" /Users/lukas/jiminny/app/app --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {
Bash
Find all test references to RateLimitException
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/tests --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);
Bash
Check retry/failure handling in GenerateActionItemsJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30
Copy code to clipboard
OUT
13:use Jiminny\Exceptions\RateLimitException;
51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
56: $this->release(30);
Bash
Check retry/failure handling in GenerateAiCallScoringJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30
Copy code to clipboard
OUT
17:use Jiminny\Exceptions\RateLimitException;
68: RateLimitException |
75: $this->release(30);
Bash
Check retry config in MatchActivityCrmData
IN
grep -n "retryAfter\|RETRY_WINDOW\|retryUntil\|maxTries" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php
Copy code to clipboard
OUT
34: private const int RETRY_WINDOW_MINUTES = 30;
45: public function retryUntil(): \DateTimeInterface
47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);
77: return self::RETRY_WINDOW_MINUTES * 60 + 60;
Bash
Check the cache key format
IN
grep -n "getRateLimitCacheKey\|hubspot:ratelimit" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Copy code to clipboard
OUT
78: $cacheKey = $this->getRateLimitCacheKey();
110: private function getRateLimitCacheKey(): string
112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());
Bash
Find other jobs that might use the rate limit middleware
IN
grep -rn "class.*Middleware\|HandleHubspotRateLimit" /Users/lukas/jiminny/app/app/Jobs --include="*.php" | grep -v "HandleHubspotRateLimit.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];
Read
HubspotClientInterface.php
HubspotClientInterface.php
Thought for 73s
Thought for 73s
Now I have a complete picture. Here's my full analysis:
Review Summary
Review Summary
RuntimeException vs LogicException — Safe ✓
RuntimeException
vs
LogicException
— Safe ✓
The change is semantically correct and won't break anything.
LogicException
is for programmer errors (invalid arguments, contract violations). A rate limit from an external API is a runtime condition —
RuntimeException
is the right parent. All callers catch
RateLimitException
by name explicitly (union catches in
GenerateActionItemsJob
,
GenerateAiCallScoringJob
,
SummarizeTranscriptionJob
,
GenerateAiActivityTypeJob
) or check
instanceof RateLimitException
— none relies on the parent class type, so nothing breaks.
Issue 1 — Circuit breaker stores stale delay (most important bug)
Issue 1 — Circuit breaker stores stale delay (most important bug)
Client.php:94
Client.php:94
Copy code to clipboard
Redis::setex($cacheKey, $retryAfter, (string) $retryAfter);
The TTL is
$retryAfter
seconds, but the
stored value
is also the original
$retryAfter
. If a second job reads this cache 20 seconds later, it throws
RateLimitException(retryAfter=30)
and gets delayed 30 more seconds — even though only 10 remain. In a spike of 100 jobs, each subsequent job inflates its wait.
Fix:
store the absolute expiry timestamp, reconstruct the remaining delay when reading:
Copy code to clipboard
// on 429:
Redis::setex($cacheKey, $retryAfter, (string) (time() + $retryAfter));
// in the cached-read branch:
$remaining = max(1, (int) $cachedRetryAfter - time());
throw new RateLimitException('Hubspot rate limit (cached circuit-breaker)', $remaining);
Issue 2 — retryUntil() window may expire under sustained rate limiting
Issue 2 —
retryUntil()
window may expire under sustained rate limiting
MatchActivityCrmData.php:45-47
MatchActivityCrmData.php:45-47
retryUntil()
is calculated as
now() + 30 min
at
dispatch time
and stored in the job payload. With
MAX_RETRY_DELAY = 600s
, you only get ~3 rate-limit-triggered releases before the 30-minute window closes and the job is permanently failed. For a heavy 429 spike (daily limit, parsed as 600s), this can easily happen.
Consider increasing
RETRY_WINDOW_MINUTES
to 60 or more, or making it configurable. The
uniqueFor()
value should be updated to match.
Issue 3 — Only search() uses the circuit breaker, but other methods can still hit 429
Issue 3 — Only
search()
uses the circuit breaker, but other methods can still hit 429
Client.php:76-108
Client.php:76-108
executeRequest()
is only called from
search()
. Methods like
getOpportunityById()
,
batchReadObjects()
,
getContactByEmail()
bypass it entirely. If HubSpot's 429 is account-wide (not search-endpoint-specific), those methods will keep firing even while the circuit breaker is supposedly open. This is likely fine because the search endpoint has its own tighter quota (5 RPS), but it's worth being explicit in a comment.
Issue 4 — object $job type in middleware has no compile-time safety
Issue 4 —
object $job
type in middleware has no compile-time safety
HandleHubspotRateLimit.php:20
HandleHubspotRateLimit.php:20
Copy code to clipboard
public function handle(object $job, callable $next): void
Calling
$job->attempts()
and
$job->release()
on
object
will work at runtime since Laravel always passes jobs that use
InteractsWithQueue
, but PHPStan/psalm won't verify this and it could silently break in tests with bare mocks (as your test does use a
Job
mock that has
attempts()
— so the test is fine, but the real type contract is invisible). Minor, but worth tightening to
\Illuminate\Contracts\Queue\Job
.
What works well ✓
What works well ✓
RateLimitException
is caught by the middleware
before
it increments
maxExceptions
, so rate-limit releases don't consume the 3-exception budget for real failures.
The middleware's jitter (
random_int(0, 5)
) prevents thundering herd on release.
The log-sampling logic (
attempts <= 3 || attempts % 10 === 0
) keeps logs useful without flooding.
ShouldBeUnique
+
uniqueFor()
correctly prevents duplicate jobs queuing up during the rate-limit wait window.
RateLimitException
propagates cleanly through the DB transaction (rolled back), then through the job catch block (logged conditionally), then to the middleware which handles it.
The pagination service re-throws
RateLimitException
as-is and resets pagination state on the next job attempt — safe because offset restarts from 0.
Message actions
Expand on 1 and 2. Let's consider there are several jobs running from ten workers. They are out fired instantly, and therefore there is no cache when they look for cache, but once they receive 429, they store cache. Can there be some potential issues with that flow?
Thought for 91s
Thought for 91s
Let me trace the exact race conditions step by step.
Wave 1 — The Initial Burst (t=0)
Wave 1 — The Initial Burst (t=0)
All 10 workers execute simultaneously. Each does:
Copy code to clipboard
$cachedRetryAfter = Redis::get($cacheKey); // ALL TEN: null — cache miss
Cache doesn't exist yet, so all 10 proceed and hammer HubSpot's search endpoint simultaneously. HubSpot's search limit is 5 RPS per portal — 10 concurrent requests guarantees 429s. All 10 get 429, all 10 run:
Copy code to clipboard
Redis::setex($cacheKey, $retryAfter, (string) $retryAfter); // e.g. TTL=10, value="10"
throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);
All 10 write the same key at nearly the same millisecond. Redis just accepts last-write-wins — no error, no coordination, 10 redundant writes. Then all 10 are caught by the middleware and released:
Copy code to clipboard
delay = max(1, min(10, 600)) + random_int(0, 5) → 10 to 15 seconds
So all 10 jobs land back in the queue, scheduled to fire between t=10 and t=15.
The Core Problem — TTL Expires Before Released Jobs Fire
The Core Problem — TTL Expires Before Released Jobs Fire
The Redis key was set with
TTL = retryAfter = 10
. The jobs were released with
delay = retryAfter + jitter = 10–15
.
Copy code to clipboard
Redis key expires: t = 10
Jobs start firing: t = 10 to t = 15
These are nearly the same window. The very first job that fires at t=10 may race against the TTL expiry. Jobs firing at t=11–15
will find an empty cache
because the key already expired. So the circuit breaker provides
zero protection
for the second wave of the same jobs. They all miss the cache again and charge straight at HubSpot.
This is the core mismatch: the release delay was derived from the same value as the TTL, so they expire together. The circuit breaker is only effective for...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Explorer (⇧⌘E)","depth":19,"bounds":{"left":0.0,"top":0.047885075,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.057462092,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Search (⇧⌘F)","depth":19,"bounds":{"left":0.0,"top":0.08619314,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.09577015,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Source Control (⌃⇧G) - 17 pending changes","depth":19,"bounds":{"left":0.0,"top":0.1245012,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":true},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.13407822,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"17","depth":22,"bounds":{"left":0.00831117,"top":0.1452514,"width":0.003656915,"height":0.008778931},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.008643617,"top":0.14604948,"width":0.0016622341,"height":0.007980846}},{"char_start":1,"char_count":1,"bounds":{"left":0.009973404,"top":0.14604948,"width":0.0019946808,"height":0.007980846}}],"role_description":"text"},{"role":"AXRadioButton","text":"Run and Debug (⇧⌘D)","depth":19,"bounds":{"left":0.0,"top":0.16280925,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.17238627,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Remote Explorer","depth":19,"bounds":{"left":0.0,"top":0.20111732,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.21069433,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Extensions (⇧⌘X) - 2 require update","depth":19,"bounds":{"left":0.0,"top":0.23942538,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.2490024,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"2","depth":22,"bounds":{"left":0.009640957,"top":0.2601756,"width":0.0019946808,"height":0.008778931},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Testing","depth":19,"bounds":{"left":0.0,"top":0.27773345,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.28731045,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Claude Code","depth":19,"bounds":{"left":0.0,"top":0.3160415,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"SOURCE CONTROL","depth":17,"bounds":{"left":0.022606382,"top":0.047885075,"width":0.034242023,"height":0.02793296},"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"SOURCE CONTROL","depth":18,"bounds":{"left":0.022606382,"top":0.056664005,"width":0.034242023,"height":0.0103751},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Changes Section","depth":21,"bounds":{"left":0.015957447,"top":0.07581804,"width":0.09940159,"height":0.017557861},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"","depth":23,"bounds":{"left":0.01662234,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"CHANGES","depth":22,"bounds":{"left":0.022606382,"top":0.07581804,"width":0.01861702,"height":0.017557861},"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"CHANGES","depth":23,"bounds":{"left":0.022606382,"top":0.079010375,"width":0.01861702,"height":0.0103751},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"","depth":28,"bounds":{"left":0.024601065,"top":0.09896249,"width":0.07247341,"height":0.015961692},"on_screen":true,"value":"","role_description":"editor","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":29,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Commit Changes on \"JY-20725-handle-HS-search-rate-limit\"","depth":26,"bounds":{"left":0.022273935,"top":0.1245012,"width":0.08045213,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.052526597,"top":0.12849163,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Commit","depth":27,"bounds":{"left":0.058843084,"top":0.1292897,"width":0.014960106,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.059175532,"top":0.1292897,"width":0.0029920214,"height":0.011173184}},{"char_start":1,"char_count":5,"bounds":{"left":0.061835106,"top":0.1292897,"width":0.011968086,"height":0.011173184}}],"role_description":"text"},{"role":"AXPopUpButton","text":"More Actions...","depth":26,"bounds":{"left":0.10305851,"top":0.1245012,"width":0.00831117,"height":0.0207502},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.1043883,"top":0.12849163,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.019614361,"top":0.15163608,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Changes","depth":27,"bounds":{"left":0.025930852,"top":0.15163608,"width":0.01761968,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.025930852,"top":0.15243416,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":6,"bounds":{"left":0.028922873,"top":0.15243416,"width":0.014960106,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"17","depth":27,"bounds":{"left":0.10571808,"top":0.15243416,"width":0.0039893617,"height":0.0103751},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.10605053,"top":0.15243416,"width":0.0016622341,"height":0.0103751}},{"char_start":1,"char_count":1,"bounds":{"left":0.10771277,"top":0.15243416,"width":0.0019946808,"height":0.0103751}}],"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.16759777,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.local","depth":28,"bounds":{"left":0.028590426,"top":0.16919394,"width":0.01861702,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.16999201,"width":0.0013297872,"height":0.011971269}},{"char_start":1,"char_count":9,"bounds":{"left":0.029920213,"top":0.16999201,"width":0.01761968,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.16999201,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.18515563,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.nikilocal","depth":28,"bounds":{"left":0.028590426,"top":0.1867518,"width":0.025598405,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.18754987,"width":0.0013297872,"height":0.011971269}},{"char_start":1,"char_count":13,"bounds":{"left":0.029920213,"top":0.18754987,"width":0.024268618,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.18754987,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.20271349,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.other","depth":28,"bounds":{"left":0.028590426,"top":0.20430966,"width":0.019946808,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.20510775,"width":0.0013297872,"height":0.011971269}},{"char_start":1,"char_count":9,"bounds":{"left":0.029920213,"top":0.20510775,"width":0.01861702,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.20510775,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.22027135,"width":0.0076462766,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ids.txt","depth":28,"bounds":{"left":0.028590426,"top":0.22186752,"width":0.012300532,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.22266561,"width":0.0009973404,"height":0.011971269}},{"char_start":1,"char_count":6,"bounds":{"left":0.029587766,"top":0.22266561,"width":0.011635638,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.22266561,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.23782921,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"raw_sql_query.sql","depth":28,"bounds":{"left":0.028590426,"top":0.23942538,"width":0.035904255,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.24022347,"width":0.0016622341,"height":0.011971269}},{"char_start":1,"char_count":16,"bounds":{"left":0.03025266,"top":0.24022347,"width":0.034242023,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.24022347,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.25538707,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"WEBHOOK_FILTERING_IMPLEMENTATION.md","depth":28,"bounds":{"left":0.028590426,"top":0.25698325,"width":0.076130316,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.25778133,"width":0.0043218085,"height":0.011971269}},{"char_start":1,"char_count":34,"bounds":{"left":0.032912236,"top":0.25778133,"width":0.08909574,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.25778133,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.27294493,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JiminnyDebugCommand.php","depth":28,"bounds":{"left":0.028590426,"top":0.2745411,"width":0.05817819,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.2753392,"width":0.0023271276,"height":0.011971269}},{"char_start":1,"char_count":22,"bounds":{"left":0.030917553,"top":0.2753392,"width":0.055851065,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands","depth":28,"bounds":{"left":0.08843085,"top":0.2745411,"width":0.01462766,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.0887633,"top":0.27613726,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":19,"bounds":{"left":0.09075798,"top":0.27613726,"width":0.043218084,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.2753392,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.2905028,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"SimulateWebhooksCommand.php","depth":28,"bounds":{"left":0.028590426,"top":0.29209897,"width":0.06781915,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.29289705,"width":0.0026595744,"height":0.011971269}},{"char_start":1,"char_count":26,"bounds":{"left":0.03125,"top":0.29289705,"width":0.065159574,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands/Crm/Hubspot","depth":28,"bounds":{"left":0.098071806,"top":0.29209897,"width":0.005984043,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.09840426,"top":0.29369512,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":31,"bounds":{"left":0.100398935,"top":0.29369512,"width":0.06881649,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.29289705,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.30806065,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CreateMockAskJiminnyReportResultCommand.php","depth":28,"bounds":{"left":0.028590426,"top":0.30965683,"width":0.07646277,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.3104549,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":42,"bounds":{"left":0.03158245,"top":0.3104549,"width":0.099734046,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands/Reports","depth":28,"bounds":{"left":0.10538564,"top":0.3104549,"width":0.0003324468,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.13331117,"top":0.31125298,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":27,"bounds":{"left":0.13530585,"top":0.31125298,"width":0.05851064,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.3104549,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.3256185,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException.php","depth":28,"bounds":{"left":0.028590426,"top":0.3272147,"width":0.047539894,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.32801276,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":21,"bounds":{"left":0.03158245,"top":0.32801276,"width":0.04488032,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Exceptions","depth":28,"bounds":{"left":0.078125,"top":0.3272147,"width":0.024933511,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.078125,"top":0.32881084,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":13,"bounds":{"left":0.08045213,"top":0.32881084,"width":0.026263298,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.32801276,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.34317636,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"MatchActivityCrmData.php","depth":28,"bounds":{"left":0.028590426,"top":0.34477255,"width":0.054521278,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.34557062,"width":0.0039893617,"height":0.011971269}},{"char_start":1,"char_count":23,"bounds":{"left":0.032579787,"top":0.34557062,"width":0.050531916,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Jobs/Crm","depth":28,"bounds":{"left":0.084773935,"top":0.34477255,"width":0.020279255,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.084773935,"top":0.3463687,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":11,"bounds":{"left":0.087101065,"top":0.3463687,"width":0.023603724,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.34557062,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.36073422,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Client.php","depth":28,"bounds":{"left":0.028590426,"top":0.3623304,"width":0.020611702,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.36312848,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":9,"bounds":{"left":0.03158245,"top":0.36312848,"width":0.01761968,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Services/Crm/Hubspot","depth":28,"bounds":{"left":0.05119681,"top":0.3623304,"width":0.028590426,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.05119681,"top":0.3639266,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":23,"bounds":{"left":0.053523935,"top":0.3639266,"width":0.047539894,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"9, M","depth":28,"bounds":{"left":0.10206117,"top":0.36312848,"width":0.00831117,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.10206117,"top":0.36312848,"width":0.0026595744,"height":0.011173184}},{"char_start":1,"char_count":3,"bounds":{"left":0.10472074,"top":0.36312848,"width":0.005984043,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.3782921,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"logging.php","depth":28,"bounds":{"left":0.028590426,"top":0.37988827,"width":0.023936171,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.38068634,"width":0.0009973404,"height":0.011971269}},{"char_start":1,"char_count":10,"bounds":{"left":0.029587766,"top":0.38068634,"width":0.022938829,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"config","depth":28,"bounds":{"left":0.05418883,"top":0.38068634,"width":0.011968086,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.054521278,"top":0.38148445,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":5,"bounds":{"left":0.05651596,"top":0.38148445,"width":0.009640957,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.38068634,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.39584997,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"favicon.ico","depth":28,"bounds":{"left":0.028590426,"top":0.39744613,"width":0.021609042,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.3982442,"width":0.0016622341,"height":0.011971269}},{"char_start":1,"char_count":10,"bounds":{"left":0.03025266,"top":0.3982442,"width":0.020279255,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"public","depth":28,"bounds":{"left":0.05219415,"top":0.3982442,"width":0.011303191,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.05219415,"top":0.3990423,"width":0.0026595744,"height":0.011173184}},{"char_start":1,"char_count":5,"bounds":{"left":0.054521278,"top":0.3990423,"width":0.008976064,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.3982442,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.41340783,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php","depth":28,"bounds":{"left":0.028590426,"top":0.415004,"width":0.06781915,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.41580206,"width":0.0033244682,"height":0.011971269}},{"char_start":1,"char_count":29,"bounds":{"left":0.031914894,"top":0.41580206,"width":0.064494684,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Jobs/Middleware","depth":28,"bounds":{"left":0.098071806,"top":0.415004,"width":0.004986702,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.098071806,"top":0.41660017,"width":0.0016622341,"height":0.011173184}},{"char_start":1,"char_count":25,"bounds":{"left":0.09940159,"top":0.41660017,"width":0.04886968,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.41580206,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.4309657,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CanAccessAiReportsTest.php","depth":28,"bounds":{"left":0.028590426,"top":0.43256184,"width":0.059175532,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.43335995,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":25,"bounds":{"left":0.03158245,"top":0.43335995,"width":0.05618351,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Policies","depth":28,"bounds":{"left":0.089428194,"top":0.43256184,"width":0.013962766,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.08976064,"top":0.43415803,"width":0.0013297872,"height":0.011173184}},{"char_start":1,"char_count":18,"bounds":{"left":0.091090426,"top":0.43415803,"width":0.031914894,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.43335995,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.44852355,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClientTest.php","depth":28,"bounds":{"left":0.028590426,"top":0.4501197,"width":0.028922873,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.4509178,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":13,"bounds":{"left":0.03158245,"top":0.4509178,"width":0.026263298,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Services/Crm/Hubspot","depth":28,"bounds":{"left":0.059507977,"top":0.4501197,"width":0.043882977,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.059507977,"top":0.4517159,"width":0.0016622341,"height":0.011173184}},{"char_start":1,"char_count":30,"bounds":{"left":0.060837764,"top":0.4517159,"width":0.059507977,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.4509178,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Graph Section","depth":21,"bounds":{"left":0.015957447,"top":0.60015965,"width":0.09940159,"height":0.017557861},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"","depth":23,"bounds":{"left":0.01662234,"top":0.60255384,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"GRAPH","depth":22,"bounds":{"left":0.022606382,"top":0.60015965,"width":0.013297873,"height":0.017557861},"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"GRAPH","depth":23,"bounds":{"left":0.022606382,"top":0.60415006,"width":0.013297873,"height":0.0103751},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add more tests","depth":28,"bounds":{"left":0.023271276,"top":0.6201117,"width":0.038231384,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.023271276,"top":0.6209098,"width":0.0026595744,"height":0.011971269}},{"char_start":1,"char_count":22,"bounds":{"left":0.025930852,"top":0.6209098,"width":0.05086436,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.061835106,"top":0.6209098,"width":0.0003324468,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.078457445,"top":0.6217079,"width":0.0026595744,"height":0.011173184}},{"char_start":1,"char_count":12,"bounds":{"left":0.080784574,"top":0.6217079,"width":0.023271276,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.06382979,"top":0.6201117,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit","depth":27,"bounds":{"left":0.06948138,"top":0.6209098,"width":0.032579787,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.06948138,"top":0.6209098,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":35,"bounds":{"left":0.07180851,"top":0.6209098,"width":0.071476065,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.10571808,"top":0.6201117,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add tests","depth":28,"bounds":{"left":0.023271276,"top":0.63766956,"width":0.03956117,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.023271276,"top":0.63846767,"width":0.0023271276,"height":0.011971269}},{"char_start":1,"char_count":17,"bounds":{"left":0.025598405,"top":0.63846767,"width":0.03723404,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.064494684,"top":0.63846767,"width":0.024933511,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.06482713,"top":0.6392658,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":12,"bounds":{"left":0.066821806,"top":0.6392658,"width":0.022938829,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching","depth":28,"bounds":{"left":0.023271276,"top":0.6552275,"width":0.08577128,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.023271276,"top":0.6560255,"width":0.0023271276,"height":0.011971269}},{"char_start":1,"char_count":59,"bounds":{"left":0.025598405,"top":0.6560255,"width":0.11968085,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.109707445,"top":0.6560255,"width":0.0003324468,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.14727394,"top":0.65682364,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":12,"bounds":{"left":0.14926861,"top":0.65682364,"width":0.022938829,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric","depth":28,"bounds":{"left":0.026928192,"top":0.67278534,"width":0.049534574,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.0774601,"top":0.6735834,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.07945479,"top":0.67278534,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"origin/master","depth":27,"bounds":{"left":0.08510638,"top":0.6735834,"width":0.024933511,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric","depth":28,"bounds":{"left":0.030585106,"top":0.6903432,"width":0.078457445,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.109707445,"top":0.69114125,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit","depth":28,"bounds":{"left":0.030585106,"top":0.70790106,"width":0.078457445,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"bounds":{"left":0.109707445,"top":0.7086991,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20819: Increase download transcription rate limit","depth":28,"bounds":{"left":0.030585106,"top":0.7254589,"width":0.078457445,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihail.mihaylov","depth":28,"bounds":{"left":0.109707445,"top":0.72625697,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20818 move ask jiminny reports to its own datadog metric","depth":28,"bounds":{"left":0.030585106,"top":0.7430168,"width":0.07945479,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.109707445,"top":0.7438148,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks","depth":28,"bounds":{"left":0.030585106,"top":0.76057464,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"bounds":{"left":0.109707445,"top":0.7613727,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-20817-fix-deleting-old-tracks","depth":28,"bounds":{"left":0.030585106,"top":0.7781325,"width":0.078125,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"bounds":{"left":0.109707445,"top":0.77893054,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12047 from jiminny/JY-20662-remove-word-boost","depth":28,"bounds":{"left":0.030585106,"top":0.79569036,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"bounds":{"left":0.109707445,"top":0.7964884,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20662 return void","depth":28,"bounds":{"left":0.030585106,"top":0.8132482,"width":0.043218084,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.075465426,"top":0.81404626,"width":0.0076462766,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20662 Removed word_boost parameter from Assembly transcription request","depth":28,"bounds":{"left":0.030585106,"top":0.8308061,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.109707445,"top":0.8316041,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20817: Fix deleting old tracks","depth":28,"bounds":{"left":0.030585106,"top":0.84836394,"width":0.06582447,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihail.mihaylov","depth":28,"bounds":{"left":0.098071806,"top":0.84836394,"width":0.010305851,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es","depth":28,"bounds":{"left":0.030585106,"top":0.8659218,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"bounds":{"left":0.109707445,"top":0.8667199,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-19938-bot-status-in-csv-es","depth":28,"bounds":{"left":0.030585106,"top":0.88347965,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"bounds":{"left":0.109707445,"top":0.88427776,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update","depth":28,"bounds":{"left":0.030585106,"top":0.9010375,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"bounds":{"left":0.109707445,"top":0.9018356,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 Added consent information and recording error reason to Elastic Search mapping","depth":28,"bounds":{"left":0.030585106,"top":0.9185954,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.109707445,"top":0.9193935,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 reorder array map","depth":28,"bounds":{"left":0.030585106,"top":0.93615323,"width":0.056848403,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.089428194,"top":0.93695134,"width":0.00731383,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938: Address PR review comments","depth":28,"bounds":{"left":0.030585106,"top":0.9537111,"width":0.07912234,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.109707445,"top":0.9545092,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 lint fixes","depth":28,"bounds":{"left":0.030585106,"top":0.97126895,"width":0.03756649,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.06981383,"top":0.97206706,"width":0.0076462766,"height":0.0103751},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1","depth":28,"on_screen":false,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1","depth":28,"bounds":{"left":0.11569149,"top":0.047885075,"width":0.08577128,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Client.php (Working Tree) (Client.php), Editor Group 1","depth":28,"bounds":{"left":0.20113032,"top":0.047885075,"width":0.08211436,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXRadioButton","text":"MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1","depth":28,"bounds":{"left":0.28324467,"top":0.047885075,"width":0.11103723,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1","depth":28,"bounds":{"left":0.39394948,"top":0.047885075,"width":0.09075798,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.12832446,"top":0.07821229,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.15093085,"top":0.07821229,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.16489361,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.1875,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.22074468,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.22606383,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.24401596,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.24933511,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Review rate limit handli…, Editor Group 2","depth":28,"bounds":{"left":0.5578458,"top":0.047885075,"width":0.07014628,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXButton","text":"remote","depth":16,"bounds":{"left":0.0006648936,"top":0.98244214,"width":0.010638298,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...","depth":16,"bounds":{"left":0.012965426,"top":0.98244214,"width":0.084773935,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":0.013962766,"top":0.9848364,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit*","depth":17,"bounds":{"left":0.019281914,"top":0.9856345,"width":0.0774601,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"app (Git) - Synchronize Changes","depth":16,"bounds":{"left":0.09740692,"top":0.98244214,"width":0.0076462766,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Errors: 6, Warnings: 11, Infos: 28","depth":16,"bounds":{"left":0.107380316,"top":0.98244214,"width":0.03756649,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":0.109042555,"top":0.9848364,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"6","depth":17,"bounds":{"left":0.1143617,"top":0.9856345,"width":0.004986702,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":0.11934841,"top":0.9848364,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":17,"bounds":{"left":0.124667555,"top":0.9856345,"width":0.00731383,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":0.13164894,"top":0.9848364,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"28","depth":17,"bounds":{"left":0.13696809,"top":0.9856345,"width":0.0063164895,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Notifications","depth":16,"bounds":{"left":0.9886968,"top":0.98244214,"width":0.010638298,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"key, PHP extension: Premium features not active.","depth":16,"bounds":{"left":0.9790558,"top":0.98244214,"width":0.008643617,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"8.3, Language Level: 8.3","depth":16,"bounds":{"left":0.9674202,"top":0.98244214,"width":0.009640957,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sign In","depth":16,"bounds":{"left":0.9428192,"top":0.98244214,"width":0.022938829,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":0.9444814,"top":0.9848364,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Sign In","depth":17,"bounds":{"left":0.94980055,"top":0.9856345,"width":0.014295213,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"PHP","depth":16,"bounds":{"left":0.92952126,"top":0.98244214,"width":0.011635638,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"LF","depth":16,"bounds":{"left":0.91988033,"top":0.98244214,"width":0.007978723,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UTF-8","depth":16,"bounds":{"left":0.90226066,"top":0.98244214,"width":0.015625,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Spaces: 4","depth":16,"bounds":{"left":0.87832445,"top":0.98244214,"width":0.022273935,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Ln 120, Col 1","depth":16,"bounds":{"left":0.84773934,"top":0.98244214,"width":0.028922873,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"git-commit Not Committed Yet","depth":16,"bounds":{"left":0.80019945,"top":0.98244214,"width":0.045545213,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":0.8018617,"top":0.9848364,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Not Committed Yet","depth":17,"bounds":{"left":0.8071808,"top":0.9856345,"width":0.036901597,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Diff editor","depth":12,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Clear","depth":12,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Review rate limit handling for HubSpot job queue","depth":19,"bounds":{"left":0.56017286,"top":0.08060654,"width":0.099734046,"height":0.022346368},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Session history","depth":19,"bounds":{"left":0.9780585,"top":0.08060654,"width":0.00930851,"height":0.022346368},"on_screen":true,"help_text":"Session history","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New session","depth":19,"bounds":{"left":0.9886968,"top":0.08060654,"width":0.00930851,"height":0.022346368},"on_screen":true,"help_text":"New session","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Message actions","depth":24,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"RateLimitException.php#9-9","depth":23,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"RateLimitException.php#9-9","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Reveiw the diff. Mostly","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Jobs/Crm/MatchActivityCrmData.php","depth":26,"on_screen":false,"help_text":"Open app/Jobs/Crm/MatchActivityCrmData.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Exceptions/RateLimitException.php","depth":26,"on_screen":false,"help_text":"Open app/Exceptions/RateLimitException.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Jobs/Middleware/HandleHubspotRateLimit.php","depth":26,"on_screen":false,"help_text":"Open app/Jobs/Middleware/HandleHubspotRateLimit.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Services/Crm/Hubspot/Client.php","depth":26,"on_screen":false,"help_text":"Open app/Services/Crm/Hubspot/Client.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"and","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php","depth":26,"on_screen":false,"help_text":"Open app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":". The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Thought for 5s","depth":23,"on_screen":false,"role_description":"disclosure triangle","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thought for 5s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"I'll review the implementation carefully. Let me first check how","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is used elsewhere and look at the test files and","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PaginationState","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all usages of RateLimitException outside the changed files","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\" /Users/lukas/jiminny/app/app --include=\"*.php\" | grep -v \"HandleHubspotRateLimit\\|MatchActivityCrmData\\|Client.php\\|RateLimitException.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |\n/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"PaginationState.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"PaginationState.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"HandleHubspotRateLimitTest.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check how RateLimitException is used in GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\\|LogicException\\|RuntimeException\" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all catch blocks or instanceof checks for RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"catch.*RateLimitException\\|instanceof RateLimitException\" /Users/lukas/jiminny/app/app --include=\"*.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls\n/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {\n/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all test references to RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\" /Users/lukas/jiminny/app/tests --include=\"*.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry/failure handling in GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"RateLimitException\\|retryUntil\\|backoff\\|maxExceptions\\|maxTries\\|release\\|failed\" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"13:use Jiminny\\Exceptions\\RateLimitException;\n51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n56: $this->release(30);","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry/failure handling in GenerateAiCallScoringJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"RateLimitException\\|retryUntil\\|backoff\\|maxExceptions\\|maxTries\\|release\\|failed\" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"17:use Jiminny\\Exceptions\\RateLimitException;\n68: RateLimitException |\n75: $this->release(30);","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry config in MatchActivityCrmData","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"retryAfter\\|RETRY_WINDOW\\|retryUntil\\|maxTries\" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"34: private const int RETRY_WINDOW_MINUTES = 30;\n45: public function retryUntil(): \\DateTimeInterface\n47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n77: return self::RETRY_WINDOW_MINUTES * 60 + 60;","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check the cache key format","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"getRateLimitCacheKey\\|hubspot:ratelimit\" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"78: $cacheKey = $this->getRateLimitCacheKey();\n110: private function getRateLimitCacheKey(): string\n112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find other jobs that might use the rate limit middleware","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"class.*Middleware\\|HandleHubspotRateLimit\" /Users/lukas/jiminny/app/app/Jobs --include=\"*.php\" | grep -v \"HandleHubspotRateLimit.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\\Jobs\\Middleware\\HandleHubspotRateLimit;\n/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"HubspotClientInterface.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"HubspotClientInterface.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Thought for 73s","depth":23,"on_screen":false,"role_description":"disclosure triangle","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thought for 73s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Now I have a complete picture. Here's my full analysis:","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Review Summary","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Review Summary","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"RuntimeException vs LogicException — Safe ✓","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"RuntimeException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"vs","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LogicException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"— Safe ✓","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The change is semantically correct and won't break anything.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LogicException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is for programmer errors (invalid arguments, contract violations). A rate limit from an external API is a runtime condition —","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RuntimeException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is the right parent. All callers catch","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"by name explicitly (union catches in","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateAiCallScoringJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SummarizeTranscriptionJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateAiActivityTypeJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":") or check","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"instanceof RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"— none relies on the parent class type, so nothing breaks.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 1 — Circuit breaker stores stale delay (most important bug)","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 1 — Circuit breaker stores stale delay (most important bug)","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"Client.php:94","depth":23,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Client.php:94","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Redis::setex($cacheKey, $retryAfter, (string) $retryAfter);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The TTL is","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"$retryAfter","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"seconds, but the","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"stored value","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is also the original","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"$retryAfter","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":". If a second job reads this cache 20 seconds later, it throws","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException(retryAfter=30)","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"and gets delayed 30 more seconds — even though only 10 remain. In a spike of 100 jobs, each subsequent job inflates its wait.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Fix:","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"store the absolute expiry timestamp, reconstruct the remaining delay when reading:","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"// on 429:\nRedis::setex($cacheKey, $retryAfter, (string) (time() + $retryAfter));\n\n// in the cached-read branch:\n$remaining = max(1, (int) $cachedRetryAfter - time());\nthrow new RateLimitException('Hubspot rate limit (cached circuit-breaker)', $remaining);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 2 — retryUntil() window may expire under sustained rate limiting","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 2 —","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"retryUntil()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"window may expire under sustained rate limiting","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"MatchActivityCrmData.php:45-47","depth":23,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"MatchActivityCrmData.php:45-47","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"retryUntil()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is calculated as","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"now() + 30 min","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"at","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"dispatch time","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"and stored in the job payload. With","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MAX_RETRY_DELAY = 600s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":", you only get ~3 rate-limit-triggered releases before the 30-minute window closes and the job is permanently failed. For a heavy 429 spike (daily limit, parsed as 600s), this can easily happen.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Consider increasing","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RETRY_WINDOW_MINUTES","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"to 60 or more, or making it configurable. The","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"uniqueFor()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"value should be updated to match.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 3 — Only search() uses the circuit breaker, but other methods can still hit 429","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 3 — Only","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"search()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"uses the circuit breaker, but other methods can still hit 429","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"Client.php:76-108","depth":23,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Client.php:76-108","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"executeRequest()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is only called from","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"search()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":". Methods like","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getOpportunityById()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"batchReadObjects()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getContactByEmail()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"bypass it entirely. If HubSpot's 429 is account-wide (not search-endpoint-specific), those methods will keep firing even while the circuit breaker is supposedly open. This is likely fine because the search endpoint has its own tighter quota (5 RPS), but it's worth being explicit in a comment.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 4 — object $job type in middleware has no compile-time safety","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 4 —","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"object $job","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"type in middleware has no compile-time safety","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"HandleHubspotRateLimit.php:20","depth":23,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"HandleHubspotRateLimit.php:20","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"public function handle(object $job, callable $next): void","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Calling","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"$job->attempts()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"and","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"$job->release()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"on","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"object","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"will work at runtime since Laravel always passes jobs that use","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"InteractsWithQueue","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":", but PHPStan/psalm won't verify this and it could silently break in tests with bare mocks (as your test does use a","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Job","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"mock that has","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"attempts()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"— so the test is fine, but the real type contract is invisible). Minor, but worth tightening to","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"\\Illuminate\\Contracts\\Queue\\Job","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"What works well ✓","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"What works well ✓","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is caught by the middleware","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"before","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"it increments","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"maxExceptions","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":", so rate-limit releases don't consume the 3-exception budget for real failures.","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The middleware's jitter (","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"random_int(0, 5)","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":") prevents thundering herd on release.","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The log-sampling logic (","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"attempts <= 3 || attempts % 10 === 0","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":") keeps logs useful without flooding.","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ShouldBeUnique","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"+","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"uniqueFor()","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"correctly prevents duplicate jobs queuing up during the rate-limit wait window.","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"propagates cleanly through the DB transaction (rolled back), then through the job catch block (logged conditionally), then to the middleware which handles it.","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The pagination service re-throws","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"as-is and resets pagination state on the next job attempt — safe because offset restarts from 0.","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Message actions","depth":24,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Expand on 1 and 2. Let's consider there are several jobs running from ten workers. They are out fired instantly, and therefore there is no cache when they look for cache, but once they receive 429, they store cache. Can there be some potential issues with that flow?","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Thought for 91s","depth":23,"on_screen":false,"role_description":"disclosure triangle","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thought for 91s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Let me trace the exact race conditions step by step.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Wave 1 — The Initial Burst (t=0)","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Wave 1 — The Initial Burst (t=0)","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"All 10 workers execute simultaneously. Each does:","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"$cachedRetryAfter = Redis::get($cacheKey); // ALL TEN: null — cache miss","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Cache doesn't exist yet, so all 10 proceed and hammer HubSpot's search endpoint simultaneously. HubSpot's search limit is 5 RPS per portal — 10 concurrent requests guarantees 429s. All 10 get 429, all 10 run:","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Redis::setex($cacheKey, $retryAfter, (string) $retryAfter); // e.g. TTL=10, value=\"10\"\nthrow new RateLimitException('Hubspot returned 429', $retryAfter, $e);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"All 10 write the same key at nearly the same millisecond. Redis just accepts last-write-wins — no error, no coordination, 10 redundant writes. Then all 10 are caught by the middleware and released:","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"delay = max(1, min(10, 600)) + random_int(0, 5) → 10 to 15 seconds","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"So all 10 jobs land back in the queue, scheduled to fire between t=10 and t=15.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"The Core Problem — TTL Expires Before Released Jobs Fire","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"The Core Problem — TTL Expires Before Released Jobs Fire","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The Redis key was set with","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"TTL = retryAfter = 10","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":". The jobs were released with","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"delay = retryAfter + jitter = 10–15","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Redis key expires: t = 10\nJobs start firing: t = 10 to t = 15","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"These are nearly the same window. The very first job that fires at t=10 may race against the TTL expiry. Jobs firing at t=11–15","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"will find an empty cache","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"because the key already expired. So the circuit breaker provides","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"zero protection","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"for the second wave of the same jobs. They all miss the cache again and charge straight at HubSpot.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"This is the core mismatch: the release delay was derived from the same value as the TTL, so they expire together. The circuit breaker is only effective for","depth":23,"on_screen":false,"role_description":"text"}]...
|
6825050132201250388
|
1472778562829919939
|
idle
|
accessibility
|
NULL
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G) - 17 pending changes
17
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Testing
Claude Code
SOURCE CONTROL
SOURCE CONTROL
Changes Section
CHANGES
CHANGES
Commit Changes on "JY-20725-handle-HS-search-rate-limit"
Commit
More Actions...
Changes
17
.env.local
M
.env.nikilocal
U
.env.other
U
ids.txt
U
raw_sql_query.sql
U
WEBHOOK_FILTERING_IMPLEMENTATION.md
U
JiminnyDebugCommand.php
app/Console/Commands
M
SimulateWebhooksCommand.php
app/Console/Commands/Crm/Hubspot
U
CreateMockAskJiminnyReportResultCommand.php
app/Console/Commands/Reports
U
RateLimitException.php
app/Exceptions
M
MatchActivityCrmData.php
app/Jobs/Crm
M
Client.php
app/Services/Crm/Hubspot
9, M
logging.php
config
M
favicon.ico
public
U
HandleHubspotRateLimitTest.php
tests/Unit/Jobs/Middleware
M
CanAccessAiReportsTest.php
tests/Unit/Policies
U
ClientTest.php
tests/Unit/Services/Crm/Hubspot
M
Graph Section
GRAPH
GRAPH
JY-20725 add more tests
Lukas Kovalik
JY-20725-handle-HS-search-rate-limit
JY-20725 add tests
Lukas Kovalik
JY-20725 add HS rate limit handling on activities rematching
Lukas Kovalik
Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
origin/master
Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit
mihailmihaylovjiminny
JY-20819: Increase download transcription rate limit
mihail.mihaylov
JY-20818 move ask jiminny reports to its own datadog metric
Lukas Kovalik
Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge branch 'master' into JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge pull request #12047 from jiminny/JY-20662-remove-word-boost
ilian-jiminny
JY-20662 return void
ilian
JY-20662 Removed word_boost parameter from Assembly transcription request
ilian
JY-20817: Fix deleting old tracks
mihail.mihaylov
Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge branch 'master' into JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update
ilian-jiminny
JY-19938 Added consent information and recording error reason to Elastic Search mapping
ilian
JY-19938 reorder array map
ilian
JY-19938: Address PR review comments
ilian
JY-19938 lint fixes
ilian
HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1
ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1
Client.php (Working Tree) (Client.php), Editor Group 1
MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1
RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1
Review rate limit handli…, Editor Group 2
remote
app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...
JY-20725-handle-HS-search-rate-limit*
app (Git) - Synchronize Changes
Errors: 6, Warnings: 11, Infos: 28
6
11
28
Notifications
key, PHP extension: Premium features not active.
8.3, Language Level: 8.3
Sign In
Sign In
PHP
LF
UTF-8
Spaces: 4
Ln 120, Col 1
git-commit Not Committed Yet
Not Committed Yet
Diff editor
Clear
Review rate limit handling for HubSpot job queue
Session history
New session
Message actions
RateLimitException.php#9-9
RateLimitException.php#9-9
Reveiw the diff. Mostly
@app/Jobs/Crm/MatchActivityCrmData.php
,
@app/Exceptions/RateLimitException.php
,
@app/Jobs/Middleware/HandleHubspotRateLimit.php
,
@app/Services/Crm/Hubspot/Client.php
and
@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php
. The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?
Thought for 5s
Thought for 5s
I'll review the implementation carefully. Let me first check how
RateLimitException
is used elsewhere and look at the test files and
PaginationState
.
Bash
Find all usages of RateLimitException outside the changed files
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/app --include="*.php" | grep -v "HandleHubspotRateLimit\|MatchActivityCrmData\|Client.php\|RateLimitException.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is
Read
PaginationState.php
PaginationState.php
Read
HandleHubspotRateLimitTest.php
HandleHubspotRateLimitTest.php
Bash
Check how RateLimitException is used in GenerateActionItemsJob
IN
grep -rn "RateLimitException\|LogicException\|RuntimeException" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
Bash
Find all catch blocks or instanceof checks for RateLimitException
IN
grep -rn "catch.*RateLimitException\|instanceof RateLimitException" /Users/lukas/jiminny/app/app --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {
Bash
Find all test references to RateLimitException
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/tests --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);
Bash
Check retry/failure handling in GenerateActionItemsJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30
Copy code to clipboard
OUT
13:use Jiminny\Exceptions\RateLimitException;
51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
56: $this->release(30);
Bash
Check retry/failure handling in GenerateAiCallScoringJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30
Copy code to clipboard
OUT
17:use Jiminny\Exceptions\RateLimitException;
68: RateLimitException |
75: $this->release(30);
Bash
Check retry config in MatchActivityCrmData
IN
grep -n "retryAfter\|RETRY_WINDOW\|retryUntil\|maxTries" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php
Copy code to clipboard
OUT
34: private const int RETRY_WINDOW_MINUTES = 30;
45: public function retryUntil(): \DateTimeInterface
47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);
77: return self::RETRY_WINDOW_MINUTES * 60 + 60;
Bash
Check the cache key format
IN
grep -n "getRateLimitCacheKey\|hubspot:ratelimit" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Copy code to clipboard
OUT
78: $cacheKey = $this->getRateLimitCacheKey();
110: private function getRateLimitCacheKey(): string
112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());
Bash
Find other jobs that might use the rate limit middleware
IN
grep -rn "class.*Middleware\|HandleHubspotRateLimit" /Users/lukas/jiminny/app/app/Jobs --include="*.php" | grep -v "HandleHubspotRateLimit.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];
Read
HubspotClientInterface.php
HubspotClientInterface.php
Thought for 73s
Thought for 73s
Now I have a complete picture. Here's my full analysis:
Review Summary
Review Summary
RuntimeException vs LogicException — Safe ✓
RuntimeException
vs
LogicException
— Safe ✓
The change is semantically correct and won't break anything.
LogicException
is for programmer errors (invalid arguments, contract violations). A rate limit from an external API is a runtime condition —
RuntimeException
is the right parent. All callers catch
RateLimitException
by name explicitly (union catches in
GenerateActionItemsJob
,
GenerateAiCallScoringJob
,
SummarizeTranscriptionJob
,
GenerateAiActivityTypeJob
) or check
instanceof RateLimitException
— none relies on the parent class type, so nothing breaks.
Issue 1 — Circuit breaker stores stale delay (most important bug)
Issue 1 — Circuit breaker stores stale delay (most important bug)
Client.php:94
Client.php:94
Copy code to clipboard
Redis::setex($cacheKey, $retryAfter, (string) $retryAfter);
The TTL is
$retryAfter
seconds, but the
stored value
is also the original
$retryAfter
. If a second job reads this cache 20 seconds later, it throws
RateLimitException(retryAfter=30)
and gets delayed 30 more seconds — even though only 10 remain. In a spike of 100 jobs, each subsequent job inflates its wait.
Fix:
store the absolute expiry timestamp, reconstruct the remaining delay when reading:
Copy code to clipboard
// on 429:
Redis::setex($cacheKey, $retryAfter, (string) (time() + $retryAfter));
// in the cached-read branch:
$remaining = max(1, (int) $cachedRetryAfter - time());
throw new RateLimitException('Hubspot rate limit (cached circuit-breaker)', $remaining);
Issue 2 — retryUntil() window may expire under sustained rate limiting
Issue 2 —
retryUntil()
window may expire under sustained rate limiting
MatchActivityCrmData.php:45-47
MatchActivityCrmData.php:45-47
retryUntil()
is calculated as
now() + 30 min
at
dispatch time
and stored in the job payload. With
MAX_RETRY_DELAY = 600s
, you only get ~3 rate-limit-triggered releases before the 30-minute window closes and the job is permanently failed. For a heavy 429 spike (daily limit, parsed as 600s), this can easily happen.
Consider increasing
RETRY_WINDOW_MINUTES
to 60 or more, or making it configurable. The
uniqueFor()
value should be updated to match.
Issue 3 — Only search() uses the circuit breaker, but other methods can still hit 429
Issue 3 — Only
search()
uses the circuit breaker, but other methods can still hit 429
Client.php:76-108
Client.php:76-108
executeRequest()
is only called from
search()
. Methods like
getOpportunityById()
,
batchReadObjects()
,
getContactByEmail()
bypass it entirely. If HubSpot's 429 is account-wide (not search-endpoint-specific), those methods will keep firing even while the circuit breaker is supposedly open. This is likely fine because the search endpoint has its own tighter quota (5 RPS), but it's worth being explicit in a comment.
Issue 4 — object $job type in middleware has no compile-time safety
Issue 4 —
object $job
type in middleware has no compile-time safety
HandleHubspotRateLimit.php:20
HandleHubspotRateLimit.php:20
Copy code to clipboard
public function handle(object $job, callable $next): void
Calling
$job->attempts()
and
$job->release()
on
object
will work at runtime since Laravel always passes jobs that use
InteractsWithQueue
, but PHPStan/psalm won't verify this and it could silently break in tests with bare mocks (as your test does use a
Job
mock that has
attempts()
— so the test is fine, but the real type contract is invisible). Minor, but worth tightening to
\Illuminate\Contracts\Queue\Job
.
What works well ✓
What works well ✓
RateLimitException
is caught by the middleware
before
it increments
maxExceptions
, so rate-limit releases don't consume the 3-exception budget for real failures.
The middleware's jitter (
random_int(0, 5)
) prevents thundering herd on release.
The log-sampling logic (
attempts <= 3 || attempts % 10 === 0
) keeps logs useful without flooding.
ShouldBeUnique
+
uniqueFor()
correctly prevents duplicate jobs queuing up during the rate-limit wait window.
RateLimitException
propagates cleanly through the DB transaction (rolled back), then through the job catch block (logged conditionally), then to the middleware which handles it.
The pagination service re-throws
RateLimitException
as-is and resets pagination state on the next job attempt — safe because offset restarts from 0.
Message actions
Expand on 1 and 2. Let's consider there are several jobs running from ten workers. They are out fired instantly, and therefore there is no cache when they look for cache, but once they receive 429, they store cache. Can there be some potential issues with that flow?
Thought for 91s
Thought for 91s
Let me trace the exact race conditions step by step.
Wave 1 — The Initial Burst (t=0)
Wave 1 — The Initial Burst (t=0)
All 10 workers execute simultaneously. Each does:
Copy code to clipboard
$cachedRetryAfter = Redis::get($cacheKey); // ALL TEN: null — cache miss
Cache doesn't exist yet, so all 10 proceed and hammer HubSpot's search endpoint simultaneously. HubSpot's search limit is 5 RPS per portal — 10 concurrent requests guarantees 429s. All 10 get 429, all 10 run:
Copy code to clipboard
Redis::setex($cacheKey, $retryAfter, (string) $retryAfter); // e.g. TTL=10, value="10"
throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);
All 10 write the same key at nearly the same millisecond. Redis just accepts last-write-wins — no error, no coordination, 10 redundant writes. Then all 10 are caught by the middleware and released:
Copy code to clipboard
delay = max(1, min(10, 600)) + random_int(0, 5) → 10 to 15 seconds
So all 10 jobs land back in the queue, scheduled to fire between t=10 and t=15.
The Core Problem — TTL Expires Before Released Jobs Fire
The Core Problem — TTL Expires Before Released Jobs Fire
The Redis key was set with
TTL = retryAfter = 10
. The jobs were released with
delay = retryAfter + jitter = 10–15
.
Copy code to clipboard
Redis key expires: t = 10
Jobs start firing: t = 10 to t = 15
These are nearly the same window. The very first job that fires at t=10 may race against the TTL expiry. Jobs firing at t=11–15
will find an empty cache
because the key already expired. So the circuit breaker provides
zero protection
for the second wave of the same jobs. They all miss the cache again and charge straight at HubSpot.
This is the core mismatch: the release delay was derived from the same value as the TTL, so they expire together. The circuit breaker is only effective for...
|
20313
|
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/ /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php...
|
NULL
|
NULL
|
|
20316
|
NULL
|
0
|
2026-05-11T15:00:01.154845+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778511601154_m1.jpg...
|
Code
|
Client.php (Working Tree) (Client.php) — app — 9 p Client.php (Working Tree) (Client.php) — app — 9 problems in this file • Modified...
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G) - 17 pending changes
17
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Testing
Claude Code
SOURCE CONTROL
SOURCE CONTROL
Changes Section
CHANGES
CHANGES
Commit Changes on "JY-20725-handle-HS-search-rate-limit"
Commit
More Actions...
Changes
17
.env.local
M
.env.nikilocal
U
.env.other
U
ids.txt
U
raw_sql_query.sql
U
WEBHOOK_FILTERING_IMPLEMENTATION.md
U
JiminnyDebugCommand.php
app/Console/Commands
M
SimulateWebhooksCommand.php
app/Console/Commands/Crm/Hubspot
U
CreateMockAskJiminnyReportResultCommand.php
app/Console/Commands/Reports
U
RateLimitException.php
app/Exceptions
M
MatchActivityCrmData.php
app/Jobs/Crm
M
Client.php
app/Services/Crm/Hubspot
9, M
logging.php
config
M
favicon.ico
public
U
HandleHubspotRateLimitTest.php
tests/Unit/Jobs/Middleware
M
CanAccessAiReportsTest.php
tests/Unit/Policies
U
ClientTest.php
tests/Unit/Services/Crm/Hubspot
M
Graph Section
GRAPH
GRAPH
JY-20725 add more tests
Lukas Kovalik
JY-20725-handle-HS-search-rate-limit
JY-20725 add tests
Lukas Kovalik
JY-20725 add HS rate limit handling on activities rematching
Lukas Kovalik
Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
origin/master
Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit
mihailmihaylovjiminny
JY-20819: Increase download transcription rate limit
mihail.mihaylov
JY-20818 move ask jiminny reports to its own datadog metric
Lukas Kovalik
Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge branch 'master' into JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge pull request #12047 from jiminny/JY-20662-remove-word-boost
ilian-jiminny
JY-20662 return void
ilian
JY-20662 Removed word_boost parameter from Assembly transcription request
ilian
JY-20817: Fix deleting old tracks
mihail.mihaylov
Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge branch 'master' into JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update
ilian-jiminny
JY-19938 Added consent information and recording error reason to Elastic Search mapping
ilian
JY-19938 reorder array map
ilian
JY-19938: Address PR review comments
ilian
JY-19938 lint fixes
ilian
HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1
ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1
Client.php (Working Tree) (Client.php), Editor Group 1
MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1
RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1
Review rate limit handli…, Editor Group 2
remote
app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...
JY-20725-handle-HS-search-rate-limit*
app (Git) - Synchronize Changes
Errors: 6, Warnings: 11, Infos: 28
6
11
28
Notifications
key, PHP extension: Premium features not active.
8.3, Language Level: 8.3
Sign In
Sign In
PHP
LF
UTF-8
Spaces: 4
Ln 120, Col 1
git-commit Not Committed Yet
Not Committed Yet
Diff editor
Clear
Review rate limit handling for HubSpot job queue
Session history
New session
Message actions
RateLimitException.php#9-9
RateLimitException.php#9-9
Reveiw the diff. Mostly
@app/Jobs/Crm/MatchActivityCrmData.php
,
@app/Exceptions/RateLimitException.php
,
@app/Jobs/Middleware/HandleHubspotRateLimit.php
,
@app/Services/Crm/Hubspot/Client.php
and
@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php
. The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?
Thought for 5s
Thought for 5s
I'll review the implementation carefully. Let me first check how
RateLimitException
is used elsewhere and look at the test files and
PaginationState
.
Bash
Find all usages of RateLimitException outside the changed files
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/app --include="*.php" | grep -v "HandleHubspotRateLimit\|MatchActivityCrmData\|Client.php\|RateLimitException.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is
Read
PaginationState.php
PaginationState.php
Read
HandleHubspotRateLimitTest.php
HandleHubspotRateLimitTest.php
Bash
Check how RateLimitException is used in GenerateActionItemsJob
IN
grep -rn "RateLimitException\|LogicException\|RuntimeException" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
Bash
Find all catch blocks or instanceof checks for RateLimitException
IN
grep -rn "catch.*RateLimitException\|instanceof RateLimitException" /Users/lukas/jiminny/app/app --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {
Bash
Find all test references to RateLimitException
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/tests --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);
Bash
Check retry/failure handling in GenerateActionItemsJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30
Copy code to clipboard
OUT
13:use Jiminny\Exceptions\RateLimitException;
51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
56: $this->release(30);
Bash
Check retry/failure handling in GenerateAiCallScoringJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30
Copy code to clipboard
OUT
17:use Jiminny\Exceptions\RateLimitException;
68: RateLimitException |
75: $this->release(30);
Bash
Check retry config in MatchActivityCrmData
IN
grep -n "retryAfter\|RETRY_WINDOW\|retryUntil\|maxTries" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php
Copy code to clipboard
OUT
34: private const int RETRY_WINDOW_MINUTES = 30;
45: public function retryUntil(): \DateTimeInterface
47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);
77: return self::RETRY_WINDOW_MINUTES * 60 + 60;
Bash
Check the cache key format
IN
grep -n "getRateLimitCacheKey\|hubspot:ratelimit" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Copy code to clipboard
OUT
78: $cacheKey = $this->getRateLimitCacheKey();
110: private function getRateLimitCacheKey(): string
112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());
Bash
Find other jobs that might use the rate limit middleware
IN
grep -rn "class.*Middleware\|HandleHubspotRateLimit" /Users/lukas/jiminny/app/app/Jobs --include="*.php" | grep -v "HandleHubspotRateLimit.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];
Read
HubspotClientInterface.php
HubspotClientInterface.php
Thought for 73s
Thought for 73s
Now I have a complete picture. Here's my full analysis:
Review Summary
Review Summary
RuntimeException vs LogicException — Safe ✓
RuntimeException
vs
LogicException
— Safe ✓
The change is semantically correct and won't break anything.
LogicException
is for programmer errors (invalid arguments, contract violations). A rate limit from an external API is a runtime condition —
RuntimeException
is the right parent. All callers catch
RateLimitException
by name explicitly (union catches in
GenerateActionItemsJob
,
GenerateAiCallScoringJob
,
SummarizeTranscriptionJob
,
GenerateAiActivityTypeJob
) or check
instanceof RateLimitException
— none relies on the parent class type, so nothing breaks.
Issue 1 — Circuit breaker stores stale delay (most important bug)
Issue 1 — Circuit breaker stores stale delay (most important bug)
Client.php:94
Client.php:94
Copy code to clipboard
Redis::setex($cacheKey, $retryAfter, (string) $retryAfter);
The TTL is
$retryAfter
seconds, but the
stored value
is also the original
$retryAfter
. If a second job reads this cache 20 seconds later, it throws
RateLimitException(retryAfter=30)
and gets delayed 30 more seconds — even though only 10 remain. In a spike of 100 jobs, each subsequent job inflates its wait.
Fix:
store the absolute expiry timestamp, reconstruct the remaining delay when reading:
Copy code to clipboard
// on 429:
Redis::setex($cacheKey, $retryAfter, (string) (time() + $retryAfter));
// in the cached-read branch:
$remaining = max(1, (int) $cachedRetryAfter - time());
throw new RateLimitException('Hubspot rate limit (cached circuit-breaker)', $remaining);
Issue 2 — retryUntil() window may expire under sustained rate limiting
Issue 2 —
retryUntil()
window may expire under sustained rate limiting
MatchActivityCrmData.php:45-47
MatchActivityCrmData.php:45-47
retryUntil()
is calculated as
now() + 30 min
at
dispatch time
and stored in the job payload. With
MAX_RETRY_DELAY = 600s
, you only get ~3 rate-limit-triggered releases before the 30-minute window closes and the job is permanently failed. For a heavy 429 spike (daily limit, parsed as 600s), this can easily happen.
Consider increasing
RETRY_WINDOW_MINUTES
to 60 or more, or making it configurable. The...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Explorer (⇧⌘E)","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Search (⇧⌘F)","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Source Control (⌃⇧G) - 17 pending changes","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":true},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"17","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Run and Debug (⇧⌘D)","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Remote Explorer","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Extensions (⇧⌘X) - 2 require update","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"2","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Testing","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Claude Code","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"SOURCE CONTROL","depth":17,"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"SOURCE CONTROL","depth":18,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Changes Section","depth":21,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"CHANGES","depth":22,"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"CHANGES","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"","depth":28,"on_screen":true,"value":"","role_description":"editor","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":29,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Commit Changes on \"JY-20725-handle-HS-search-rate-limit\"","depth":26,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Commit","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"More Actions...","depth":26,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Changes","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"17","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.local","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.nikilocal","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.other","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ids.txt","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"raw_sql_query.sql","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"WEBHOOK_FILTERING_IMPLEMENTATION.md","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JiminnyDebugCommand.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"SimulateWebhooksCommand.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands/Crm/Hubspot","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CreateMockAskJiminnyReportResultCommand.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands/Reports","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Exceptions","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"MatchActivityCrmData.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Jobs/Crm","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Client.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Services/Crm/Hubspot","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"9, M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"logging.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"config","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"favicon.ico","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"public","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Jobs/Middleware","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CanAccessAiReportsTest.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Policies","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClientTest.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Services/Crm/Hubspot","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Graph Section","depth":21,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"GRAPH","depth":22,"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"GRAPH","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add more tests","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add tests","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"origin/master","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20819: Increase download transcription rate limit","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihail.mihaylov","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20818 move ask jiminny reports to its own datadog metric","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-20817-fix-deleting-old-tracks","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12047 from jiminny/JY-20662-remove-word-boost","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20662 return void","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20662 Removed word_boost parameter from Assembly transcription request","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20817: Fix deleting old tracks","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihail.mihaylov","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-19938-bot-status-in-csv-es","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 Added consent information and recording error reason to Elastic Search mapping","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 reorder array map","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938: Address PR review comments","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 lint fixes","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1","depth":28,"on_screen":false,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Client.php (Working Tree) (Client.php), Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXRadioButton","text":"MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Review rate limit handli…, Editor Group 2","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXButton","text":"remote","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit*","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"app (Git) - Synchronize Changes","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Errors: 6, Warnings: 11, Infos: 28","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"6","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"28","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Notifications","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"key, PHP extension: Premium features not active.","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"8.3, Language Level: 8.3","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sign In","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Sign In","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"PHP","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"LF","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UTF-8","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Spaces: 4","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Ln 120, Col 1","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"git-commit Not Committed Yet","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Not Committed Yet","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Diff editor","depth":12,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Clear","depth":12,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Review rate limit handling for HubSpot job queue","depth":19,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Session history","depth":19,"on_screen":true,"help_text":"Session history","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New session","depth":19,"on_screen":true,"help_text":"New session","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Message actions","depth":24,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"RateLimitException.php#9-9","depth":23,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"RateLimitException.php#9-9","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Reveiw the diff. Mostly","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Jobs/Crm/MatchActivityCrmData.php","depth":26,"on_screen":false,"help_text":"Open app/Jobs/Crm/MatchActivityCrmData.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Exceptions/RateLimitException.php","depth":26,"on_screen":false,"help_text":"Open app/Exceptions/RateLimitException.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Jobs/Middleware/HandleHubspotRateLimit.php","depth":26,"on_screen":false,"help_text":"Open app/Jobs/Middleware/HandleHubspotRateLimit.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Services/Crm/Hubspot/Client.php","depth":26,"on_screen":false,"help_text":"Open app/Services/Crm/Hubspot/Client.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"and","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php","depth":26,"on_screen":false,"help_text":"Open app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":". The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Thought for 5s","depth":23,"on_screen":false,"role_description":"disclosure triangle","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thought for 5s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"I'll review the implementation carefully. Let me first check how","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is used elsewhere and look at the test files and","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PaginationState","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all usages of RateLimitException outside the changed files","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\" /Users/lukas/jiminny/app/app --include=\"*.php\" | grep -v \"HandleHubspotRateLimit\\|MatchActivityCrmData\\|Client.php\\|RateLimitException.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |\n/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"PaginationState.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"PaginationState.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"HandleHubspotRateLimitTest.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check how RateLimitException is used in GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\\|LogicException\\|RuntimeException\" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all catch blocks or instanceof checks for RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"catch.*RateLimitException\\|instanceof RateLimitException\" /Users/lukas/jiminny/app/app --include=\"*.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls\n/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {\n/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all test references to RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\" /Users/lukas/jiminny/app/tests --include=\"*.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry/failure handling in GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"RateLimitException\\|retryUntil\\|backoff\\|maxExceptions\\|maxTries\\|release\\|failed\" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"13:use Jiminny\\Exceptions\\RateLimitException;\n51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n56: $this->release(30);","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry/failure handling in GenerateAiCallScoringJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"RateLimitException\\|retryUntil\\|backoff\\|maxExceptions\\|maxTries\\|release\\|failed\" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"17:use Jiminny\\Exceptions\\RateLimitException;\n68: RateLimitException |\n75: $this->release(30);","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry config in MatchActivityCrmData","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"retryAfter\\|RETRY_WINDOW\\|retryUntil\\|maxTries\" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"34: private const int RETRY_WINDOW_MINUTES = 30;\n45: public function retryUntil(): \\DateTimeInterface\n47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n77: return self::RETRY_WINDOW_MINUTES * 60 + 60;","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check the cache key format","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"getRateLimitCacheKey\\|hubspot:ratelimit\" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"78: $cacheKey = $this->getRateLimitCacheKey();\n110: private function getRateLimitCacheKey(): string\n112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find other jobs that might use the rate limit middleware","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"class.*Middleware\\|HandleHubspotRateLimit\" /Users/lukas/jiminny/app/app/Jobs --include=\"*.php\" | grep -v \"HandleHubspotRateLimit.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\\Jobs\\Middleware\\HandleHubspotRateLimit;\n/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"HubspotClientInterface.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"HubspotClientInterface.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Thought for 73s","depth":23,"on_screen":false,"role_description":"disclosure triangle","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thought for 73s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Now I have a complete picture. Here's my full analysis:","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Review Summary","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Review Summary","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"RuntimeException vs LogicException — Safe ✓","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"RuntimeException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"vs","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LogicException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"— Safe ✓","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The change is semantically correct and won't break anything.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LogicException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is for programmer errors (invalid arguments, contract violations). A rate limit from an external API is a runtime condition —","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RuntimeException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is the right parent. All callers catch","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"by name explicitly (union catches in","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateAiCallScoringJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SummarizeTranscriptionJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateAiActivityTypeJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":") or check","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"instanceof RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"— none relies on the parent class type, so nothing breaks.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 1 — Circuit breaker stores stale delay (most important bug)","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 1 — Circuit breaker stores stale delay (most important bug)","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"Client.php:94","depth":23,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Client.php:94","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Redis::setex($cacheKey, $retryAfter, (string) $retryAfter);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The TTL is","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"$retryAfter","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"seconds, but the","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"stored value","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is also the original","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"$retryAfter","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":". If a second job reads this cache 20 seconds later, it throws","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException(retryAfter=30)","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"and gets delayed 30 more seconds — even though only 10 remain. In a spike of 100 jobs, each subsequent job inflates its wait.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Fix:","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"store the absolute expiry timestamp, reconstruct the remaining delay when reading:","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"// on 429:\nRedis::setex($cacheKey, $retryAfter, (string) (time() + $retryAfter));\n\n// in the cached-read branch:\n$remaining = max(1, (int) $cachedRetryAfter - time());\nthrow new RateLimitException('Hubspot rate limit (cached circuit-breaker)', $remaining);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 2 — retryUntil() window may expire under sustained rate limiting","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 2 —","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"retryUntil()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"window may expire under sustained rate limiting","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"MatchActivityCrmData.php:45-47","depth":23,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"MatchActivityCrmData.php:45-47","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"retryUntil()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is calculated as","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"now() + 30 min","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"at","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"dispatch time","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"and stored in the job payload. With","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MAX_RETRY_DELAY = 600s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":", you only get ~3 rate-limit-triggered releases before the 30-minute window closes and the job is permanently failed. For a heavy 429 spike (daily limit, parsed as 600s), this can easily happen.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Consider increasing","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RETRY_WINDOW_MINUTES","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"to 60 or more, or making it configurable. The","depth":23,"on_screen":false,"role_description":"text"}]...
|
5821602138596927486
|
1545250672083509827
|
idle
|
accessibility
|
NULL
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G) - 17 pending changes
17
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Testing
Claude Code
SOURCE CONTROL
SOURCE CONTROL
Changes Section
CHANGES
CHANGES
Commit Changes on "JY-20725-handle-HS-search-rate-limit"
Commit
More Actions...
Changes
17
.env.local
M
.env.nikilocal
U
.env.other
U
ids.txt
U
raw_sql_query.sql
U
WEBHOOK_FILTERING_IMPLEMENTATION.md
U
JiminnyDebugCommand.php
app/Console/Commands
M
SimulateWebhooksCommand.php
app/Console/Commands/Crm/Hubspot
U
CreateMockAskJiminnyReportResultCommand.php
app/Console/Commands/Reports
U
RateLimitException.php
app/Exceptions
M
MatchActivityCrmData.php
app/Jobs/Crm
M
Client.php
app/Services/Crm/Hubspot
9, M
logging.php
config
M
favicon.ico
public
U
HandleHubspotRateLimitTest.php
tests/Unit/Jobs/Middleware
M
CanAccessAiReportsTest.php
tests/Unit/Policies
U
ClientTest.php
tests/Unit/Services/Crm/Hubspot
M
Graph Section
GRAPH
GRAPH
JY-20725 add more tests
Lukas Kovalik
JY-20725-handle-HS-search-rate-limit
JY-20725 add tests
Lukas Kovalik
JY-20725 add HS rate limit handling on activities rematching
Lukas Kovalik
Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
origin/master
Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit
mihailmihaylovjiminny
JY-20819: Increase download transcription rate limit
mihail.mihaylov
JY-20818 move ask jiminny reports to its own datadog metric
Lukas Kovalik
Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge branch 'master' into JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge pull request #12047 from jiminny/JY-20662-remove-word-boost
ilian-jiminny
JY-20662 return void
ilian
JY-20662 Removed word_boost parameter from Assembly transcription request
ilian
JY-20817: Fix deleting old tracks
mihail.mihaylov
Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge branch 'master' into JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update
ilian-jiminny
JY-19938 Added consent information and recording error reason to Elastic Search mapping
ilian
JY-19938 reorder array map
ilian
JY-19938: Address PR review comments
ilian
JY-19938 lint fixes
ilian
HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1
ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1
Client.php (Working Tree) (Client.php), Editor Group 1
MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1
RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1
Review rate limit handli…, Editor Group 2
remote
app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...
JY-20725-handle-HS-search-rate-limit*
app (Git) - Synchronize Changes
Errors: 6, Warnings: 11, Infos: 28
6
11
28
Notifications
key, PHP extension: Premium features not active.
8.3, Language Level: 8.3
Sign In
Sign In
PHP
LF
UTF-8
Spaces: 4
Ln 120, Col 1
git-commit Not Committed Yet
Not Committed Yet
Diff editor
Clear
Review rate limit handling for HubSpot job queue
Session history
New session
Message actions
RateLimitException.php#9-9
RateLimitException.php#9-9
Reveiw the diff. Mostly
@app/Jobs/Crm/MatchActivityCrmData.php
,
@app/Exceptions/RateLimitException.php
,
@app/Jobs/Middleware/HandleHubspotRateLimit.php
,
@app/Services/Crm/Hubspot/Client.php
and
@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php
. The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?
Thought for 5s
Thought for 5s
I'll review the implementation carefully. Let me first check how
RateLimitException
is used elsewhere and look at the test files and
PaginationState
.
Bash
Find all usages of RateLimitException outside the changed files
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/app --include="*.php" | grep -v "HandleHubspotRateLimit\|MatchActivityCrmData\|Client.php\|RateLimitException.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is
Read
PaginationState.php
PaginationState.php
Read
HandleHubspotRateLimitTest.php
HandleHubspotRateLimitTest.php
Bash
Check how RateLimitException is used in GenerateActionItemsJob
IN
grep -rn "RateLimitException\|LogicException\|RuntimeException" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
Bash
Find all catch blocks or instanceof checks for RateLimitException
IN
grep -rn "catch.*RateLimitException\|instanceof RateLimitException" /Users/lukas/jiminny/app/app --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {
Bash
Find all test references to RateLimitException
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/tests --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);
Bash
Check retry/failure handling in GenerateActionItemsJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30
Copy code to clipboard
OUT
13:use Jiminny\Exceptions\RateLimitException;
51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
56: $this->release(30);
Bash
Check retry/failure handling in GenerateAiCallScoringJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30
Copy code to clipboard
OUT
17:use Jiminny\Exceptions\RateLimitException;
68: RateLimitException |
75: $this->release(30);
Bash
Check retry config in MatchActivityCrmData
IN
grep -n "retryAfter\|RETRY_WINDOW\|retryUntil\|maxTries" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php
Copy code to clipboard
OUT
34: private const int RETRY_WINDOW_MINUTES = 30;
45: public function retryUntil(): \DateTimeInterface
47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);
77: return self::RETRY_WINDOW_MINUTES * 60 + 60;
Bash
Check the cache key format
IN
grep -n "getRateLimitCacheKey\|hubspot:ratelimit" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Copy code to clipboard
OUT
78: $cacheKey = $this->getRateLimitCacheKey();
110: private function getRateLimitCacheKey(): string
112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());
Bash
Find other jobs that might use the rate limit middleware
IN
grep -rn "class.*Middleware\|HandleHubspotRateLimit" /Users/lukas/jiminny/app/app/Jobs --include="*.php" | grep -v "HandleHubspotRateLimit.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];
Read
HubspotClientInterface.php
HubspotClientInterface.php
Thought for 73s
Thought for 73s
Now I have a complete picture. Here's my full analysis:
Review Summary
Review Summary
RuntimeException vs LogicException — Safe ✓
RuntimeException
vs
LogicException
— Safe ✓
The change is semantically correct and won't break anything.
LogicException
is for programmer errors (invalid arguments, contract violations). A rate limit from an external API is a runtime condition —
RuntimeException
is the right parent. All callers catch
RateLimitException
by name explicitly (union catches in
GenerateActionItemsJob
,
GenerateAiCallScoringJob
,
SummarizeTranscriptionJob
,
GenerateAiActivityTypeJob
) or check
instanceof RateLimitException
— none relies on the parent class type, so nothing breaks.
Issue 1 — Circuit breaker stores stale delay (most important bug)
Issue 1 — Circuit breaker stores stale delay (most important bug)
Client.php:94
Client.php:94
Copy code to clipboard
Redis::setex($cacheKey, $retryAfter, (string) $retryAfter);
The TTL is
$retryAfter
seconds, but the
stored value
is also the original
$retryAfter
. If a second job reads this cache 20 seconds later, it throws
RateLimitException(retryAfter=30)
and gets delayed 30 more seconds — even though only 10 remain. In a spike of 100 jobs, each subsequent job inflates its wait.
Fix:
store the absolute expiry timestamp, reconstruct the remaining delay when reading:
Copy code to clipboard
// on 429:
Redis::setex($cacheKey, $retryAfter, (string) (time() + $retryAfter));
// in the cached-read branch:
$remaining = max(1, (int) $cachedRetryAfter - time());
throw new RateLimitException('Hubspot rate limit (cached circuit-breaker)', $remaining);
Issue 2 — retryUntil() window may expire under sustained rate limiting
Issue 2 —
retryUntil()
window may expire under sustained rate limiting
MatchActivityCrmData.php:45-47
MatchActivityCrmData.php:45-47
retryUntil()
is calculated as
now() + 30 min
at
dispatch time
and stored in the job payload. With
MAX_RETRY_DELAY = 600s
, you only get ~3 rate-limit-triggered releases before the 30-minute window closes and the job is permanently failed. For a heavy 429 spike (daily limit, parsed as 600s), this can easily happen.
Consider increasing
RETRY_WINDOW_MINUTES
to 60 or more, or making it configurable. The...
|
NULL
|
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/ /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php...
|
NULL
|
NULL
|
|
20317
|
878
|
0
|
2026-05-11T15:15:51.535520+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778512551535_m1.jpg...
|
Code
|
Client.php (Working Tree) (Client.php) — app — 9 p Client.php (Working Tree) (Client.php) — app — 9 problems in this file • Modified...
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Explorer (⇧⌘E)","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Search (⇧⌘F)","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Source Control (⌃⇧G)","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":true},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Run and Debug (⇧⌘D)","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Remote Explorer","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Extensions (⇧⌘X) - 2 require update","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-2301639976708540590
|
-2992618173708598900
|
click
|
hybrid
|
NULL
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
iTerm2ShellEditViewSessionScriptsProfilesWindowHelpAPP (-zsh)-zsh++DOCKER-232,9 +235,11₴81DEV (docker)₴2APP (-zsh)84* eparam string SobjectType The object type ('deals', 'companies', 'contacts', 'calls')* @param array<string, mixed› Spayload The search payload with filters, sorts, properties, etc.ereturn array Thesearch response with'results'total', 'paging'keys* @throws RateLimitException When rate limit is hit* @throws HubspotException On API errors* @return array The search response with 'results', 'total','paging' keyspublic function search(string SobjectType, array Spayload): array-zsh>0 ll*5screenpipe"100% <78• Mon 11 May 18:15:51181€ 886-zsh*7 |+APPend diff4) app/Console/Commands/JiminnyDebugCommand.php (statement_indentation)begin diff--- /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php+++/home/jiminny/app/Console/Commands/JiminnyDebugCommand.php-359,11 +359,11 @ScrmService = ScrmResolver->prepareCrmService();-//-//for (Si = 0; si < 3; Si++) {if (Si%25 ===0) {Sthis->info("Syncing opportunity {$1}");Sthis->info("Matching contact {$i}");-//-//.ScrmService->syncOpportunity('374720564');if ($i % 25 === 0) {Sthis->info("Syncing opportunity {$1}");Sthis->info("Matching contact {Si}");////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, persistent debugging tools in any container or image → docker debug docker_lamp_1...
|
20316
|
NULL
|
NULL
|
NULL
|
|
20318
|
879
|
0
|
2026-05-11T15:15:51.571868+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778512551571_m2.jpg...
|
Code
|
Client.php (Working Tree) (Client.php) — app — 9 p Client.php (Working Tree) (Client.php) — app — 9 problems in this file • Modified...
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
*selectionWindovSOURCE CONTROw Clienttest.oho (Wor *selectionWindovSOURCE CONTROw Clienttest.oho (Workina Tree M17 Client.php (Working Tree) 9, M Xm MatchActivitvermData.pho (Workina Tree Mi RateLimitException.oho (Workina Tree)V CHANGESapp > Services > Crm > Hubspot › « Client.php > a Client › © getRateLimitCacheKey()class Client extends Baseclient 1mplements HubspotCllentintertacey Changes• .env.local• .env.nikilocal@ .env.otherE ids.txtraw sal_query.sq* WEBHOOK_FILTERING_IMPLEMENT...# JiminnyDebugCommand.php app/Cl** SimulateWebhooksCommand.php a..." CreateMockAskJiminnvReportResult..1* RateLimitException.php app/Except...w MatchActivitvCrmData.oho app/Jobs... M1* Client.php app/Services... U 9 + 9, Moadina.nno confid* favicon.ico public** Handle-lubsootRateLimitTest.oho t...** CanAccessAiReportsTest.php tests/...** ClientTest oho tests/Unit/Services/Cr.V GRAPHAuto O y vTO JY-20725 add ...• JY-20725 add tests Lukas Kovali!•JY-20725 add HS rate limit handling on a..Merae oulll request #12.Merae branch 'master' into .IY-20818..Merae oull reauest #12057 from limin….JY.20818 move ask liminnv renorts to..Merge pull request #12052 from jimin...Morao hranch Imactorl into.IV.20917Merge pull request #12047 from jimin...A IV.20662 roturn woid flier• JY-20662 Removed word_boost para...JY-20817: Fix deleting old tracks mih...Merge pull request #12043 from jimin...Merge branch 'master' into JY-19938.Merge pull request #12044 from Jimin…y 10000 Addod cancont informatinnJy-19938 reorder arrav mao ilianJY-19938: Address PR review commeprivate tunction executeRequest callable Sapicallsthis->log->warning(' [Hubspot] Received 429 from API', I"conT19.10= Sch1s→>cont10→>gec.d),'retry after' => SretrvAtter.throw new RateLimitException('Hubspot returned 429', $retryAfprivate function getRateLimitCachekey(): stringreturn sprintf('hubspot:ratelimit:portal:%d', sthis->config->getId())public tunction 1sHubspotRateL1m1t Throwable se: boolif Se instanceof BadRequest11 se instanceof DealAniExcention1l se inctancenf ContactAniFycentionIl se instanceof \GuzzleHttp\Exception\RequestExceptionreturn (1nt se->qetcode === 429:return false.public function parseRetryAfter(Throwable Se): intif (method_exists($e,'getResponseheaders')Sheaders = se->aetResoonsereaderso7:ASvalue = Sheaders ('Retrv-After'] 2? Sheaders ('retry-after'] 22 nlSifis arravsvallue)Svalue = svaluel0l 22 nulleif (ic numeric(<valne)) !Smessane = strtolower(Se-saetMessade0)):if (str_contains($message,'daily')) {1t str contains smessage.'ten secondly'"){if (str contains(Smessage, "secondly') ^return 1Sthis->loq->warning(* (Hubspot) No retry-after header or known messageJY-19938 lint fixes ilian?8 JY-20725-harlle-HS-search-rate-limit* (@6 A 110 28class Client extends Baseclient 1mplements Hubspotclientintertace16b107122123-124112511401141|142|143144157|158150|160sthis->log->warning(' [Hubspot] Received 429 from API', ILean1o→ sches→>contlo-ream10,'config id'= Sch1s→>cont1g→>getla0),retry after' = SretryAtter.= Se->getMessageonthrow new RateLimitException('Hubspot returned 429', SretryAfter,throw se:private function getRateLimitCacheKey(): stringreturh sprinut nuospot: rateuimic: contig.0, schis→>contig-gecto,orivate tunction 1sHubspotRateL1m1t Throwable se: boouif Se instanceof BadRequest11 Se instanceof DealAniExcention11 ce inctancenf ContactAniFycentionreturn (1nt se->getcode === 429:return false;nrivate function narceRetrvAfter(Throwahle &e)• intif (method_exists(se,'getResponseHeaders")) €sheaders = se->aetResoonsereadersoiSvalue = Sheaders" "Retrv-After" ?? Sheaders "'retrv-after' 72 null.sfais arrav(svalue)4svalue = svalue al 22 nulleif (is numeric(Svalue)) {Smessage = strtolower(Se-saetMessage0):if (str_contains($message, 'daily')) ‹recurn b00.1t str contains Smessage, "ten secondlv') ‹str contains(Smessade. "secondlv')<return 1.Sthis->loq->warning('(Hubspot) No retry-after header or known message, us:$0 lil100% L2Mon 11 May 18:15:51• 2 C23 Review rate limit handli..Review rate limit handling for HubSpot jo...Here are code reveiw suggestions: Issues'expecteaMin' → 36,• Now run all affected tests to verify everything passes:Bash Run all affected teststests/Unit/Services/Crm/Hubspot/Pagination/HubspotPaginationServiceTest.php \nlCommand.aunniingin_hackaround witth_T0hhugaz8vk.Ontoutk.heina_waltten tor/naivate//tmo//alaude-501V/2lsersmkas-Tminnv-ann//cae8117bd-3c48-4c62_h226-2fa1d87Af2e4d/Ztasks/hu9azRvk..om• Bash Check test outoutcat /private/tmp/claude-501/-Users-lukas-jiminny-app/cae817bd-3c48-4c63-b336-2f1d874f2e4d/tasks/bhu9az8vk.output 2>/dev/null | tail -30OUT PHPUnit 11.5.55 by Sebastian Bergmann and contributors.PHP 8.3.30• Thouaht for 2c )• All 120 tests pass. Here's a summary of every change made to address the review feedback:Issue 1 - Batch API calls bypass rate-limit handlingClient nho:?05-021-hatchRead0htleatc/Anow.wrans the ShatchConfintLaniWsread/luucalllinsideexeantteReatiest/lso./120c.from.batch.endnoints.are.cauaht.hvathe.same.circuitahreakerlodicAdedicated catch (RateLimitException se) ( throw Se; } before the general catch ensures the rate-limit exception propagates to the job middleware instead of being swallowed by handleBatchError ()and re-wrapped as ermexceptzon .Issue 2 - Misleading cache key nameClient.php:120 - Renamed hubspot:ratelimit:portal:%d → hubspot:ratelimit:config:&d . The key was already scoped to the config record ID (not a HubSpot portal ID); the name now matches theintent. The correspondina strina in CuientTest.oho was vodated to match.Issue 3 - isHubspotRateLimit() and parseRetryAfter() visibilityCalllentanhow/kWky-IBoth methods changedIfrom.nuhGtto.navateNeitherlisinthelnterface.andinofhing.outsidelexccutcRemuest70calstthem.the direct&testsiin.dbientalesia.nhn_weretuodateditoIssue o — Test constants duplicated trom production codeWondloEmhenatPatalfmitTestnhn.Pemoved.the.three.nrivate.constants/IMAXRETRYDELAYDUIMTNRETRY_DEWAYOIUITER_SFCONDSI.andlinlinedIthe concretelboundary.values.with commentsie.g'expectedMax' => 605, // 600 + 5 jitter ). If the middleware constants change, these tests will now fail rather than silently pass against stale expected valuesIccue 6 - Douhle-clamnina in Patol Ini+EycontionRateLimitException.php - The clamping moved to the constructor ( $this->retryAfter =• max(1, SretryAfter) ). The property is no longer a promoted parameter (which would prevent assignment in theconstructor bodv. getRetrvAftero is now a simole return. The external behaviour is identical, confirmed by the existing test suiteIssue 7 - Non-obvious RateLimitException path in failed()MatchActivitvCrmData.oho:172 - Added a three-line coent explainina that RateLimitExcention oniv reaches failedo when retrvuntilo exoires while the iob is still held in the released state.because the middleware calls release() rather than re-throwing.9 Ece to focuc or unfocuc Claude+00' Client.ohoAsk before edit.0 Not Committed Yet Ln 120, Col 1Spaces: 4 UTF-8 LF PHP 8 Signin 8.3 g...
|
NULL
|
-507992123674058757
|
NULL
|
click
|
ocr
|
NULL
|
*selectionWindovSOURCE CONTROw Clienttest.oho (Wor *selectionWindovSOURCE CONTROw Clienttest.oho (Workina Tree M17 Client.php (Working Tree) 9, M Xm MatchActivitvermData.pho (Workina Tree Mi RateLimitException.oho (Workina Tree)V CHANGESapp > Services > Crm > Hubspot › « Client.php > a Client › © getRateLimitCacheKey()class Client extends Baseclient 1mplements HubspotCllentintertacey Changes• .env.local• .env.nikilocal@ .env.otherE ids.txtraw sal_query.sq* WEBHOOK_FILTERING_IMPLEMENT...# JiminnyDebugCommand.php app/Cl** SimulateWebhooksCommand.php a..." CreateMockAskJiminnvReportResult..1* RateLimitException.php app/Except...w MatchActivitvCrmData.oho app/Jobs... M1* Client.php app/Services... U 9 + 9, Moadina.nno confid* favicon.ico public** Handle-lubsootRateLimitTest.oho t...** CanAccessAiReportsTest.php tests/...** ClientTest oho tests/Unit/Services/Cr.V GRAPHAuto O y vTO JY-20725 add ...• JY-20725 add tests Lukas Kovali!•JY-20725 add HS rate limit handling on a..Merae oulll request #12.Merae branch 'master' into .IY-20818..Merae oull reauest #12057 from limin….JY.20818 move ask liminnv renorts to..Merge pull request #12052 from jimin...Morao hranch Imactorl into.IV.20917Merge pull request #12047 from jimin...A IV.20662 roturn woid flier• JY-20662 Removed word_boost para...JY-20817: Fix deleting old tracks mih...Merge pull request #12043 from jimin...Merge branch 'master' into JY-19938.Merge pull request #12044 from Jimin…y 10000 Addod cancont informatinnJy-19938 reorder arrav mao ilianJY-19938: Address PR review commeprivate tunction executeRequest callable Sapicallsthis->log->warning(' [Hubspot] Received 429 from API', I"conT19.10= Sch1s→>cont10→>gec.d),'retry after' => SretrvAtter.throw new RateLimitException('Hubspot returned 429', $retryAfprivate function getRateLimitCachekey(): stringreturn sprintf('hubspot:ratelimit:portal:%d', sthis->config->getId())public tunction 1sHubspotRateL1m1t Throwable se: boolif Se instanceof BadRequest11 se instanceof DealAniExcention1l se inctancenf ContactAniFycentionIl se instanceof \GuzzleHttp\Exception\RequestExceptionreturn (1nt se->qetcode === 429:return false.public function parseRetryAfter(Throwable Se): intif (method_exists($e,'getResponseheaders')Sheaders = se->aetResoonsereaderso7:ASvalue = Sheaders ('Retrv-After'] 2? Sheaders ('retry-after'] 22 nlSifis arravsvallue)Svalue = svaluel0l 22 nulleif (ic numeric(<valne)) !Smessane = strtolower(Se-saetMessade0)):if (str_contains($message,'daily')) {1t str contains smessage.'ten secondly'"){if (str contains(Smessage, "secondly') ^return 1Sthis->loq->warning(* (Hubspot) No retry-after header or known messageJY-19938 lint fixes ilian?8 JY-20725-harlle-HS-search-rate-limit* (@6 A 110 28class Client extends Baseclient 1mplements Hubspotclientintertace16b107122123-124112511401141|142|143144157|158150|160sthis->log->warning(' [Hubspot] Received 429 from API', ILean1o→ sches→>contlo-ream10,'config id'= Sch1s→>cont1g→>getla0),retry after' = SretryAtter.= Se->getMessageonthrow new RateLimitException('Hubspot returned 429', SretryAfter,throw se:private function getRateLimitCacheKey(): stringreturh sprinut nuospot: rateuimic: contig.0, schis→>contig-gecto,orivate tunction 1sHubspotRateL1m1t Throwable se: boouif Se instanceof BadRequest11 Se instanceof DealAniExcention11 ce inctancenf ContactAniFycentionreturn (1nt se->getcode === 429:return false;nrivate function narceRetrvAfter(Throwahle &e)• intif (method_exists(se,'getResponseHeaders")) €sheaders = se->aetResoonsereadersoiSvalue = Sheaders" "Retrv-After" ?? Sheaders "'retrv-after' 72 null.sfais arrav(svalue)4svalue = svalue al 22 nulleif (is numeric(Svalue)) {Smessage = strtolower(Se-saetMessage0):if (str_contains($message, 'daily')) ‹recurn b00.1t str contains Smessage, "ten secondlv') ‹str contains(Smessade. "secondlv')<return 1.Sthis->loq->warning('(Hubspot) No retry-after header or known message, us:$0 lil100% L2Mon 11 May 18:15:51• 2 C23 Review rate limit handli..Review rate limit handling for HubSpot jo...Here are code reveiw suggestions: Issues'expecteaMin' → 36,• Now run all affected tests to verify everything passes:Bash Run all affected teststests/Unit/Services/Crm/Hubspot/Pagination/HubspotPaginationServiceTest.php \nlCommand.aunniingin_hackaround witth_T0hhugaz8vk.Ontoutk.heina_waltten tor/naivate//tmo//alaude-501V/2lsersmkas-Tminnv-ann//cae8117bd-3c48-4c62_h226-2fa1d87Af2e4d/Ztasks/hu9azRvk..om• Bash Check test outoutcat /private/tmp/claude-501/-Users-lukas-jiminny-app/cae817bd-3c48-4c63-b336-2f1d874f2e4d/tasks/bhu9az8vk.output 2>/dev/null | tail -30OUT PHPUnit 11.5.55 by Sebastian Bergmann and contributors.PHP 8.3.30• Thouaht for 2c )• All 120 tests pass. Here's a summary of every change made to address the review feedback:Issue 1 - Batch API calls bypass rate-limit handlingClient nho:?05-021-hatchRead0htleatc/Anow.wrans the ShatchConfintLaniWsread/luucalllinsideexeantteReatiest/lso./120c.from.batch.endnoints.are.cauaht.hvathe.same.circuitahreakerlodicAdedicated catch (RateLimitException se) ( throw Se; } before the general catch ensures the rate-limit exception propagates to the job middleware instead of being swallowed by handleBatchError ()and re-wrapped as ermexceptzon .Issue 2 - Misleading cache key nameClient.php:120 - Renamed hubspot:ratelimit:portal:%d → hubspot:ratelimit:config:&d . The key was already scoped to the config record ID (not a HubSpot portal ID); the name now matches theintent. The correspondina strina in CuientTest.oho was vodated to match.Issue 3 - isHubspotRateLimit() and parseRetryAfter() visibilityCalllentanhow/kWky-IBoth methods changedIfrom.nuhGtto.navateNeitherlisinthelnterface.andinofhing.outsidelexccutcRemuest70calstthem.the direct&testsiin.dbientalesia.nhn_weretuodateditoIssue o — Test constants duplicated trom production codeWondloEmhenatPatalfmitTestnhn.Pemoved.the.three.nrivate.constants/IMAXRETRYDELAYDUIMTNRETRY_DEWAYOIUITER_SFCONDSI.andlinlinedIthe concretelboundary.values.with commentsie.g'expectedMax' => 605, // 600 + 5 jitter ). If the middleware constants change, these tests will now fail rather than silently pass against stale expected valuesIccue 6 - Douhle-clamnina in Patol Ini+EycontionRateLimitException.php - The clamping moved to the constructor ( $this->retryAfter =• max(1, SretryAfter) ). The property is no longer a promoted parameter (which would prevent assignment in theconstructor bodv. getRetrvAftero is now a simole return. The external behaviour is identical, confirmed by the existing test suiteIssue 7 - Non-obvious RateLimitException path in failed()MatchActivitvCrmData.oho:172 - Added a three-line coent explainina that RateLimitExcention oniv reaches failedo when retrvuntilo exoires while the iob is still held in the released state.because the middleware calls release() rather than re-throwing.9 Ece to focuc or unfocuc Claude+00' Client.ohoAsk before edit.0 Not Committed Yet Ln 120, Col 1Spaces: 4 UTF-8 LF PHP 8 Signin 8.3 g...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20357
|
NULL
|
0
|
2026-05-11T15:19:08.832131+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778512748832_m1.jpg...
|
Code
|
Review rate limit handli… — app
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G) - 17 pending changes
17
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Testing
Claude Code
SOURCE CONTROL
SOURCE CONTROL
Changes Section
CHANGES
CHANGES
Commit Changes on "JY-20725-handle-HS-search-rate-limit"
Commit
More Actions...
Changes
17
.env.local
M
.env.nikilocal
U
.env.other
U
ids.txt
U
raw_sql_query.sql
U
WEBHOOK_FILTERING_IMPLEMENTATION.md
U
JiminnyDebugCommand.php
app/Console/Commands
M
...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Explorer (⇧⌘E)","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Search (⇧⌘F)","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Source Control (⌃⇧G) - 17 pending changes","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":true},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"17","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Run and Debug (⇧⌘D)","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Remote Explorer","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Extensions (⇧⌘X) - 2 require update","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"2","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Testing","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Claude Code","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"SOURCE CONTROL","depth":17,"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"SOURCE CONTROL","depth":18,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Changes Section","depth":21,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"CHANGES","depth":22,"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"CHANGES","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"","depth":28,"on_screen":true,"value":"","role_description":"editor","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":29,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Commit Changes on \"JY-20725-handle-HS-search-rate-limit\"","depth":26,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Commit","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"More Actions...","depth":26,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Changes","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"17","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.local","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.nikilocal","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.other","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ids.txt","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"raw_sql_query.sql","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"WEBHOOK_FILTERING_IMPLEMENTATION.md","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JiminnyDebugCommand.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"}]...
|
7270647419753934729
|
-289603022338006068
|
click
|
accessibility
|
NULL
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G) - 17 pending changes
17
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Testing
Claude Code
SOURCE CONTROL
SOURCE CONTROL
Changes Section
CHANGES
CHANGES
Commit Changes on "JY-20725-handle-HS-search-rate-limit"
Commit
More Actions...
Changes
17
.env.local
M
.env.nikilocal
U
.env.other
U
ids.txt
U
raw_sql_query.sql
U
WEBHOOK_FILTERING_IMPLEMENTATION.md
U
JiminnyDebugCommand.php
app/Console/Commands
M
...
|
20355
|
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivit /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php...
|
NULL
|
NULL
|
|
20360
|
NULL
|
0
|
2026-05-11T15:19:25.305116+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778512765305_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...
|
True
|
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 2 hours ago
LakyLak
LakyLak
commented
2 hours ago
2 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
2 hours ago
2 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 1 hour ago
sonarqubecloud
sonarqubecloud
Bot
commented
1 hour ago
1 hour 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 1 hour ago
LakyLak
LakyLak
commented
1 hour ago
1 hour ago
@claude
@claude
Add or remove reactions
react with eyes
👀
1
@claude
Show options
claude Bot commented 1 hour ago •
claude
claude
Bot
commented
1 hour ago
1 hour 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:...
|
[{"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":"Awaiting approval","depth":13,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Awaiting approval","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 3 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 (3)","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":"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":"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 3 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.21060506,"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 3 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.018284574,"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.014295213,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"from","depth":19,"bounds":{"left":0.21609043,"top":0.08339984,"width":0.00880984,"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.090259306,"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.086269945,"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.31781915,"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 2 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":"2 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":"2 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":"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":"@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 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 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 1 hour 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":"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":"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 1 hour 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":"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":"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},{"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,"bounds":{"left":0.114527926,"top":0.0,"width":0.063663565,"height":0.013567438},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"executeRequest()","depth":19,"bounds":{"left":0.17985372,"top":0.0,"width":0.038065158,"height":0.011572227},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":18,"bounds":{"left":0.21941489,"top":0.0,"width":0.0028257978,"height":0.013567438},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"isHubspotRateLimit()","depth":19,"bounds":{"left":0.2237367,"top":0.0,"width":0.047706116,"height":0.011572227},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":18,"bounds":{"left":0.27310506,"top":0.0,"width":0.0026595744,"height":0.013567438},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"parseRetryAfter()","depth":19,"bounds":{"left":0.27742687,"top":0.0,"width":0.040392287,"height":0.011572227},"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,"bounds":{"left":0.114527926,"top":0.0,"width":0.24883644,"height":0.030327214},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
1752928962895652553
|
7361010385068798340
|
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 2 hours ago
LakyLak
LakyLak
commented
2 hours ago
2 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
2 hours ago
2 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 1 hour ago
sonarqubecloud
sonarqubecloud
Bot
commented
1 hour ago
1 hour 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 1 hour ago
LakyLak
LakyLak
commented
1 hour ago
1 hour ago
@claude
@claude
Add or remove reactions
react with eyes
👀
1
@claude
Show options
claude Bot commented 1 hour ago •
claude
claude
Bot
commented
1 hour ago
1 hour 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:...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20361
|
881
|
0
|
2026-05-11T15:19:31.366553+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778512771366_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...
|
True
|
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 2 hours ago
LakyLak
LakyLak
commented
2 hours ago
2 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
2 hours ago
2 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 1 hour ago
sonarqubecloud
sonarqubecloud
Bot
commented
1 hour ago
1 hour 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 1 hour ago
LakyLak
LakyLak
commented
1 hour ago
1 hour ago
@claude
@claude
Add or remove reactions
react with eyes
👀
1
@claude
Show options
claude Bot commented 1 hour ago •
claude
claude
Bot
commented
1 hour ago
1 hour 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 32 minutes ago
sonarqubecloud
sonarqubecloud
Bot
commented
32 minutes ago
32 minutes 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
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.
All checks have passed
All checks have passed
12 successful checks
Collapse checks
successful checks
successful checks
build_accept_deploy
build_accept_deploy
build_accept_deploy
Successful in 14m
— Workflow: build_accept_deploy
More actions
ci/circleci: build-backend
ci/circleci: build-backend
ci/circleci: build-backend
— Your tests passed on CircleCI!
More actions
ci/circleci: build-frontend
ci/circleci: build-frontend
ci/circleci: build-frontend
— Your tests passed on CircleCI!
More actions
ci/circleci: checkout-code
ci/circleci: checkout-code
ci/circleci: checkout-code
— Your tests passed on CircleCI!
More actions
ci/circleci: phpstan
ci/circleci: phpstan
ci/circleci: phpstan
— Your tests passed on CircleCI!
More actions
ci/circleci: setup
ci/circleci: setup
ci/circleci: setup
— Your tests passed on CircleCI!
More actions
ci/circleci: sonar_cloud
ci/circleci: sonar_cloud
ci/circleci: sonar_cloud
— Your tests passed on CircleCI!
More actions
ci/circleci: test
ci/circleci: test
ci/circleci: test
— Your tests passed on CircleCI!
More actions
ci/circleci: test-backend-lint
ci/circleci: test-backend-lint
ci/circleci: test-backend-lint...
|
[{"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":"Awaiting approval","depth":13,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Awaiting approval","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 3 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 (3)","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":"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":"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 3 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.21060506,"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 3 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.018284574,"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.014295213,"height":0.011572227},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"from","depth":19,"bounds":{"left":0.21609043,"top":0.08339984,"width":0.00880984,"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.090259306,"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.086269945,"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.31781915,"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 2 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":"2 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":"2 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":"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":"@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 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 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 1 hour 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":"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":"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 1 hour 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":"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":"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,"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":"Gathering context","depth":18,"bounds":{"left":0.12250665,"top":0.0,"width":0.039893616,"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":"Reading changed files","depth":18,"bounds":{"left":0.12250665,"top":0.0,"width":0.04886968,"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":"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.03790902,"width":0.25182846,"height":0.030327214},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Issues","depth":16,"bounds":{"left":0.114527926,"top":0.11093376,"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.111332804,"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.14285715,"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.17438148,"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.17238627,"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.17438148,"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.17238627,"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.19114126,"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.18914606,"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.20790103,"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.20590582,"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.24780527,"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.26137272,"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.26137272,"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.26137272,"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.26137272,"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.26137272,"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.26137272,"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.26137272,"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.26137272,"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.26137272,"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.26137272,"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.26137272,"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.26137272,"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.26137272,"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.26137272,"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.26137272,"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.24022347,"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.30127692,"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.3367917,"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.3347965,"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.3643256,"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.3643256,"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.42218676,"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.4537111,"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.4517159,"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.4537111,"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.4517159,"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.4537111,"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.4517159,"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.4537111,"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.46847567,"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.46847567,"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.46847567,"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.53152436,"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.59018356,"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.603751,"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.603751,"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.603751,"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.603751,"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.603751,"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.603751,"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.603751,"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.603751,"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.603751,"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.603751,"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.603751,"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.603751,"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.603751,"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.603751,"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.5826017,"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.67198724,"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.67398244,"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.67198724,"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.67398244,"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.67198724,"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.67398244,"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.67198724,"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.7015164,"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.7035116,"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.7015164,"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.7035116,"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.7015164,"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.7035116,"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.7015164,"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.72027135,"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.71827614,"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.72027135,"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.71827614,"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.7370311,"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.7350359,"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.7370311,"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.7350359,"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.8096568,"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.81165206,"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.8096568,"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.81165206,"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.8096568,"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.81165206,"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.83918595,"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.88747007,"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.88547486,"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.88747007,"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.88547486,"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.92378294,"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.92178774,"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.94373506,"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.9417398,"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.94373506,"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.9417398,"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.94373506,"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.9417398,"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.9880287,"width":0.15109707,"height":0.011971295},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Minor observations","depth":16,"bounds":{"left":0.114527926,"top":1.0,"width":0.2521609,"height":-0.044293642},"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Minor observations","depth":17,"bounds":{"left":0.114527926,"top":1.0,"width":0.052526597,"height":-0.044692755},"on_screen":false,"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.076217055},"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,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"MIN_RETRY_DELAY = 1","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", and","depth":17,"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,"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 32 minutes 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":"32 minutes 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":"32 minutes 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":"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":"All checks have passed","depth":13,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"All checks have passed","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"12 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":"AXHeading","text":"successful checks","depth":19,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"successful checks","depth":20,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"build_accept_deploy","depth":22,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"build_accept_deploy","depth":23,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"build_accept_deploy","depth":24,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Successful in 14m","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"— Workflow: build_accept_deploy","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-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":"— Your tests passed on CircleCI!","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":"— Your tests passed on CircleCI!","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: checkout-code","depth":22,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"ci/circleci: checkout-code","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: checkout-code","depth":24,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"— Your tests passed on CircleCI!","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: phpstan","depth":22,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"ci/circleci: phpstan","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: phpstan","depth":24,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"— Your tests passed on CircleCI!","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: setup","depth":22,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"ci/circleci: setup","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: setup","depth":24,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"— Your tests passed on CircleCI!","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: sonar_cloud","depth":22,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"ci/circleci: sonar_cloud","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: sonar_cloud","depth":24,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"— Your tests passed on CircleCI!","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: test","depth":22,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"ci/circleci: test","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: test","depth":24,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"— Your tests passed on CircleCI!","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: test-backend-lint","depth":22,"on_screen":false,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"ci/circleci: test-backend-lint","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: test-backend-lint","depth":24,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
-6757780872969020578
|
8504219381627926916
|
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 2 hours ago
LakyLak
LakyLak
commented
2 hours ago
2 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
2 hours ago
2 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 1 hour ago
sonarqubecloud
sonarqubecloud
Bot
commented
1 hour ago
1 hour 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 1 hour ago
LakyLak
LakyLak
commented
1 hour ago
1 hour ago
@claude
@claude
Add or remove reactions
react with eyes
👀
1
@claude
Show options
claude Bot commented 1 hour ago •
claude
claude
Bot
commented
1 hour ago
1 hour 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 32 minutes ago
sonarqubecloud
sonarqubecloud
Bot
commented
32 minutes ago
32 minutes 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
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.
All checks have passed
All checks have passed
12 successful checks
Collapse checks
successful checks
successful checks
build_accept_deploy
build_accept_deploy
build_accept_deploy
Successful in 14m
— Workflow: build_accept_deploy
More actions
ci/circleci: build-backend
ci/circleci: build-backend
ci/circleci: build-backend
— Your tests passed on CircleCI!
More actions
ci/circleci: build-frontend
ci/circleci: build-frontend
ci/circleci: build-frontend
— Your tests passed on CircleCI!
More actions
ci/circleci: checkout-code
ci/circleci: checkout-code
ci/circleci: checkout-code
— Your tests passed on CircleCI!
More actions
ci/circleci: phpstan
ci/circleci: phpstan
ci/circleci: phpstan
— Your tests passed on CircleCI!
More actions
ci/circleci: setup
ci/circleci: setup
ci/circleci: setup
— Your tests passed on CircleCI!
More actions
ci/circleci: sonar_cloud
ci/circleci: sonar_cloud
ci/circleci: sonar_cloud
— Your tests passed on CircleCI!
More actions
ci/circleci: test
ci/circleci: test
ci/circleci: test
— Your tests passed on CircleCI!
More actions
ci/circleci: test-backend-lint
ci/circleci: test-backend-lint
ci/circleci: test-backend-lint...
|
20360
|
NULL
|
NULL
|
NULL
|
|
20363
|
880
|
0
|
2026-05-11T15:19:39.658044+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778512779658_m1.jpg...
|
Code
|
MatchActivityCrmData.php — app — Modified
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G) - 17 pending changes
17
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Testing
Claude Code
SOURCE CONTROL
SOURCE CONTROL
Changes Section
CHANGES
CHANGES
Commit Changes on "JY-20725-handle-HS-search-rate-limit"
Commit
More Actions...
Changes
17
.env.local
M
.env.nikilocal
U
.env.other
U
ids.txt
U
raw_sql_query.sql
U
WEBHOOK_FILTERING_IMPLEMENTATION.md
U
JiminnyDebugCommand.php
app/Console/Commands
M
SimulateWebhooksCommand.php
app/Console/Commands/Crm/Hubspot
U
CreateMockAskJiminnyReportResultCommand.php
app/Console/Commands/Reports
U
RateLimitException.php
app/Exceptions
M
MatchActivityCrmData.php
app/Jobs/Crm
M
Client.php
app/Services/Crm/Hubspot
9, M
logging.php
config
M
favicon.ico
public
U
HandleHubspotRateLimitTest.php
tests/Unit/Jobs/Middleware
M
CanAccessAiReportsTest.php
tests/Unit/Policies
U
ClientTest.php
tests/Unit/Services/Crm/Hubspot
M
Graph Section
GRAPH
GRAPH
JY-20725 add more tests
Lukas Kovalik
JY-20725-handle-HS-search-rate-limit
JY-20725 add tests
Lukas Kovalik
JY-20725 add HS rate limit handling on activities rematching
Lukas Kovalik
Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
origin/master
Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit
mihailmihaylovjiminny
JY-20819: Increase download transcription rate limit
mihail.mihaylov
JY-20818 move ask jiminny reports to its own datadog metric
Lukas Kovalik
Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge branch 'master' into JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge pull request #12047 from jiminny/JY-20662-remove-word-boost
ilian-jiminny
JY-20662 return void
ilian
JY-20662 Removed word_boost parameter from Assembly transcription request
ilian
JY-20817: Fix deleting old tracks
mihail.mihaylov
Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge branch 'master' into JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update
ilian-jiminny
JY-19938 Added consent information and recording error reason to Elastic Search mapping
ilian
JY-19938 reorder array map
ilian
JY-19938: Address PR review comments
ilian
JY-19938 lint fixes
ilian
HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1
ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1
Client.php (Working Tree) (Client.php), Editor Group 1
MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1
RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1
MatchActivityCrmData.php, preview, Editor Group 1
<?php
declare(strict_types=1);
namespace Jiminny\Jobs\Crm;
use Exception;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Connection;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Jiminny\Component\Queue\Constants;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Jobs\Job;
use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
use Jiminny\Models\Activity;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Repositories\ActivityRepository;
use Jiminny\Services\Crm\CrmActivityService;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Throwable;
class MatchActivityCrmData extends Job implements ShouldQueue, ShouldBeUnique
{
use InteractsWithQueue;
use SerializesModels;
private const int RETRY_WINDOW_MINUTES = 120;
public int $maxExceptions = 3;
private int $activityId;
private ?Configuration $fromConfiguration;
private bool $remoteSearch;
public function __construct(
int $activityId,
?Configuration $fromConfiguration = null,
bool $remoteSearch = false,
) {
$this->activityId = $activityId;
$this->fromConfiguration = $fromConfiguration;
$this->remoteSearch = $remoteSearch;
$this->onQueue(Constants::QUEUE_ANALYTICS_LOW);
}
public function middleware(): array
{
return [new HandleHubspotRateLimit()];
}
public function retryUntil(): \DateTimeInterface
{
return now()->addMinutes(self::RETRY_WINDOW_MINUTES);
}
public function uniqueId(): string
{
$configId = $this->fromConfiguration?->getId() ?? 0;
$remote = $this->remoteSearch ? 'remote' : 'local';
return "$this->activityId:$configId:$remote";
}
public function timeout(): int
{
return 300; // 5 minutes max execution time
}
public function uniqueFor(): int
{
return self::RETRY_WINDOW_MINUTES * 60 + 60;
}
public function backoff(): array
{
return [30, 90, 180];
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
* @throws Exception|Throwable
*/
public function handle(
ActivityRepository $activityRepository,
CrmActivityService $crmActivityService,
Connection $connection,
): void {
$activity = $activityRepository->findById($this->activityId);
if ($activity === null) {
throw new InvalidArgumentException('[MatchActivityCrmData] Cannot find activity.');
}
try {
$connection->transaction(function () use ($activity, $crmActivityService, $activityRepository) {
Log::info('[MatchActivityCrmData] Starting CRM data matching', [
'activity' => $this->activityId,
'remote_search' => $this->remoteSearch,
'set_configuration' => $this->fromConfiguration?->getId(),
'old_state' => [
'lead_id' => $activity->getLead()?->getId(),
'contact_id' => $activity->getContact()?->getId(),
'account_id' => $activity->getAccount()?->getId(),
'opportunity_id' => $activity->getOpportunity()?->getId(),
'stage_id' => $activity->getStage()?->getId(),
],
]);
$this->resetCrmMappings($activity, $activityRepository);
$this->switchCrmConfigurationIfNeeded($activity);
$activity->refresh();
$crmActivityService->updateCrmData(
activity: $activity,
remoteSearch: $this->remoteSearch,
);
$hasMatch = $activity->getLead() !== null
|| $activity->getContact() !== null
|| $activity->getAccount() !== null
|| $activity->getOpportunity() !== null;
if ($hasMatch) {
Log::info('[MatchActivityCrmData] Successfully matched CRM data', [
'activity' => $this->activityId,
'remote_search' => $this->remoteSearch,
'lead_id' => $activity->getLead()?->getId(),
'contact_id' => $activity->getContact()?->getId(),
'account_id' => $activity->getAccount()?->getId(),
'opportunity_id' => $activity->getOpportunity()?->getId(),
'stage_id' => $activity->getStage()?->getId(),
]);
} else {
Log::info('[MatchActivityCrmData] No CRM match found', [
'activity' => $this->activityId,
'remote_search' => $this->remoteSearch,
]);
}
});
} catch (Throwable $e) {
if (! $e instanceof RateLimitException) {
Log::error('[MatchActivityCrmData] Failed to match CRM data', [
'activity' => $this->activityId,
'remote_search' => $this->remoteSearch,
'exception' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
throw $e;
}
}
public function failed(Throwable $exception): void
{
$context = [
'activity' => $this->activityId,
'remote_search' => $this->remoteSearch,
'from_configuration' => $this->fromConfiguration?->getId(),
'exception' => $exception->getMessage(),
'attempts' => $this->attempts(),
];
// RateLimitException reaches failed() only when retryUntil() expires while the job is
// still held in the "released" state by HandleHubspotRateLimit — the middleware never
// re-throws, so the exception is not the direct cause of the failure in normal flow.
if ($exception instanceof RateLimitException || $exception instanceof \Illuminate\Queue\MaxAttemptsExceededException) {
Log::warning('[MatchActivityCrmData] Job permanently failed due to rate limiting', $context);
} else {
Log::error('[MatchActivityCrmData] Job permanently failed after all retries', $context);
}
\Sentry::captureException($exception);
}
private function resetCrmMappings(
Activity $activity,
ActivityRepository $activityRepository
): void {
$activity->update([
'lead_id' => null,
'contact_id' => null,
'account_id' => null,
'opportunity_id' => null,
'stage_id' => null,
]);
$participantsOldState = $activityRepository->getActivityParticipants($activity)
->map(function ($participant) {
return [
'id' => $participant->id,
'user_id' => $participant->user_id,
'contact_id' => $participant->contact_id,
'lead_id' => $participant->lead_id,
];
});
if ($participantsOldState->isNotEmpty()) {
Log::info('[MatchActivityCrmData] Participants old state', [
'activity' => $this->activityId,
'participants' => $participantsOldState->toArray(),
]);
}
$activity->participants()->update([
'user_id' => null,
'contact_id' => null,
'lead_id' => null,
]);
}
private function switchCrmConfigurationIfNeeded(Activity $activity): void
{
if ($this->fromConfiguration === null) {
return;
}
if ($activity->getCrm()?->getId() === $this->fromConfiguration->getId()) {
return;
}
Log::info('[MatchActivityCrmData] Switching CRM configuration', [
'activity' => $this->activityId,
'old_configuration' => $activity->getCrm()?->getId(),
'new_configuration' => $this->fromConfiguration->getId(),
]);
$activity->update([
'crm_configuration_id' => $this->fromConfiguration->getId(),
'crm_provider_id' => null,
]);
}
}
<?php
declare(strict_types=1);
namespace Jiminny\Jobs\Crm;
use Exception;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Connection;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Jiminny\Component\Queue\Constants;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Jobs\Job;
use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
use Jiminny\Models\Activity;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Repositories\ActivityRepository;
use Jiminny\Services\Crm\CrmActivityService;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Throwable;
class MatchActivityCrmData extends Job implements ShouldQueue, ShouldBeUnique
{
use InteractsWithQueue;
use SerializesModels;
private const int RETRY_WINDOW_MINUTES = 120;
public int $maxExceptions = 3;
private int $activityId;
private ?Configuration $fromConfiguration;
private bool $remoteSearch;
public function __construct(
int $activityId,
?Configuration $fromConfiguration = null,
bool $remoteSearch = false,
) {
$this->activityId = $activityId;
$this->fromConfiguration = $fromConfiguration;
$this->remoteSearch = $remoteSearch;
$this->onQueue(Constants::QUEUE_ANALYTICS_LOW);
}
public function middleware(): array
{
return [new HandleHubspotRateLimit()];
}
public function retryUntil(): \DateTimeInterface
{
return now()->addMinutes(self::RETRY_WINDOW_MINUTES);
}
public function uniqueId(): string
{
$configId = $this->fromConfiguration?->getId() ?? 0;
$remote = $this->remoteSearch ? 'remote' : 'local';
return "$this->activityId:$configId:$remote";
}
public function timeout(): int
{
return 300; // 5 minutes max execution time
}
public function uniqueFor(): int
{
return self::RETRY_WINDOW_MINUTES * 60 + 60;
}
public function backoff(): array
{
return [30, 90, 180];
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
* @throws Exception|Throwable
*/
public function handle(
ActivityRepository $activityRepository,
CrmActivityService $crmActivityService,
Connection $connection,
): void {
$activity = $activityRepository->findById($this->activityId);
if ($activity === null) {
throw new InvalidArgumentException('[MatchActivityCrmData] Cannot find activity.');
}
try {
$connection->transaction(function () use ($activity, $crmActivityService, $activityRepository) {
Log::info('[MatchActivityCrmData] Starting CRM data matching', [
'activity' => $this->activityId,
'remote_search' => $this->remoteSearch,
'set_configuration' => $this->fromConfiguration?->getId(),
'old_state' => [
'lead_id' => $activity->getLead()?->getId(),
'contact_id' => $activity->getContact()?->getId(),
'account_id' => $activity->getAccount()?->getId(),
'opportunity_id' => $activity->getOpportunity()?->getId(),
'stage_id' => $activity->getStage()?->getId(),
],
]);
$this->resetCrmMappings($activity, $activityRepository);
$this->switchCrmConfigurationIfNeeded($activity);
$activity->refresh();
$crmActivityService->updateCrmData(
activity: $activity,
remoteSearch: $this->remoteSearch,
);
$hasMatch = $activity->getLead() !== null
|| $activity->getContact() !== null
|| $activity->getAccount() !== null
|| $activity->getOpportunity() !== null;
if ($hasMatch) {
Log::info('[MatchActivityCrmData] Successfully matched CRM data', [
'activity' => $this->activityId,
'remote_search' => $this->remoteSearch,
'lead_id' => $activity->getLead()?->getId(),
'contact_id' => $activity->getContact()?->getId(),
'account_id' => $activity->getAccount()?->getId(),
'opportunity_id' => $activity->getOpportunity()?->getId(),
'stage_id' => $activity->getStage()?->getId(),
]);
} else {
Log::info('[MatchActivityCrmData] No CRM match found', [
'activity' => $this->activityId,
'remote_search' => $this->remoteSearch,
]);
}
});
} catch (Throwable $e) {
if (! $e instanceof RateLimitException) {
Log::error('[MatchActivityCrmData] Failed to match CRM data', [
'activity' => $this->activityId,
'remote_search' => $this->remoteSearch,
'exception' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
throw $e;
}
}
public function failed(Throwable $exception): void
{
$context = [
'activity' => $this->activityId,
'remote_search' => $this->remoteSearch,
'from_configuration' => $this->fromConfiguration?->getId(),
'exception' => $exception->getMessage(),
'attempts' => $this->attempts(),
];
// RateLimitException reaches failed() only when retryUntil() expires while the job is
// still held in the "released" state by HandleHubspotRateLimit — the middleware never
// re-throws, so the exception is not the direct cause of the failure in normal flow.
if ($exception instanceof RateLimitException || $exception instanceof \Illuminate\Queue\MaxAttemptsExceededException) {
Log::warning('[MatchActivityCrmData] Job permanently failed due to rate limiting', $context);
} else {
Log::error('[MatchActivityCrmData] Job permanently failed after all retries', $context);
}
\Sentry::captureException($exception);
}
private function resetCrmMappings(
Activity $activity,
ActivityRepository $activityRepository
): void {
$activity->update([
'lead_id' => null,
'contact_id' => null,
'account_id' => null,
'opportunity_id' => null,
'stage_id' => null,
]);
$participantsOldState = $activityRepository->getActivityParticipants($activity)
->map(function ($participant) {
return [
'id' => $participant->id,
'user_id' => $participant->user_id,
'contact_id' => $participant->contact_id,
'lead_id' => $participant->lead_id,
];
});
if ($participantsOldState->isNotEmpty()) {
Log::info('[MatchActivityCrmData] Participants old state', [
'activity' => $this->activityId,
'participants' => $participantsOldState->toArray(),
]);
}
$activity->participants()->update([
'user_id' => null,
'contact_id' => null,
'lead_id' => null,
]);
}
private function switchCrmConfigurationIfNeeded(Activity $activity): void
{
if ($this->fromConfiguration === null) {
return;
}
if ($activity->getCrm()?->getId() === $this->fromConfiguration->getId()) {
return;
}
Log::info('[MatchActivityCrmData] Switching CRM configuration', [
'activity' => $this->activityId,
'old_configuration' => $activity->getCrm()?->getId(),
'new_configuration' => $this->fromConfiguration->getId(),
]);
$activity->update([
'crm_configuration_id' => $this->fromConfiguration->getId(),
'crm_provider_id' => null,
]);
}
}
Review rate limit handli…, Editor Group 2
remote
app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...
JY-20725-handle-HS-search-rate-limit*
app (Git) - Synchronize Changes
Errors: 6, Warnings: 11, Infos: 28
6
11
28
Notifications
key, PHP extension: Premium features not active.
8.3, Language Level: 8.3
Sign In
Sign In
PHP
Editor Language Status: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 4
Ln 172, Col 1
Diff editor
Clear
Review rate limit handling for HubSpot job queue
Session history
New session
Message actions
RateLimitException.php#9-9
RateLimitException.php#9-9
Reveiw the diff. Mostly
@app/Jobs/Crm/MatchActivityCrmData.php
,
@app/Exceptions/RateLimitException.php
,
@app/Jobs/Middleware/HandleHubspotRateLimit.php
,
@app/Services/Crm/Hubspot/Client.php
and
@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php
. The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?
Thought for 5s
Thought for 5s
I'll review the implementation carefully. Let me first check how
RateLimitException
is used elsewhere and look at the test files and
PaginationState
.
Bash
Find all usages of RateLimitException outside the changed files
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/app --include="*.php" | grep -v "HandleHubspotRateLimit\|MatchActivityCrmData\|Client.php\|RateLimitException.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is
Read
PaginationState.php
PaginationState.php
Read
HandleHubspotRateLimitTest.php
HandleHubspotRateLimitTest.php
Bash
Check how RateLimitException is used in GenerateActionItemsJob
IN
grep -rn "RateLimitException\|LogicException\|RuntimeException" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
Bash
Find all catch blocks or instanceof checks for RateLimitException
IN
grep -rn "catch.*RateLimitException\|instanceof RateLimitException" /Users/lukas/jiminny/app/app --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {
Bash
Find all test references to RateLimitException
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/tests --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);
Bash
Check retry/failure handling in GenerateActionItemsJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30
Copy code to clipboard
OUT
13:use Jiminny\Exceptions\RateLimitException;
51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
56: $this->release(30);
Bash
Check retry/failure handling in GenerateAiCallScoringJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30
Copy code to clipboard
OUT
17:use Jiminny\Exceptions\RateLimitException;
68: RateLimitException |
75: $this->release(30);
Bash
Check retry config in MatchActivityCrmData
IN
grep -n "retryAfter\|RETRY_WINDOW\|retryUntil\|maxTries" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php
Copy code to clipboard
OUT
34: private const int RETRY_WINDOW_MINUTES = 30;
45: public function retryUntil(): \DateTimeInterface
47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);
77: return self::RETRY_WINDOW_MINUTES * 60 + 60;
Bash
Check the cache key format
IN
grep -n "getRateLimitCacheKey\|hubspot:ratelimit" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Copy code to clipboard
OUT
78: $cacheKey = $this->getRateLimitCacheKey();
110: private function getRateLimitCacheKey(): string
112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());
Bash
Find other jobs that might use the rate limit middleware
IN
grep -rn "class.*Middleware\|HandleHubspotRateLimit" /Users/lukas/jiminny/app/app/Jobs --include="*.php" | grep -v "HandleHubspotRateLimit.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];
Read
HubspotClientInterface.php
HubspotClientInterface.php
Thought for 73s
Thought for 73s
Now I have a complete picture. Here's my full analysis:
Review Summary
Review Summary
RuntimeException vs LogicException — Safe ✓
RuntimeException
vs
LogicException
— Safe ✓
The change is semantically correct and won't break anything.
LogicException
is for programmer errors (invalid arguments, contract violations). A rate limit from an external API is a runtime condition —
RuntimeException
is the right parent. All callers catch
RateLimitException
by name explicitly (union catches in
GenerateActionItemsJob
,
GenerateAiCallScoringJob
,
SummarizeTranscriptionJob
,
GenerateAiActivityTypeJob
) or check
instanceof RateLimitException
— none relies on the parent class type, so nothing breaks.
Issue 1 — Circuit breaker stores stale delay (most important bug)
Issue 1 — Circuit breaker stores stale delay (most important bug)
Client.php:94
Client.php:94
Copy code to clipboard
Redis::setex($cacheKey, $retryAfter, (string) $retryAfter);
The TTL is
$retryAfter
seconds, but the
stored value
is also the original
$retryAfter
. If a second job reads this cache 20 seconds later, it throws
RateLimitException(retryAfter=30)
and gets delayed 30 more seconds — even though only 10 remain. In a spike of 100 jobs, each subsequent job inflates its wait.
Fix:
store the absolute expiry timestamp, reconstruct the remaining delay when reading:
Copy code to clipboard
// on 429:
Redis::setex($cacheKey, $retryAfter, (string) (time() + $retryAfter));
// in the cached-read branch:
$remaining = max(1, (int) $cachedRetryAfter - time());
throw new RateLimitException('Hubspot rate limit (cached circuit-breaker)', $remaining);
Issue 2 — retryUntil() window may expire under sustained rate limiting
Issue 2 —
retryUntil()
window may expire under sustained rate limiting
MatchActivityCrmData.php:45-47
MatchActivityCrmData.php:45-47
retryUntil()
is calculated as
now() + 30 min
at
dispatch time
and stored in the job payload. With
MAX_RETRY_DELAY = 600s
, you only get ~3 rate-limit-triggered releases before the 30-minute window closes and the job is permanently failed. For a heavy 429 spike (daily limit, parsed as 600s), this can easily happen.
Consider increasing
RETRY_WINDOW_MINUTES
to 60 or more, or making it configurable. The
uniqueFor()
value should be updated to match.
Issue 3 — Only search() uses the circuit breaker, but other methods can still hit 429
Issue 3 — Only
search()
uses the circuit breaker, but other methods can still hit 429
Client.php:76-108
Client.php:76-108
executeRequest()
is only called from
search()
. Methods like
getOpportunityById()
,
batchReadObjects()
,
getContactByEmail()
bypass it entirely. If HubSpot's 429 is account-wide (not search-endpoint-specific), those methods will keep firing even while the circuit breaker is supposedly open. This is likely fine because the search endpoint has its own tighter quota (5 RPS), but it's worth being explicit in a comment.
Issue 4 — object $job type in middleware has no compile-time safety
Issue 4 —
object $job
type in middleware has no compile-time safety
HandleHubspotRateLimit.php:20
HandleHubspotRateLimit.php:20
Copy code to clipboard
public function handle(object $job, callable $next): void
Calling
$job->attempts()
and
$job->release()
on
object
will work at runtime since Laravel always passes jobs that use
InteractsWithQueue
, but PHPStan/psalm won't verify this and it could silently break in tests with bare mocks (as your test does use a
Job
mock that has
attempts()
— so the test is fine, but the real type contract is invisible). Minor, but worth tightening to
\Illuminate\Contracts\Queue\Job
.
What works well ✓
What works well ✓
RateLimitException
is caught by the middleware
before
it increments
maxExceptions
, so rate-limit releases don't consume the 3-exception budget for real failures.
The middleware's jitter (
random_int(0, 5)
) prevents thundering herd on release.
The log-sampling logic (
attempts <= 3 || attempts % 10 === 0
) keeps logs useful without flooding.
ShouldBeUnique
+
uniqueFor()
correctly prevents duplicate jobs queuing up during the rate-limit wait window.
RateLimitException
propagates cleanly through the DB transaction (rolled back), then through the job catch block (logged conditionally), then to the middleware which handles it.
The pagination service re-throws
RateLimitException
as-is and resets pagination state on the next job attempt — safe because offset restarts from 0.
Message actions
Expand on 1 and 2. Let's consider there are several jobs running from ten workers. They are out fired instantly, and therefore there is no cache when they look for cache, but once they receive 429, they store cache. Can there be some potential issues with that flow?
Thought for 91s
Thought for 91s
Let me trace the exact race conditions step by step.
Wave 1 — The Initial Burst (t=0)
Wave 1 — The Initial Burst (t=0)
All 10 workers execute simultaneously. Each does:
Copy code to clipboard
$cachedRetryAfter = Redis::get($cacheKey); // ALL TEN: null — cache miss
Cache doesn't exist yet, so all 10 proceed and hammer HubSpot's search endpoint simultaneously. HubSpot's search limit is 5 RPS per portal — 10 concurrent requests guarantees 429s. All 10 get 429, all 10 run:
Copy code to clipboard
Redis::setex($cacheKey, $retryAfter, (string) $retryAfter); // e.g. TTL=10, value="10"
throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);
All 10 write the same key at nearly the same millisecond. Redis just accepts last-write-wins — no error, no coordination, 10 redundant writes. Then all 10 are caught by the middleware and released:
Copy code to clipboard
delay = max(1, min(10, 600)) + random_int(0, 5) → 10 to 15 seconds
So all 10 jobs land back in the queue, scheduled to fire between t=10 and t=15.
The Core Problem — TTL Expires Before Released Jobs Fire
The Core Problem — TTL Expires Before Released Jobs Fire
The Redis key was set with
TTL = retryAfter = 10
. The jobs were released with
delay = retryAfter + jitter = 10–15
.
Copy code to clipboard
Redis key expires: t = 10
Jobs start firing: t = 10 to t = 15
These are nearly the same window. The very first job that fires at t=10 may race against the TTL expiry. Jobs firing at t=11–15
will find an empty cache
because the key already expired. So the circuit breaker provides
zero protection
for the second wave of the same jobs. They all miss the cache again and charge straight at HubSpot.
This is the core mismatch: the release delay was derived from the same value as the TTL, so they expire together. The circuit breaker is only effective for
new, independent jobs
that arrive during the t=0–10 window, not for the 10 jobs that triggered the 429 in the first place.
Issue 1 Compounded — Stale Delay Makes It Worse
Issue 1 Compounded — Stale Delay Makes It Worse
Say a new job (job #11) arrives at t=5. It reads the cache and gets
"10"
. The middleware releases it for 10 seconds → fires at t=15. But the rate limit ended at t=10. It waited 5 extra seconds for nothing, and it fires exactly when the 10 original jobs are also firing (t=10–15). So instead of smoothing the load, the stale delay actually
bunches job #11 into the same second wave
....
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Explorer (⇧⌘E)","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Search (⇧⌘F)","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Source Control (⌃⇧G) - 17 pending changes","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":true},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"17","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Run and Debug (⇧⌘D)","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Remote Explorer","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Extensions (⇧⌘X) - 2 require update","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"2","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Testing","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Claude Code","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"SOURCE CONTROL","depth":17,"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"SOURCE CONTROL","depth":18,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Changes Section","depth":21,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"CHANGES","depth":22,"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"CHANGES","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"","depth":28,"on_screen":true,"value":"","role_description":"editor","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":29,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Commit Changes on \"JY-20725-handle-HS-search-rate-limit\"","depth":26,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Commit","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"More Actions...","depth":26,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Changes","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"17","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.local","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.nikilocal","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.other","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ids.txt","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"raw_sql_query.sql","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"WEBHOOK_FILTERING_IMPLEMENTATION.md","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JiminnyDebugCommand.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"SimulateWebhooksCommand.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands/Crm/Hubspot","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CreateMockAskJiminnyReportResultCommand.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands/Reports","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Exceptions","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"MatchActivityCrmData.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Jobs/Crm","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Client.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Services/Crm/Hubspot","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"9, M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"logging.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"config","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"favicon.ico","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"public","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Jobs/Middleware","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CanAccessAiReportsTest.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Policies","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClientTest.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Services/Crm/Hubspot","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Graph Section","depth":21,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"GRAPH","depth":22,"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"GRAPH","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add more tests","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add tests","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"origin/master","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20819: Increase download transcription rate limit","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihail.mihaylov","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20818 move ask jiminny reports to its own datadog metric","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-20817-fix-deleting-old-tracks","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12047 from jiminny/JY-20662-remove-word-boost","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20662 return void","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20662 Removed word_boost parameter from Assembly transcription request","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20817: Fix deleting old tracks","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihail.mihaylov","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-19938-bot-status-in-csv-es","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 Added consent information and recording error reason to Elastic Search mapping","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 reorder array map","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938: Address PR review comments","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 lint fixes","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1","depth":28,"on_screen":false,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Client.php (Working Tree) (Client.php), Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"MatchActivityCrmData.php, preview, Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Jobs\\Crm;\n\nuse Exception;\nuse Illuminate\\Contracts\\Queue\\ShouldBeUnique;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Illuminate\\Database\\Connection;\nuse Illuminate\\Queue\\InteractsWithQueue;\nuse Illuminate\\Queue\\SerializesModels;\nuse Illuminate\\Support\\Facades\\Log;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Jobs\\Job;\nuse Jiminny\\Jobs\\Middleware\\HandleHubspotRateLimit;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Repositories\\ActivityRepository;\nuse Jiminny\\Services\\Crm\\CrmActivityService;\nuse Psr\\Container\\ContainerExceptionInterface;\nuse Psr\\Container\\NotFoundExceptionInterface;\nuse Throwable;\n\nclass MatchActivityCrmData extends Job implements ShouldQueue, ShouldBeUnique\n{\n use InteractsWithQueue;\n use SerializesModels;\n\n private const int RETRY_WINDOW_MINUTES = 120;\n\n public int $maxExceptions = 3;\n\n private int $activityId;\n private ?Configuration $fromConfiguration;\n private bool $remoteSearch;\n\n public function __construct(\n int $activityId,\n ?Configuration $fromConfiguration = null,\n bool $remoteSearch = false,\n ) {\n $this->activityId = $activityId;\n $this->fromConfiguration = $fromConfiguration;\n $this->remoteSearch = $remoteSearch;\n\n $this->onQueue(Constants::QUEUE_ANALYTICS_LOW);\n }\n\n public function middleware(): array\n {\n return [new HandleHubspotRateLimit()];\n }\n\n public function retryUntil(): \\DateTimeInterface\n {\n return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n }\n\n public function uniqueId(): string\n {\n $configId = $this->fromConfiguration?->getId() ?? 0;\n $remote = $this->remoteSearch ? 'remote' : 'local';\n\n return \"$this->activityId:$configId:$remote\";\n }\n\n public function timeout(): int\n {\n return 300; // 5 minutes max execution time\n }\n\n public function uniqueFor(): int\n {\n return self::RETRY_WINDOW_MINUTES * 60 + 60;\n }\n\n public function backoff(): array\n {\n return [30, 90, 180];\n }\n\n /**\n * @throws ContainerExceptionInterface\n * @throws NotFoundExceptionInterface\n * @throws Exception|Throwable\n */\n public function handle(\n ActivityRepository $activityRepository,\n CrmActivityService $crmActivityService,\n Connection $connection,\n ): void {\n $activity = $activityRepository->findById($this->activityId);\n if ($activity === null) {\n throw new InvalidArgumentException('[MatchActivityCrmData] Cannot find activity.');\n }\n\n try {\n $connection->transaction(function () use ($activity, $crmActivityService, $activityRepository) {\n Log::info('[MatchActivityCrmData] Starting CRM data matching', [\n 'activity' => $this->activityId,\n 'remote_search' => $this->remoteSearch,\n 'set_configuration' => $this->fromConfiguration?->getId(),\n 'old_state' => [\n 'lead_id' => $activity->getLead()?->getId(),\n 'contact_id' => $activity->getContact()?->getId(),\n 'account_id' => $activity->getAccount()?->getId(),\n 'opportunity_id' => $activity->getOpportunity()?->getId(),\n 'stage_id' => $activity->getStage()?->getId(),\n ],\n ]);\n\n $this->resetCrmMappings($activity, $activityRepository);\n\n $this->switchCrmConfigurationIfNeeded($activity);\n\n $activity->refresh();\n\n $crmActivityService->updateCrmData(\n activity: $activity,\n remoteSearch: $this->remoteSearch,\n );\n\n $hasMatch = $activity->getLead() !== null\n || $activity->getContact() !== null\n || $activity->getAccount() !== null\n || $activity->getOpportunity() !== null;\n\n if ($hasMatch) {\n Log::info('[MatchActivityCrmData] Successfully matched CRM data', [\n 'activity' => $this->activityId,\n 'remote_search' => $this->remoteSearch,\n 'lead_id' => $activity->getLead()?->getId(),\n 'contact_id' => $activity->getContact()?->getId(),\n 'account_id' => $activity->getAccount()?->getId(),\n 'opportunity_id' => $activity->getOpportunity()?->getId(),\n 'stage_id' => $activity->getStage()?->getId(),\n ]);\n } else {\n Log::info('[MatchActivityCrmData] No CRM match found', [\n 'activity' => $this->activityId,\n 'remote_search' => $this->remoteSearch,\n ]);\n }\n });\n } catch (Throwable $e) {\n if (! $e instanceof RateLimitException) {\n Log::error('[MatchActivityCrmData] Failed to match CRM data', [\n 'activity' => $this->activityId,\n 'remote_search' => $this->remoteSearch,\n 'exception' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n }\n\n throw $e;\n }\n }\n\n public function failed(Throwable $exception): void\n {\n $context = [\n 'activity' => $this->activityId,\n 'remote_search' => $this->remoteSearch,\n 'from_configuration' => $this->fromConfiguration?->getId(),\n 'exception' => $exception->getMessage(),\n 'attempts' => $this->attempts(),\n ];\n\n // RateLimitException reaches failed() only when retryUntil() expires while the job is\n // still held in the \"released\" state by HandleHubspotRateLimit — the middleware never\n // re-throws, so the exception is not the direct cause of the failure in normal flow.\n if ($exception instanceof RateLimitException || $exception instanceof \\Illuminate\\Queue\\MaxAttemptsExceededException) {\n Log::warning('[MatchActivityCrmData] Job permanently failed due to rate limiting', $context);\n } else {\n Log::error('[MatchActivityCrmData] Job permanently failed after all retries', $context);\n }\n\n \\Sentry::captureException($exception);\n }\n\n private function resetCrmMappings(\n Activity $activity,\n ActivityRepository $activityRepository\n ): void {\n $activity->update([\n 'lead_id' => null,\n 'contact_id' => null,\n 'account_id' => null,\n 'opportunity_id' => null,\n 'stage_id' => null,\n ]);\n\n $participantsOldState = $activityRepository->getActivityParticipants($activity)\n ->map(function ($participant) {\n return [\n 'id' => $participant->id,\n 'user_id' => $participant->user_id,\n 'contact_id' => $participant->contact_id,\n 'lead_id' => $participant->lead_id,\n ];\n });\n\n if ($participantsOldState->isNotEmpty()) {\n Log::info('[MatchActivityCrmData] Participants old state', [\n 'activity' => $this->activityId,\n 'participants' => $participantsOldState->toArray(),\n ]);\n }\n\n $activity->participants()->update([\n 'user_id' => null,\n 'contact_id' => null,\n 'lead_id' => null,\n ]);\n }\n\n private function switchCrmConfigurationIfNeeded(Activity $activity): void\n {\n if ($this->fromConfiguration === null) {\n return;\n }\n\n if ($activity->getCrm()?->getId() === $this->fromConfiguration->getId()) {\n return;\n }\n\n Log::info('[MatchActivityCrmData] Switching CRM configuration', [\n 'activity' => $this->activityId,\n 'old_configuration' => $activity->getCrm()?->getId(),\n 'new_configuration' => $this->fromConfiguration->getId(),\n ]);\n\n $activity->update([\n 'crm_configuration_id' => $this->fromConfiguration->getId(),\n 'crm_provider_id' => null,\n ]);\n }\n}","depth":28,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Jobs\\Crm;\n\nuse Exception;\nuse Illuminate\\Contracts\\Queue\\ShouldBeUnique;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Illuminate\\Database\\Connection;\nuse Illuminate\\Queue\\InteractsWithQueue;\nuse Illuminate\\Queue\\SerializesModels;\nuse Illuminate\\Support\\Facades\\Log;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Jobs\\Job;\nuse Jiminny\\Jobs\\Middleware\\HandleHubspotRateLimit;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Repositories\\ActivityRepository;\nuse Jiminny\\Services\\Crm\\CrmActivityService;\nuse Psr\\Container\\ContainerExceptionInterface;\nuse Psr\\Container\\NotFoundExceptionInterface;\nuse Throwable;\n\nclass MatchActivityCrmData extends Job implements ShouldQueue, ShouldBeUnique\n{\n use InteractsWithQueue;\n use SerializesModels;\n\n private const int RETRY_WINDOW_MINUTES = 120;\n\n public int $maxExceptions = 3;\n\n private int $activityId;\n private ?Configuration $fromConfiguration;\n private bool $remoteSearch;\n\n public function __construct(\n int $activityId,\n ?Configuration $fromConfiguration = null,\n bool $remoteSearch = false,\n ) {\n $this->activityId = $activityId;\n $this->fromConfiguration = $fromConfiguration;\n $this->remoteSearch = $remoteSearch;\n\n $this->onQueue(Constants::QUEUE_ANALYTICS_LOW);\n }\n\n public function middleware(): array\n {\n return [new HandleHubspotRateLimit()];\n }\n\n public function retryUntil(): \\DateTimeInterface\n {\n return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n }\n\n public function uniqueId(): string\n {\n $configId = $this->fromConfiguration?->getId() ?? 0;\n $remote = $this->remoteSearch ? 'remote' : 'local';\n\n return \"$this->activityId:$configId:$remote\";\n }\n\n public function timeout(): int\n {\n return 300; // 5 minutes max execution time\n }\n\n public function uniqueFor(): int\n {\n return self::RETRY_WINDOW_MINUTES * 60 + 60;\n }\n\n public function backoff(): array\n {\n return [30, 90, 180];\n }\n\n /**\n * @throws ContainerExceptionInterface\n * @throws NotFoundExceptionInterface\n * @throws Exception|Throwable\n */\n public function handle(\n ActivityRepository $activityRepository,\n CrmActivityService $crmActivityService,\n Connection $connection,\n ): void {\n $activity = $activityRepository->findById($this->activityId);\n if ($activity === null) {\n throw new InvalidArgumentException('[MatchActivityCrmData] Cannot find activity.');\n }\n\n try {\n $connection->transaction(function () use ($activity, $crmActivityService, $activityRepository) {\n Log::info('[MatchActivityCrmData] Starting CRM data matching', [\n 'activity' => $this->activityId,\n 'remote_search' => $this->remoteSearch,\n 'set_configuration' => $this->fromConfiguration?->getId(),\n 'old_state' => [\n 'lead_id' => $activity->getLead()?->getId(),\n 'contact_id' => $activity->getContact()?->getId(),\n 'account_id' => $activity->getAccount()?->getId(),\n 'opportunity_id' => $activity->getOpportunity()?->getId(),\n 'stage_id' => $activity->getStage()?->getId(),\n ],\n ]);\n\n $this->resetCrmMappings($activity, $activityRepository);\n\n $this->switchCrmConfigurationIfNeeded($activity);\n\n $activity->refresh();\n\n $crmActivityService->updateCrmData(\n activity: $activity,\n remoteSearch: $this->remoteSearch,\n );\n\n $hasMatch = $activity->getLead() !== null\n || $activity->getContact() !== null\n || $activity->getAccount() !== null\n || $activity->getOpportunity() !== null;\n\n if ($hasMatch) {\n Log::info('[MatchActivityCrmData] Successfully matched CRM data', [\n 'activity' => $this->activityId,\n 'remote_search' => $this->remoteSearch,\n 'lead_id' => $activity->getLead()?->getId(),\n 'contact_id' => $activity->getContact()?->getId(),\n 'account_id' => $activity->getAccount()?->getId(),\n 'opportunity_id' => $activity->getOpportunity()?->getId(),\n 'stage_id' => $activity->getStage()?->getId(),\n ]);\n } else {\n Log::info('[MatchActivityCrmData] No CRM match found', [\n 'activity' => $this->activityId,\n 'remote_search' => $this->remoteSearch,\n ]);\n }\n });\n } catch (Throwable $e) {\n if (! $e instanceof RateLimitException) {\n Log::error('[MatchActivityCrmData] Failed to match CRM data', [\n 'activity' => $this->activityId,\n 'remote_search' => $this->remoteSearch,\n 'exception' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n }\n\n throw $e;\n }\n }\n\n public function failed(Throwable $exception): void\n {\n $context = [\n 'activity' => $this->activityId,\n 'remote_search' => $this->remoteSearch,\n 'from_configuration' => $this->fromConfiguration?->getId(),\n 'exception' => $exception->getMessage(),\n 'attempts' => $this->attempts(),\n ];\n\n // RateLimitException reaches failed() only when retryUntil() expires while the job is\n // still held in the \"released\" state by HandleHubspotRateLimit — the middleware never\n // re-throws, so the exception is not the direct cause of the failure in normal flow.\n if ($exception instanceof RateLimitException || $exception instanceof \\Illuminate\\Queue\\MaxAttemptsExceededException) {\n Log::warning('[MatchActivityCrmData] Job permanently failed due to rate limiting', $context);\n } else {\n Log::error('[MatchActivityCrmData] Job permanently failed after all retries', $context);\n }\n\n \\Sentry::captureException($exception);\n }\n\n private function resetCrmMappings(\n Activity $activity,\n ActivityRepository $activityRepository\n ): void {\n $activity->update([\n 'lead_id' => null,\n 'contact_id' => null,\n 'account_id' => null,\n 'opportunity_id' => null,\n 'stage_id' => null,\n ]);\n\n $participantsOldState = $activityRepository->getActivityParticipants($activity)\n ->map(function ($participant) {\n return [\n 'id' => $participant->id,\n 'user_id' => $participant->user_id,\n 'contact_id' => $participant->contact_id,\n 'lead_id' => $participant->lead_id,\n ];\n });\n\n if ($participantsOldState->isNotEmpty()) {\n Log::info('[MatchActivityCrmData] Participants old state', [\n 'activity' => $this->activityId,\n 'participants' => $participantsOldState->toArray(),\n ]);\n }\n\n $activity->participants()->update([\n 'user_id' => null,\n 'contact_id' => null,\n 'lead_id' => null,\n ]);\n }\n\n private function switchCrmConfigurationIfNeeded(Activity $activity): void\n {\n if ($this->fromConfiguration === null) {\n return;\n }\n\n if ($activity->getCrm()?->getId() === $this->fromConfiguration->getId()) {\n return;\n }\n\n Log::info('[MatchActivityCrmData] Switching CRM configuration', [\n 'activity' => $this->activityId,\n 'old_configuration' => $activity->getCrm()?->getId(),\n 'new_configuration' => $this->fromConfiguration->getId(),\n ]);\n\n $activity->update([\n 'crm_configuration_id' => $this->fromConfiguration->getId(),\n 'crm_provider_id' => null,\n ]);\n }\n}","role_description":"editor","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Jobs\\Crm;\n\nuse Exception;\nuse Illuminate\\Contracts\\Queue\\ShouldBeUnique;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Illuminate\\Database\\Connection;\nuse Illuminate\\Queue\\InteractsWithQueue;\nuse Illuminate\\Queue\\SerializesModels;\nuse Illuminate\\Support\\Facades\\Log;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Jobs\\Job;\nuse Jiminny\\Jobs\\Middleware\\HandleHubspotRateLimit;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Repositories\\ActivityRepository;\nuse Jiminny\\Services\\Crm\\CrmActivityService;\nuse Psr\\Container\\ContainerExceptionInterface;\nuse Psr\\Container\\NotFoundExceptionInterface;\nuse Throwable;\n\nclass MatchActivityCrmData extends Job implements ShouldQueue, ShouldBeUnique\n{\n use InteractsWithQueue;\n use SerializesModels;\n\n private const int RETRY_WINDOW_MINUTES = 120;\n\n public int $maxExceptions = 3;\n\n private int $activityId;\n private ?Configuration $fromConfiguration;\n private bool $remoteSearch;\n\n public function __construct(\n int $activityId,\n ?Configuration $fromConfiguration = null,\n bool $remoteSearch = false,\n ) {\n $this->activityId = $activityId;\n $this->fromConfiguration = $fromConfiguration;\n $this->remoteSearch = $remoteSearch;\n\n $this->onQueue(Constants::QUEUE_ANALYTICS_LOW);\n }\n\n public function middleware(): array\n {\n return [new HandleHubspotRateLimit()];\n }\n\n public function retryUntil(): \\DateTimeInterface\n {\n return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n }\n\n public function uniqueId(): string\n {\n $configId = $this->fromConfiguration?->getId() ?? 0;\n $remote = $this->remoteSearch ? 'remote' : 'local';\n\n return \"$this->activityId:$configId:$remote\";\n }\n\n public function timeout(): int\n {\n return 300; // 5 minutes max execution time\n }\n\n public function uniqueFor(): int\n {\n return self::RETRY_WINDOW_MINUTES * 60 + 60;\n }\n\n public function backoff(): array\n {\n return [30, 90, 180];\n }\n\n /**\n * @throws ContainerExceptionInterface\n * @throws NotFoundExceptionInterface\n * @throws Exception|Throwable\n */\n public function handle(\n ActivityRepository $activityRepository,\n CrmActivityService $crmActivityService,\n Connection $connection,\n ): void {\n $activity = $activityRepository->findById($this->activityId);\n if ($activity === null) {\n throw new InvalidArgumentException('[MatchActivityCrmData] Cannot find activity.');\n }\n\n try {\n $connection->transaction(function () use ($activity, $crmActivityService, $activityRepository) {\n Log::info('[MatchActivityCrmData] Starting CRM data matching', [\n 'activity' => $this->activityId,\n 'remote_search' => $this->remoteSearch,\n 'set_configuration' => $this->fromConfiguration?->getId(),\n 'old_state' => [\n 'lead_id' => $activity->getLead()?->getId(),\n 'contact_id' => $activity->getContact()?->getId(),\n 'account_id' => $activity->getAccount()?->getId(),\n 'opportunity_id' => $activity->getOpportunity()?->getId(),\n 'stage_id' => $activity->getStage()?->getId(),\n ],\n ]);\n\n $this->resetCrmMappings($activity, $activityRepository);\n\n $this->switchCrmConfigurationIfNeeded($activity);\n\n $activity->refresh();\n\n $crmActivityService->updateCrmData(\n activity: $activity,\n remoteSearch: $this->remoteSearch,\n );\n\n $hasMatch = $activity->getLead() !== null\n || $activity->getContact() !== null\n || $activity->getAccount() !== null\n || $activity->getOpportunity() !== null;\n\n if ($hasMatch) {\n Log::info('[MatchActivityCrmData] Successfully matched CRM data', [\n 'activity' => $this->activityId,\n 'remote_search' => $this->remoteSearch,\n 'lead_id' => $activity->getLead()?->getId(),\n 'contact_id' => $activity->getContact()?->getId(),\n 'account_id' => $activity->getAccount()?->getId(),\n 'opportunity_id' => $activity->getOpportunity()?->getId(),\n 'stage_id' => $activity->getStage()?->getId(),\n ]);\n } else {\n Log::info('[MatchActivityCrmData] No CRM match found', [\n 'activity' => $this->activityId,\n 'remote_search' => $this->remoteSearch,\n ]);\n }\n });\n } catch (Throwable $e) {\n if (! $e instanceof RateLimitException) {\n Log::error('[MatchActivityCrmData] Failed to match CRM data', [\n 'activity' => $this->activityId,\n 'remote_search' => $this->remoteSearch,\n 'exception' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n }\n\n throw $e;\n }\n }\n\n public function failed(Throwable $exception): void\n {\n $context = [\n 'activity' => $this->activityId,\n 'remote_search' => $this->remoteSearch,\n 'from_configuration' => $this->fromConfiguration?->getId(),\n 'exception' => $exception->getMessage(),\n 'attempts' => $this->attempts(),\n ];\n\n // RateLimitException reaches failed() only when retryUntil() expires while the job is\n // still held in the \"released\" state by HandleHubspotRateLimit — the middleware never\n // re-throws, so the exception is not the direct cause of the failure in normal flow.\n if ($exception instanceof RateLimitException || $exception instanceof \\Illuminate\\Queue\\MaxAttemptsExceededException) {\n Log::warning('[MatchActivityCrmData] Job permanently failed due to rate limiting', $context);\n } else {\n Log::error('[MatchActivityCrmData] Job permanently failed after all retries', $context);\n }\n\n \\Sentry::captureException($exception);\n }\n\n private function resetCrmMappings(\n Activity $activity,\n ActivityRepository $activityRepository\n ): void {\n $activity->update([\n 'lead_id' => null,\n 'contact_id' => null,\n 'account_id' => null,\n 'opportunity_id' => null,\n 'stage_id' => null,\n ]);\n\n $participantsOldState = $activityRepository->getActivityParticipants($activity)\n ->map(function ($participant) {\n return [\n 'id' => $participant->id,\n 'user_id' => $participant->user_id,\n 'contact_id' => $participant->contact_id,\n 'lead_id' => $participant->lead_id,\n ];\n });\n\n if ($participantsOldState->isNotEmpty()) {\n Log::info('[MatchActivityCrmData] Participants old state', [\n 'activity' => $this->activityId,\n 'participants' => $participantsOldState->toArray(),\n ]);\n }\n\n $activity->participants()->update([\n 'user_id' => null,\n 'contact_id' => null,\n 'lead_id' => null,\n ]);\n }\n\n private function switchCrmConfigurationIfNeeded(Activity $activity): void\n {\n if ($this->fromConfiguration === null) {\n return;\n }\n\n if ($activity->getCrm()?->getId() === $this->fromConfiguration->getId()) {\n return;\n }\n\n Log::info('[MatchActivityCrmData] Switching CRM configuration', [\n 'activity' => $this->activityId,\n 'old_configuration' => $activity->getCrm()?->getId(),\n 'new_configuration' => $this->fromConfiguration->getId(),\n ]);\n\n $activity->update([\n 'crm_configuration_id' => $this->fromConfiguration->getId(),\n 'crm_provider_id' => null,\n ]);\n }\n}","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Review rate limit handli…, Editor Group 2","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXButton","text":"remote","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit*","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"app (Git) - Synchronize Changes","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Errors: 6, Warnings: 11, Infos: 28","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"6","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"28","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Notifications","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"key, PHP extension: Premium features not active.","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"8.3, Language Level: 8.3","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sign In","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Sign In","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"PHP","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Editor Language Status: $(copilot) No inline suggestion available, Inline suggestions","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"LF","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UTF-8","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Spaces: 4","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Ln 172, Col 1","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Diff editor","depth":12,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Clear","depth":12,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Review rate limit handling for HubSpot job queue","depth":19,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Session history","depth":19,"on_screen":true,"help_text":"Session history","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New session","depth":19,"on_screen":true,"help_text":"New session","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Message actions","depth":24,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"RateLimitException.php#9-9","depth":23,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"RateLimitException.php#9-9","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Reveiw the diff. Mostly","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Jobs/Crm/MatchActivityCrmData.php","depth":26,"on_screen":false,"help_text":"Open app/Jobs/Crm/MatchActivityCrmData.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Exceptions/RateLimitException.php","depth":26,"on_screen":false,"help_text":"Open app/Exceptions/RateLimitException.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Jobs/Middleware/HandleHubspotRateLimit.php","depth":26,"on_screen":false,"help_text":"Open app/Jobs/Middleware/HandleHubspotRateLimit.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Services/Crm/Hubspot/Client.php","depth":26,"on_screen":false,"help_text":"Open app/Services/Crm/Hubspot/Client.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"and","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php","depth":26,"on_screen":false,"help_text":"Open app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":". The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Thought for 5s","depth":23,"on_screen":false,"role_description":"disclosure triangle","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thought for 5s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"I'll review the implementation carefully. Let me first check how","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is used elsewhere and look at the test files and","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PaginationState","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all usages of RateLimitException outside the changed files","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\" /Users/lukas/jiminny/app/app --include=\"*.php\" | grep -v \"HandleHubspotRateLimit\\|MatchActivityCrmData\\|Client.php\\|RateLimitException.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |\n/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"PaginationState.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"PaginationState.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"HandleHubspotRateLimitTest.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check how RateLimitException is used in GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\\|LogicException\\|RuntimeException\" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all catch blocks or instanceof checks for RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"catch.*RateLimitException\\|instanceof RateLimitException\" /Users/lukas/jiminny/app/app --include=\"*.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls\n/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {\n/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all test references to RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\" /Users/lukas/jiminny/app/tests --include=\"*.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry/failure handling in GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"RateLimitException\\|retryUntil\\|backoff\\|maxExceptions\\|maxTries\\|release\\|failed\" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"13:use Jiminny\\Exceptions\\RateLimitException;\n51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n56: $this->release(30);","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry/failure handling in GenerateAiCallScoringJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"RateLimitException\\|retryUntil\\|backoff\\|maxExceptions\\|maxTries\\|release\\|failed\" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"17:use Jiminny\\Exceptions\\RateLimitException;\n68: RateLimitException |\n75: $this->release(30);","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry config in MatchActivityCrmData","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"retryAfter\\|RETRY_WINDOW\\|retryUntil\\|maxTries\" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"34: private const int RETRY_WINDOW_MINUTES = 30;\n45: public function retryUntil(): \\DateTimeInterface\n47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n77: return self::RETRY_WINDOW_MINUTES * 60 + 60;","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check the cache key format","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"getRateLimitCacheKey\\|hubspot:ratelimit\" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"78: $cacheKey = $this->getRateLimitCacheKey();\n110: private function getRateLimitCacheKey(): string\n112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find other jobs that might use the rate limit middleware","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"class.*Middleware\\|HandleHubspotRateLimit\" /Users/lukas/jiminny/app/app/Jobs --include=\"*.php\" | grep -v \"HandleHubspotRateLimit.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\\Jobs\\Middleware\\HandleHubspotRateLimit;\n/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"HubspotClientInterface.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"HubspotClientInterface.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Thought for 73s","depth":23,"on_screen":false,"role_description":"disclosure triangle","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thought for 73s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Now I have a complete picture. Here's my full analysis:","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Review Summary","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Review Summary","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"RuntimeException vs LogicException — Safe ✓","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"RuntimeException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"vs","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LogicException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"— Safe ✓","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The change is semantically correct and won't break anything.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LogicException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is for programmer errors (invalid arguments, contract violations). A rate limit from an external API is a runtime condition —","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RuntimeException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is the right parent. All callers catch","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"by name explicitly (union catches in","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateAiCallScoringJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SummarizeTranscriptionJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateAiActivityTypeJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":") or check","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"instanceof RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"— none relies on the parent class type, so nothing breaks.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 1 — Circuit breaker stores stale delay (most important bug)","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 1 — Circuit breaker stores stale delay (most important bug)","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"Client.php:94","depth":23,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Client.php:94","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Redis::setex($cacheKey, $retryAfter, (string) $retryAfter);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The TTL is","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"$retryAfter","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"seconds, but the","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"stored value","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is also the original","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"$retryAfter","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":". If a second job reads this cache 20 seconds later, it throws","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException(retryAfter=30)","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"and gets delayed 30 more seconds — even though only 10 remain. In a spike of 100 jobs, each subsequent job inflates its wait.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Fix:","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"store the absolute expiry timestamp, reconstruct the remaining delay when reading:","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"// on 429:\nRedis::setex($cacheKey, $retryAfter, (string) (time() + $retryAfter));\n\n// in the cached-read branch:\n$remaining = max(1, (int) $cachedRetryAfter - time());\nthrow new RateLimitException('Hubspot rate limit (cached circuit-breaker)', $remaining);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 2 — retryUntil() window may expire under sustained rate limiting","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 2 —","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"retryUntil()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"window may expire under sustained rate limiting","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"MatchActivityCrmData.php:45-47","depth":23,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"MatchActivityCrmData.php:45-47","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"retryUntil()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is calculated as","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"now() + 30 min","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"at","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"dispatch time","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"and stored in the job payload. With","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MAX_RETRY_DELAY = 600s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":", you only get ~3 rate-limit-triggered releases before the 30-minute window closes and the job is permanently failed. For a heavy 429 spike (daily limit, parsed as 600s), this can easily happen.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Consider increasing","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RETRY_WINDOW_MINUTES","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"to 60 or more, or making it configurable. The","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"uniqueFor()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"value should be updated to match.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 3 — Only search() uses the circuit breaker, but other methods can still hit 429","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 3 — Only","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"search()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"uses the circuit breaker, but other methods can still hit 429","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"Client.php:76-108","depth":23,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Client.php:76-108","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"executeRequest()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is only called from","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"search()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":". Methods like","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getOpportunityById()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"batchReadObjects()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getContactByEmail()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"bypass it entirely. If HubSpot's 429 is account-wide (not search-endpoint-specific), those methods will keep firing even while the circuit breaker is supposedly open. This is likely fine because the search endpoint has its own tighter quota (5 RPS), but it's worth being explicit in a comment.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 4 — object $job type in middleware has no compile-time safety","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 4 —","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"object $job","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"type in middleware has no compile-time safety","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"HandleHubspotRateLimit.php:20","depth":23,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"HandleHubspotRateLimit.php:20","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"public function handle(object $job, callable $next): void","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Calling","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"$job->attempts()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"and","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"$job->release()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"on","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"object","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"will work at runtime since Laravel always passes jobs that use","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"InteractsWithQueue","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":", but PHPStan/psalm won't verify this and it could silently break in tests with bare mocks (as your test does use a","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Job","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"mock that has","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"attempts()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"— so the test is fine, but the real type contract is invisible). Minor, but worth tightening to","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"\\Illuminate\\Contracts\\Queue\\Job","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"What works well ✓","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"What works well ✓","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is caught by the middleware","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"before","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"it increments","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"maxExceptions","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":", so rate-limit releases don't consume the 3-exception budget for real failures.","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The middleware's jitter (","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"random_int(0, 5)","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":") prevents thundering herd on release.","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The log-sampling logic (","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"attempts <= 3 || attempts % 10 === 0","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":") keeps logs useful without flooding.","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ShouldBeUnique","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"+","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"uniqueFor()","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"correctly prevents duplicate jobs queuing up during the rate-limit wait window.","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"propagates cleanly through the DB transaction (rolled back), then through the job catch block (logged conditionally), then to the middleware which handles it.","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The pagination service re-throws","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"as-is and resets pagination state on the next job attempt — safe because offset restarts from 0.","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Message actions","depth":24,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Expand on 1 and 2. Let's consider there are several jobs running from ten workers. They are out fired instantly, and therefore there is no cache when they look for cache, but once they receive 429, they store cache. Can there be some potential issues with that flow?","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Thought for 91s","depth":23,"on_screen":false,"role_description":"disclosure triangle","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thought for 91s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Let me trace the exact race conditions step by step.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Wave 1 — The Initial Burst (t=0)","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Wave 1 — The Initial Burst (t=0)","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"All 10 workers execute simultaneously. Each does:","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"$cachedRetryAfter = Redis::get($cacheKey); // ALL TEN: null — cache miss","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Cache doesn't exist yet, so all 10 proceed and hammer HubSpot's search endpoint simultaneously. HubSpot's search limit is 5 RPS per portal — 10 concurrent requests guarantees 429s. All 10 get 429, all 10 run:","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Redis::setex($cacheKey, $retryAfter, (string) $retryAfter); // e.g. TTL=10, value=\"10\"\nthrow new RateLimitException('Hubspot returned 429', $retryAfter, $e);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"All 10 write the same key at nearly the same millisecond. Redis just accepts last-write-wins — no error, no coordination, 10 redundant writes. Then all 10 are caught by the middleware and released:","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"delay = max(1, min(10, 600)) + random_int(0, 5) → 10 to 15 seconds","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"So all 10 jobs land back in the queue, scheduled to fire between t=10 and t=15.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"The Core Problem — TTL Expires Before Released Jobs Fire","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"The Core Problem — TTL Expires Before Released Jobs Fire","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The Redis key was set with","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"TTL = retryAfter = 10","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":". The jobs were released with","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"delay = retryAfter + jitter = 10–15","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Redis key expires: t = 10\nJobs start firing: t = 10 to t = 15","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"These are nearly the same window. The very first job that fires at t=10 may race against the TTL expiry. Jobs firing at t=11–15","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"will find an empty cache","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"because the key already expired. So the circuit breaker provides","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"zero protection","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"for the second wave of the same jobs. They all miss the cache again and charge straight at HubSpot.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"This is the core mismatch: the release delay was derived from the same value as the TTL, so they expire together. The circuit breaker is only effective for","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"new, independent jobs","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"that arrive during the t=0–10 window, not for the 10 jobs that triggered the 429 in the first place.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 1 Compounded — Stale Delay Makes It Worse","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 1 Compounded — Stale Delay Makes It Worse","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Say a new job (job #11) arrives at t=5. It reads the cache and gets","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"\"10\"","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":". The middleware releases it for 10 seconds → fires at t=15. But the rate limit ended at t=10. It waited 5 extra seconds for nothing, and it fires exactly when the 10 original jobs are also firing (t=10–15). So instead of smoothing the load, the stale delay actually","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"bunches job #11 into the same second wave","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".","depth":23,"on_screen":false,"role_description":"text"}]...
|
-9037929763147080684
|
2071672647940014805
|
idle
|
accessibility
|
NULL
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G) - 17 pending changes
17
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Testing
Claude Code
SOURCE CONTROL
SOURCE CONTROL
Changes Section
CHANGES
CHANGES
Commit Changes on "JY-20725-handle-HS-search-rate-limit"
Commit
More Actions...
Changes
17
.env.local
M
.env.nikilocal
U
.env.other
U
ids.txt
U
raw_sql_query.sql
U
WEBHOOK_FILTERING_IMPLEMENTATION.md
U
JiminnyDebugCommand.php
app/Console/Commands
M
SimulateWebhooksCommand.php
app/Console/Commands/Crm/Hubspot
U
CreateMockAskJiminnyReportResultCommand.php
app/Console/Commands/Reports
U
RateLimitException.php
app/Exceptions
M
MatchActivityCrmData.php
app/Jobs/Crm
M
Client.php
app/Services/Crm/Hubspot
9, M
logging.php
config
M
favicon.ico
public
U
HandleHubspotRateLimitTest.php
tests/Unit/Jobs/Middleware
M
CanAccessAiReportsTest.php
tests/Unit/Policies
U
ClientTest.php
tests/Unit/Services/Crm/Hubspot
M
Graph Section
GRAPH
GRAPH
JY-20725 add more tests
Lukas Kovalik
JY-20725-handle-HS-search-rate-limit
JY-20725 add tests
Lukas Kovalik
JY-20725 add HS rate limit handling on activities rematching
Lukas Kovalik
Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
origin/master
Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit
mihailmihaylovjiminny
JY-20819: Increase download transcription rate limit
mihail.mihaylov
JY-20818 move ask jiminny reports to its own datadog metric
Lukas Kovalik
Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge branch 'master' into JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge pull request #12047 from jiminny/JY-20662-remove-word-boost
ilian-jiminny
JY-20662 return void
ilian
JY-20662 Removed word_boost parameter from Assembly transcription request
ilian
JY-20817: Fix deleting old tracks
mihail.mihaylov
Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge branch 'master' into JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update
ilian-jiminny
JY-19938 Added consent information and recording error reason to Elastic Search mapping
ilian
JY-19938 reorder array map
ilian
JY-19938: Address PR review comments
ilian
JY-19938 lint fixes
ilian
HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1
ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1
Client.php (Working Tree) (Client.php), Editor Group 1
MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1
RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1
MatchActivityCrmData.php, preview, Editor Group 1
<?php
declare(strict_types=1);
namespace Jiminny\Jobs\Crm;
use Exception;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Connection;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Jiminny\Component\Queue\Constants;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Jobs\Job;
use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
use Jiminny\Models\Activity;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Repositories\ActivityRepository;
use Jiminny\Services\Crm\CrmActivityService;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Throwable;
class MatchActivityCrmData extends Job implements ShouldQueue, ShouldBeUnique
{
use InteractsWithQueue;
use SerializesModels;
private const int RETRY_WINDOW_MINUTES = 120;
public int $maxExceptions = 3;
private int $activityId;
private ?Configuration $fromConfiguration;
private bool $remoteSearch;
public function __construct(
int $activityId,
?Configuration $fromConfiguration = null,
bool $remoteSearch = false,
) {
$this->activityId = $activityId;
$this->fromConfiguration = $fromConfiguration;
$this->remoteSearch = $remoteSearch;
$this->onQueue(Constants::QUEUE_ANALYTICS_LOW);
}
public function middleware(): array
{
return [new HandleHubspotRateLimit()];
}
public function retryUntil(): \DateTimeInterface
{
return now()->addMinutes(self::RETRY_WINDOW_MINUTES);
}
public function uniqueId(): string
{
$configId = $this->fromConfiguration?->getId() ?? 0;
$remote = $this->remoteSearch ? 'remote' : 'local';
return "$this->activityId:$configId:$remote";
}
public function timeout(): int
{
return 300; // 5 minutes max execution time
}
public function uniqueFor(): int
{
return self::RETRY_WINDOW_MINUTES * 60 + 60;
}
public function backoff(): array
{
return [30, 90, 180];
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
* @throws Exception|Throwable
*/
public function handle(
ActivityRepository $activityRepository,
CrmActivityService $crmActivityService,
Connection $connection,
): void {
$activity = $activityRepository->findById($this->activityId);
if ($activity === null) {
throw new InvalidArgumentException('[MatchActivityCrmData] Cannot find activity.');
}
try {
$connection->transaction(function () use ($activity, $crmActivityService, $activityRepository) {
Log::info('[MatchActivityCrmData] Starting CRM data matching', [
'activity' => $this->activityId,
'remote_search' => $this->remoteSearch,
'set_configuration' => $this->fromConfiguration?->getId(),
'old_state' => [
'lead_id' => $activity->getLead()?->getId(),
'contact_id' => $activity->getContact()?->getId(),
'account_id' => $activity->getAccount()?->getId(),
'opportunity_id' => $activity->getOpportunity()?->getId(),
'stage_id' => $activity->getStage()?->getId(),
],
]);
$this->resetCrmMappings($activity, $activityRepository);
$this->switchCrmConfigurationIfNeeded($activity);
$activity->refresh();
$crmActivityService->updateCrmData(
activity: $activity,
remoteSearch: $this->remoteSearch,
);
$hasMatch = $activity->getLead() !== null
|| $activity->getContact() !== null
|| $activity->getAccount() !== null
|| $activity->getOpportunity() !== null;
if ($hasMatch) {
Log::info('[MatchActivityCrmData] Successfully matched CRM data', [
'activity' => $this->activityId,
'remote_search' => $this->remoteSearch,
'lead_id' => $activity->getLead()?->getId(),
'contact_id' => $activity->getContact()?->getId(),
'account_id' => $activity->getAccount()?->getId(),
'opportunity_id' => $activity->getOpportunity()?->getId(),
'stage_id' => $activity->getStage()?->getId(),
]);
} else {
Log::info('[MatchActivityCrmData] No CRM match found', [
'activity' => $this->activityId,
'remote_search' => $this->remoteSearch,
]);
}
});
} catch (Throwable $e) {
if (! $e instanceof RateLimitException) {
Log::error('[MatchActivityCrmData] Failed to match CRM data', [
'activity' => $this->activityId,
'remote_search' => $this->remoteSearch,
'exception' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
throw $e;
}
}
public function failed(Throwable $exception): void
{
$context = [
'activity' => $this->activityId,
'remote_search' => $this->remoteSearch,
'from_configuration' => $this->fromConfiguration?->getId(),
'exception' => $exception->getMessage(),
'attempts' => $this->attempts(),
];
// RateLimitException reaches failed() only when retryUntil() expires while the job is
// still held in the "released" state by HandleHubspotRateLimit — the middleware never
// re-throws, so the exception is not the direct cause of the failure in normal flow.
if ($exception instanceof RateLimitException || $exception instanceof \Illuminate\Queue\MaxAttemptsExceededException) {
Log::warning('[MatchActivityCrmData] Job permanently failed due to rate limiting', $context);
} else {
Log::error('[MatchActivityCrmData] Job permanently failed after all retries', $context);
}
\Sentry::captureException($exception);
}
private function resetCrmMappings(
Activity $activity,
ActivityRepository $activityRepository
): void {
$activity->update([
'lead_id' => null,
'contact_id' => null,
'account_id' => null,
'opportunity_id' => null,
'stage_id' => null,
]);
$participantsOldState = $activityRepository->getActivityParticipants($activity)
->map(function ($participant) {
return [
'id' => $participant->id,
'user_id' => $participant->user_id,
'contact_id' => $participant->contact_id,
'lead_id' => $participant->lead_id,
];
});
if ($participantsOldState->isNotEmpty()) {
Log::info('[MatchActivityCrmData] Participants old state', [
'activity' => $this->activityId,
'participants' => $participantsOldState->toArray(),
]);
}
$activity->participants()->update([
'user_id' => null,
'contact_id' => null,
'lead_id' => null,
]);
}
private function switchCrmConfigurationIfNeeded(Activity $activity): void
{
if ($this->fromConfiguration === null) {
return;
}
if ($activity->getCrm()?->getId() === $this->fromConfiguration->getId()) {
return;
}
Log::info('[MatchActivityCrmData] Switching CRM configuration', [
'activity' => $this->activityId,
'old_configuration' => $activity->getCrm()?->getId(),
'new_configuration' => $this->fromConfiguration->getId(),
]);
$activity->update([
'crm_configuration_id' => $this->fromConfiguration->getId(),
'crm_provider_id' => null,
]);
}
}
<?php
declare(strict_types=1);
namespace Jiminny\Jobs\Crm;
use Exception;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Connection;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Jiminny\Component\Queue\Constants;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Jobs\Job;
use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
use Jiminny\Models\Activity;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Repositories\ActivityRepository;
use Jiminny\Services\Crm\CrmActivityService;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Throwable;
class MatchActivityCrmData extends Job implements ShouldQueue, ShouldBeUnique
{
use InteractsWithQueue;
use SerializesModels;
private const int RETRY_WINDOW_MINUTES = 120;
public int $maxExceptions = 3;
private int $activityId;
private ?Configuration $fromConfiguration;
private bool $remoteSearch;
public function __construct(
int $activityId,
?Configuration $fromConfiguration = null,
bool $remoteSearch = false,
) {
$this->activityId = $activityId;
$this->fromConfiguration = $fromConfiguration;
$this->remoteSearch = $remoteSearch;
$this->onQueue(Constants::QUEUE_ANALYTICS_LOW);
}
public function middleware(): array
{
return [new HandleHubspotRateLimit()];
}
public function retryUntil(): \DateTimeInterface
{
return now()->addMinutes(self::RETRY_WINDOW_MINUTES);
}
public function uniqueId(): string
{
$configId = $this->fromConfiguration?->getId() ?? 0;
$remote = $this->remoteSearch ? 'remote' : 'local';
return "$this->activityId:$configId:$remote";
}
public function timeout(): int
{
return 300; // 5 minutes max execution time
}
public function uniqueFor(): int
{
return self::RETRY_WINDOW_MINUTES * 60 + 60;
}
public function backoff(): array
{
return [30, 90, 180];
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
* @throws Exception|Throwable
*/
public function handle(
ActivityRepository $activityRepository,
CrmActivityService $crmActivityService,
Connection $connection,
): void {
$activity = $activityRepository->findById($this->activityId);
if ($activity === null) {
throw new InvalidArgumentException('[MatchActivityCrmData] Cannot find activity.');
}
try {
$connection->transaction(function () use ($activity, $crmActivityService, $activityRepository) {
Log::info('[MatchActivityCrmData] Starting CRM data matching', [
'activity' => $this->activityId,
'remote_search' => $this->remoteSearch,
'set_configuration' => $this->fromConfiguration?->getId(),
'old_state' => [
'lead_id' => $activity->getLead()?->getId(),
'contact_id' => $activity->getContact()?->getId(),
'account_id' => $activity->getAccount()?->getId(),
'opportunity_id' => $activity->getOpportunity()?->getId(),
'stage_id' => $activity->getStage()?->getId(),
],
]);
$this->resetCrmMappings($activity, $activityRepository);
$this->switchCrmConfigurationIfNeeded($activity);
$activity->refresh();
$crmActivityService->updateCrmData(
activity: $activity,
remoteSearch: $this->remoteSearch,
);
$hasMatch = $activity->getLead() !== null
|| $activity->getContact() !== null
|| $activity->getAccount() !== null
|| $activity->getOpportunity() !== null;
if ($hasMatch) {
Log::info('[MatchActivityCrmData] Successfully matched CRM data', [
'activity' => $this->activityId,
'remote_search' => $this->remoteSearch,
'lead_id' => $activity->getLead()?->getId(),
'contact_id' => $activity->getContact()?->getId(),
'account_id' => $activity->getAccount()?->getId(),
'opportunity_id' => $activity->getOpportunity()?->getId(),
'stage_id' => $activity->getStage()?->getId(),
]);
} else {
Log::info('[MatchActivityCrmData] No CRM match found', [
'activity' => $this->activityId,
'remote_search' => $this->remoteSearch,
]);
}
});
} catch (Throwable $e) {
if (! $e instanceof RateLimitException) {
Log::error('[MatchActivityCrmData] Failed to match CRM data', [
'activity' => $this->activityId,
'remote_search' => $this->remoteSearch,
'exception' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
throw $e;
}
}
public function failed(Throwable $exception): void
{
$context = [
'activity' => $this->activityId,
'remote_search' => $this->remoteSearch,
'from_configuration' => $this->fromConfiguration?->getId(),
'exception' => $exception->getMessage(),
'attempts' => $this->attempts(),
];
// RateLimitException reaches failed() only when retryUntil() expires while the job is
// still held in the "released" state by HandleHubspotRateLimit — the middleware never
// re-throws, so the exception is not the direct cause of the failure in normal flow.
if ($exception instanceof RateLimitException || $exception instanceof \Illuminate\Queue\MaxAttemptsExceededException) {
Log::warning('[MatchActivityCrmData] Job permanently failed due to rate limiting', $context);
} else {
Log::error('[MatchActivityCrmData] Job permanently failed after all retries', $context);
}
\Sentry::captureException($exception);
}
private function resetCrmMappings(
Activity $activity,
ActivityRepository $activityRepository
): void {
$activity->update([
'lead_id' => null,
'contact_id' => null,
'account_id' => null,
'opportunity_id' => null,
'stage_id' => null,
]);
$participantsOldState = $activityRepository->getActivityParticipants($activity)
->map(function ($participant) {
return [
'id' => $participant->id,
'user_id' => $participant->user_id,
'contact_id' => $participant->contact_id,
'lead_id' => $participant->lead_id,
];
});
if ($participantsOldState->isNotEmpty()) {
Log::info('[MatchActivityCrmData] Participants old state', [
'activity' => $this->activityId,
'participants' => $participantsOldState->toArray(),
]);
}
$activity->participants()->update([
'user_id' => null,
'contact_id' => null,
'lead_id' => null,
]);
}
private function switchCrmConfigurationIfNeeded(Activity $activity): void
{
if ($this->fromConfiguration === null) {
return;
}
if ($activity->getCrm()?->getId() === $this->fromConfiguration->getId()) {
return;
}
Log::info('[MatchActivityCrmData] Switching CRM configuration', [
'activity' => $this->activityId,
'old_configuration' => $activity->getCrm()?->getId(),
'new_configuration' => $this->fromConfiguration->getId(),
]);
$activity->update([
'crm_configuration_id' => $this->fromConfiguration->getId(),
'crm_provider_id' => null,
]);
}
}
Review rate limit handli…, Editor Group 2
remote
app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...
JY-20725-handle-HS-search-rate-limit*
app (Git) - Synchronize Changes
Errors: 6, Warnings: 11, Infos: 28
6
11
28
Notifications
key, PHP extension: Premium features not active.
8.3, Language Level: 8.3
Sign In
Sign In
PHP
Editor Language Status: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 4
Ln 172, Col 1
Diff editor
Clear
Review rate limit handling for HubSpot job queue
Session history
New session
Message actions
RateLimitException.php#9-9
RateLimitException.php#9-9
Reveiw the diff. Mostly
@app/Jobs/Crm/MatchActivityCrmData.php
,
@app/Exceptions/RateLimitException.php
,
@app/Jobs/Middleware/HandleHubspotRateLimit.php
,
@app/Services/Crm/Hubspot/Client.php
and
@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php
. The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?
Thought for 5s
Thought for 5s
I'll review the implementation carefully. Let me first check how
RateLimitException
is used elsewhere and look at the test files and
PaginationState
.
Bash
Find all usages of RateLimitException outside the changed files
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/app --include="*.php" | grep -v "HandleHubspotRateLimit\|MatchActivityCrmData\|Client.php\|RateLimitException.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is
Read
PaginationState.php
PaginationState.php
Read
HandleHubspotRateLimitTest.php
HandleHubspotRateLimitTest.php
Bash
Check how RateLimitException is used in GenerateActionItemsJob
IN
grep -rn "RateLimitException\|LogicException\|RuntimeException" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
Bash
Find all catch blocks or instanceof checks for RateLimitException
IN
grep -rn "catch.*RateLimitException\|instanceof RateLimitException" /Users/lukas/jiminny/app/app --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {
Bash
Find all test references to RateLimitException
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/tests --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);
Bash
Check retry/failure handling in GenerateActionItemsJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30
Copy code to clipboard
OUT
13:use Jiminny\Exceptions\RateLimitException;
51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
56: $this->release(30);
Bash
Check retry/failure handling in GenerateAiCallScoringJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30
Copy code to clipboard
OUT
17:use Jiminny\Exceptions\RateLimitException;
68: RateLimitException |
75: $this->release(30);
Bash
Check retry config in MatchActivityCrmData
IN
grep -n "retryAfter\|RETRY_WINDOW\|retryUntil\|maxTries" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php
Copy code to clipboard
OUT
34: private const int RETRY_WINDOW_MINUTES = 30;
45: public function retryUntil(): \DateTimeInterface
47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);
77: return self::RETRY_WINDOW_MINUTES * 60 + 60;
Bash
Check the cache key format
IN
grep -n "getRateLimitCacheKey\|hubspot:ratelimit" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Copy code to clipboard
OUT
78: $cacheKey = $this->getRateLimitCacheKey();
110: private function getRateLimitCacheKey(): string
112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());
Bash
Find other jobs that might use the rate limit middleware
IN
grep -rn "class.*Middleware\|HandleHubspotRateLimit" /Users/lukas/jiminny/app/app/Jobs --include="*.php" | grep -v "HandleHubspotRateLimit.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];
Read
HubspotClientInterface.php
HubspotClientInterface.php
Thought for 73s
Thought for 73s
Now I have a complete picture. Here's my full analysis:
Review Summary
Review Summary
RuntimeException vs LogicException — Safe ✓
RuntimeException
vs
LogicException
— Safe ✓
The change is semantically correct and won't break anything.
LogicException
is for programmer errors (invalid arguments, contract violations). A rate limit from an external API is a runtime condition —
RuntimeException
is the right parent. All callers catch
RateLimitException
by name explicitly (union catches in
GenerateActionItemsJob
,
GenerateAiCallScoringJob
,
SummarizeTranscriptionJob
,
GenerateAiActivityTypeJob
) or check
instanceof RateLimitException
— none relies on the parent class type, so nothing breaks.
Issue 1 — Circuit breaker stores stale delay (most important bug)
Issue 1 — Circuit breaker stores stale delay (most important bug)
Client.php:94
Client.php:94
Copy code to clipboard
Redis::setex($cacheKey, $retryAfter, (string) $retryAfter);
The TTL is
$retryAfter
seconds, but the
stored value
is also the original
$retryAfter
. If a second job reads this cache 20 seconds later, it throws
RateLimitException(retryAfter=30)
and gets delayed 30 more seconds — even though only 10 remain. In a spike of 100 jobs, each subsequent job inflates its wait.
Fix:
store the absolute expiry timestamp, reconstruct the remaining delay when reading:
Copy code to clipboard
// on 429:
Redis::setex($cacheKey, $retryAfter, (string) (time() + $retryAfter));
// in the cached-read branch:
$remaining = max(1, (int) $cachedRetryAfter - time());
throw new RateLimitException('Hubspot rate limit (cached circuit-breaker)', $remaining);
Issue 2 — retryUntil() window may expire under sustained rate limiting
Issue 2 —
retryUntil()
window may expire under sustained rate limiting
MatchActivityCrmData.php:45-47
MatchActivityCrmData.php:45-47
retryUntil()
is calculated as
now() + 30 min
at
dispatch time
and stored in the job payload. With
MAX_RETRY_DELAY = 600s
, you only get ~3 rate-limit-triggered releases before the 30-minute window closes and the job is permanently failed. For a heavy 429 spike (daily limit, parsed as 600s), this can easily happen.
Consider increasing
RETRY_WINDOW_MINUTES
to 60 or more, or making it configurable. The
uniqueFor()
value should be updated to match.
Issue 3 — Only search() uses the circuit breaker, but other methods can still hit 429
Issue 3 — Only
search()
uses the circuit breaker, but other methods can still hit 429
Client.php:76-108
Client.php:76-108
executeRequest()
is only called from
search()
. Methods like
getOpportunityById()
,
batchReadObjects()
,
getContactByEmail()
bypass it entirely. If HubSpot's 429 is account-wide (not search-endpoint-specific), those methods will keep firing even while the circuit breaker is supposedly open. This is likely fine because the search endpoint has its own tighter quota (5 RPS), but it's worth being explicit in a comment.
Issue 4 — object $job type in middleware has no compile-time safety
Issue 4 —
object $job
type in middleware has no compile-time safety
HandleHubspotRateLimit.php:20
HandleHubspotRateLimit.php:20
Copy code to clipboard
public function handle(object $job, callable $next): void
Calling
$job->attempts()
and
$job->release()
on
object
will work at runtime since Laravel always passes jobs that use
InteractsWithQueue
, but PHPStan/psalm won't verify this and it could silently break in tests with bare mocks (as your test does use a
Job
mock that has
attempts()
— so the test is fine, but the real type contract is invisible). Minor, but worth tightening to
\Illuminate\Contracts\Queue\Job
.
What works well ✓
What works well ✓
RateLimitException
is caught by the middleware
before
it increments
maxExceptions
, so rate-limit releases don't consume the 3-exception budget for real failures.
The middleware's jitter (
random_int(0, 5)
) prevents thundering herd on release.
The log-sampling logic (
attempts <= 3 || attempts % 10 === 0
) keeps logs useful without flooding.
ShouldBeUnique
+
uniqueFor()
correctly prevents duplicate jobs queuing up during the rate-limit wait window.
RateLimitException
propagates cleanly through the DB transaction (rolled back), then through the job catch block (logged conditionally), then to the middleware which handles it.
The pagination service re-throws
RateLimitException
as-is and resets pagination state on the next job attempt — safe because offset restarts from 0.
Message actions
Expand on 1 and 2. Let's consider there are several jobs running from ten workers. They are out fired instantly, and therefore there is no cache when they look for cache, but once they receive 429, they store cache. Can there be some potential issues with that flow?
Thought for 91s
Thought for 91s
Let me trace the exact race conditions step by step.
Wave 1 — The Initial Burst (t=0)
Wave 1 — The Initial Burst (t=0)
All 10 workers execute simultaneously. Each does:
Copy code to clipboard
$cachedRetryAfter = Redis::get($cacheKey); // ALL TEN: null — cache miss
Cache doesn't exist yet, so all 10 proceed and hammer HubSpot's search endpoint simultaneously. HubSpot's search limit is 5 RPS per portal — 10 concurrent requests guarantees 429s. All 10 get 429, all 10 run:
Copy code to clipboard
Redis::setex($cacheKey, $retryAfter, (string) $retryAfter); // e.g. TTL=10, value="10"
throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);
All 10 write the same key at nearly the same millisecond. Redis just accepts last-write-wins — no error, no coordination, 10 redundant writes. Then all 10 are caught by the middleware and released:
Copy code to clipboard
delay = max(1, min(10, 600)) + random_int(0, 5) → 10 to 15 seconds
So all 10 jobs land back in the queue, scheduled to fire between t=10 and t=15.
The Core Problem — TTL Expires Before Released Jobs Fire
The Core Problem — TTL Expires Before Released Jobs Fire
The Redis key was set with
TTL = retryAfter = 10
. The jobs were released with
delay = retryAfter + jitter = 10–15
.
Copy code to clipboard
Redis key expires: t = 10
Jobs start firing: t = 10 to t = 15
These are nearly the same window. The very first job that fires at t=10 may race against the TTL expiry. Jobs firing at t=11–15
will find an empty cache
because the key already expired. So the circuit breaker provides
zero protection
for the second wave of the same jobs. They all miss the cache again and charge straight at HubSpot.
This is the core mismatch: the release delay was derived from the same value as the TTL, so they expire together. The circuit breaker is only effective for
new, independent jobs
that arrive during the t=0–10 window, not for the 10 jobs that triggered the 429 in the first place.
Issue 1 Compounded — Stale Delay Makes It Worse
Issue 1 Compounded — Stale Delay Makes It Worse
Say a new job (job #11) arrives at t=5. It reads the cache and gets
"10"
. The middleware releases it for 10 seconds → fires at t=15. But the rate limit ended at t=10. It waited 5 extra seconds for nothing, and it fires exactly when the 10 original jobs are also firing (t=10–15). So instead of smoothing the load, the stale delay actually
bunches job #11 into the same second wave
....
|
NULL
|
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivit /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php...
|
NULL
|
NULL
|
|
20411
|
NULL
|
0
|
2026-05-11T15:24:30.376458+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778513070376_m1.jpg...
|
Code
|
Client.php — app — 9 problems in this file • Modif Client.php — app — 9 problems in this file • Modified...
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G) - 17 pending changes
17
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Testing
Claude Code
SOURCE CONTROL
SOURCE CONTROL
Changes Section
CHANGES
CHANGES
Commit Changes on "JY-20725-handle-HS-search-rate-limit"
Commit
More Actions...
Changes
17
.env.local
M
.env.nikilocal
U
.env.other
U
ids.txt
U
raw_sql_query.sql
U
WEBHOOK_FILTERING_IMPLEMENTATION.md
U
JiminnyDebugCommand.php
app/Console/Commands
M
SimulateWebhooksCommand.php
app/Console/Commands/Crm/Hubspot
U
CreateMockAskJiminnyReportResultCommand.php
app/Console/Commands/Reports
U
RateLimitException.php
app/Exceptions
M
MatchActivityCrmData.php
app/Jobs/Crm
M
Client.php
app/Services/Crm/Hubspot
9, M
logging.php
config
M
favicon.ico
public
U
HandleHubspotRateLimitTest.php
tests/Unit/Jobs/Middleware
M
CanAccessAiReportsTest.php
tests/Unit/Policies
U
ClientTest.php
tests/Unit/Services/Crm/Hubspot
M
Graph Section
GRAPH
GRAPH
JY-20725 add more tests
Lukas Kovalik
JY-20725-handle-HS-search-rate-limit
JY-20725 add tests
Lukas Kovalik
JY-20725 add HS rate limit handling on activities rematching
Lukas Kovalik
Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
origin/master
Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit
mihailmihaylovjiminny
JY-20819: Increase download transcription rate limit
mihail.mihaylov
JY-20818 move ask jiminny reports to its own datadog metric
Lukas Kovalik
Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge branch 'master' into JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge pull request #12047 from jiminny/JY-20662-remove-word-boost
ilian-jiminny
JY-20662 return void
ilian
JY-20662 Removed word_boost parameter from Assembly transcription request
ilian
JY-20817: Fix deleting old tracks
mihail.mihaylov
Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge branch 'master' into JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update
ilian-jiminny
JY-19938 Added consent information and recording error reason to Elastic Search mapping
ilian
JY-19938 reorder array map
ilian
JY-19938: Address PR review comments
ilian
JY-19938 lint fixes
ilian
HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1
ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1
Client.php (Working Tree) (Client.php), Editor Group 1
MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1
MatchActivityCrmData.php, Editor Group 1
RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1
Client.php, preview, Editor Group 1
<?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 {
<?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 {
Review rate limit handli…, Editor Group 2
remote
app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...
JY-20725-handle-HS-search-rate-limit*
app (Git) - Synchronize Changes
Errors: 6, Warnings: 11, Infos: 28
6
11
28
Notifications
key, PHP extension: Premium features not active.
8.3, Language Level: 8.3
Sign In
Sign In
PHP
Editor Language Status: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 4
Ln 120, Col 1
Diff editor
Clear
Review rate limit handling for HubSpot job queue
Session history
New session
Message actions
RateLimitException.php#9-9
RateLimitException.php#9-9
Reveiw the diff. Mostly
@app/Jobs/Crm/MatchActivityCrmData.php
,
@app/Exceptions/RateLimitException.php
,
@app/Jobs/Middleware/HandleHubspotRateLimit.php
,
@app/Services/Crm/Hubspot/Client.php
and
@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php
. The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?
Thought for 5s
Thought for 5s
I'll review the implementation carefully. Let me first check how
RateLimitException
is used elsewhere and look at the test files and
PaginationState
.
Bash
Find all usages of RateLimitException outside the changed files
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/app --include="*.php" | grep -v "HandleHubspotRateLimit\|MatchActivityCrmData\|Client.php\|RateLimitException.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is
Read
PaginationState.php
PaginationState.php
Read
HandleHubspotRateLimitTest.php
HandleHubspotRateLimitTest.php
Bash
Check how RateLimitException is used in GenerateActionItemsJob
IN
grep -rn "RateLimitException\|LogicException\|RuntimeException" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
Bash
Find all catch blocks or instanceof checks for RateLimitException
IN
grep -rn "catch.*RateLimitException\|instanceof RateLimitException" /Users/lukas/jiminny/app/app --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {
Bash
Find all test references to RateLimitException
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/tests --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);
Bash
Check retry/failure handling in GenerateActionItemsJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30
Copy code to clipboard
OUT
13:use Jiminny\Exceptions\RateLimitException;
51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
56: $this->release(30);
Bash
Check retry/failure handling in GenerateAiCallScoringJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30
Copy code to clipboard
OUT
17:use Jiminny\Exceptions\RateLimitException;
68: RateLimitException |
75: $this->release(30);
Bash
Check retry config in MatchActivityCrmData
IN
grep -n "retryAfter\|RETRY_WINDOW\|retryUntil\|maxTries" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php
Copy code to clipboard
OUT
34: private const int RETRY_WINDOW_MINUTES = 30;
45: public function retryUntil(): \DateTimeInterface
47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);
77: return self::RETRY_WINDOW_MINUTES * 60 + 60;
Bash
Check the cache key format
IN
grep -n "getRateLimitCacheKey\|hubspot:ratelimit" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Copy code to clipboard
OUT
78: $cacheKey = $this->getRateLimitCacheKey();
110: private function getRateLimitCacheKey(): string
112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());
Bash
Find other jobs that might use the rate limit middleware
IN
grep -rn "class.*Middleware\|HandleHubspotRateLimit" /Users/lukas/jiminny/app/app/Jobs --include="*.php" | grep -v "HandleHubspotRateLimit.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];
Read
HubspotClientInterface.php
HubspotClientInterface.php
Thought for 73s
Thought for 73s
Now I have a complete picture. Here's my full analysis:
Review Summary
Review Summary
RuntimeException vs LogicException — Safe ✓
RuntimeException
vs
LogicException
— Safe ✓
The change is semantically correct and won't break anything.
LogicException
is for programmer errors (invalid arguments, contract violations). A rate limit from an external API is a runtime condition —
RuntimeException
is the right parent. All callers catch
RateLimitException
by name explicitly (union catches in
GenerateActionItemsJob
,
GenerateAiCallScoringJob
,
SummarizeTranscriptionJob
,
GenerateAiActivityTypeJob
) or check
instanceof RateLimitException
— none relies on the parent class type, so nothing breaks.
Issue 1 — Circuit breaker stores stale delay (most important bug)
Issue 1 — Circuit breaker stores stale delay (most important bug)
Client.php:94
Client.php:94
Copy code to clipboard
Redis::setex($cacheKey, $retryAfter, (string) $retryAfter);
The TTL is
$retryAfter
seconds, but the
stored value
is also the original
$retryAfter
. If a second job reads this cache 20 seconds later, it throws
RateLimitException(retryAfter=30)
and gets delayed 30 more seconds — even though only 10 remain. In a spike of 100 jobs, each subsequent job inflates its wait.
Fix:
store the absolute expiry timestamp, reconstruct the remaining delay when reading:
Copy code to clipboard
// on 429:
Redis::setex($cacheKey, $retryAfter, (string) (time() + $retryAfter));
// in the cached-read branch:
$remaining = max(1, (int) $cachedRetryAfter - time());
throw new RateLimitException('Hubspot rate limit (cached circuit-breaker)', $remaining);
Issue 2 — retryUntil() window may expire under sustained rate limiting
Issue 2 —
retryUntil()
window may expire under sustained rate limiting
MatchActivityCrmData.php:45-47
MatchActivityCrmData.php:45-47
retryUntil()
is calculated as
now() + 30 min
at
dispatch time
and stored in the job payload. With
MAX_RETRY_DELAY = 600s
, you only get ~3 rate-limit-triggered releases before the 30-minute window closes and the job is permanently failed. For a heavy 429 spike (daily limit, parsed as 600s), this can easily happen.
Consider increasing
RETRY_WINDOW_MINUTES
to 60 or more, or making it configurable. The
uniqueFor()
value should be updated to match.
Issue 3 — Only search() uses the circuit breaker, but other methods can still hit 429
Issue 3 — Only
search()
uses the circuit breaker, but other methods can still hit 429
Client.php:76-108
Client.php:76-108
executeRequest()
is only called from...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Explorer (⇧⌘E)","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Search (⇧⌘F)","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Source Control (⌃⇧G) - 17 pending changes","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":true},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"17","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Run and Debug (⇧⌘D)","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Remote Explorer","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Extensions (⇧⌘X) - 2 require update","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"2","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Testing","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Claude Code","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"SOURCE CONTROL","depth":17,"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"SOURCE CONTROL","depth":18,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Changes Section","depth":21,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"CHANGES","depth":22,"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"CHANGES","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"","depth":28,"on_screen":true,"value":"","role_description":"editor","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":29,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Commit Changes on \"JY-20725-handle-HS-search-rate-limit\"","depth":26,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Commit","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"More Actions...","depth":26,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Changes","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"17","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.local","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.nikilocal","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.other","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ids.txt","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"raw_sql_query.sql","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"WEBHOOK_FILTERING_IMPLEMENTATION.md","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JiminnyDebugCommand.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"SimulateWebhooksCommand.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands/Crm/Hubspot","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CreateMockAskJiminnyReportResultCommand.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands/Reports","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Exceptions","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"MatchActivityCrmData.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Jobs/Crm","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Client.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Services/Crm/Hubspot","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"9, M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"logging.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"config","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"favicon.ico","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"public","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Jobs/Middleware","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CanAccessAiReportsTest.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Policies","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClientTest.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Services/Crm/Hubspot","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Graph Section","depth":21,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"GRAPH","depth":22,"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"GRAPH","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add more tests","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add tests","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"origin/master","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20819: Increase download transcription rate limit","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihail.mihaylov","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20818 move ask jiminny reports to its own datadog metric","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-20817-fix-deleting-old-tracks","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12047 from jiminny/JY-20662-remove-word-boost","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20662 return void","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20662 Removed word_boost parameter from Assembly transcription request","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20817: Fix deleting old tracks","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihail.mihaylov","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-19938-bot-status-in-csv-es","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 Added consent information and recording error reason to Elastic Search mapping","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 reorder array map","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938: Address PR review comments","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 lint fixes","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1","depth":28,"on_screen":false,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Client.php (Working Tree) (Client.php), Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"MatchActivityCrmData.php, Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Client.php, preview, Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"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 {","depth":28,"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 {","role_description":"editor","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","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 {","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Review rate limit handli…, Editor Group 2","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXButton","text":"remote","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit*","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"app (Git) - Synchronize Changes","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Errors: 6, Warnings: 11, Infos: 28","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"6","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"28","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Notifications","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"key, PHP extension: Premium features not active.","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"8.3, Language Level: 8.3","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sign In","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Sign In","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"PHP","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Editor Language Status: $(copilot) No inline suggestion available, Inline suggestions","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"LF","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UTF-8","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Spaces: 4","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Ln 120, Col 1","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Diff editor","depth":12,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Clear","depth":12,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Review rate limit handling for HubSpot job queue","depth":19,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Session history","depth":19,"on_screen":true,"help_text":"Session history","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New session","depth":19,"on_screen":true,"help_text":"New session","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Message actions","depth":24,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"RateLimitException.php#9-9","depth":23,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"RateLimitException.php#9-9","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Reveiw the diff. Mostly","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Jobs/Crm/MatchActivityCrmData.php","depth":26,"on_screen":false,"help_text":"Open app/Jobs/Crm/MatchActivityCrmData.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Exceptions/RateLimitException.php","depth":26,"on_screen":false,"help_text":"Open app/Exceptions/RateLimitException.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Jobs/Middleware/HandleHubspotRateLimit.php","depth":26,"on_screen":false,"help_text":"Open app/Jobs/Middleware/HandleHubspotRateLimit.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Services/Crm/Hubspot/Client.php","depth":26,"on_screen":false,"help_text":"Open app/Services/Crm/Hubspot/Client.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"and","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php","depth":26,"on_screen":false,"help_text":"Open app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":". The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Thought for 5s","depth":23,"on_screen":false,"role_description":"disclosure triangle","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thought for 5s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"I'll review the implementation carefully. Let me first check how","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is used elsewhere and look at the test files and","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PaginationState","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all usages of RateLimitException outside the changed files","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\" /Users/lukas/jiminny/app/app --include=\"*.php\" | grep -v \"HandleHubspotRateLimit\\|MatchActivityCrmData\\|Client.php\\|RateLimitException.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |\n/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"PaginationState.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"PaginationState.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"HandleHubspotRateLimitTest.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check how RateLimitException is used in GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\\|LogicException\\|RuntimeException\" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all catch blocks or instanceof checks for RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"catch.*RateLimitException\\|instanceof RateLimitException\" /Users/lukas/jiminny/app/app --include=\"*.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls\n/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {\n/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all test references to RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\" /Users/lukas/jiminny/app/tests --include=\"*.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry/failure handling in GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"RateLimitException\\|retryUntil\\|backoff\\|maxExceptions\\|maxTries\\|release\\|failed\" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"13:use Jiminny\\Exceptions\\RateLimitException;\n51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n56: $this->release(30);","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry/failure handling in GenerateAiCallScoringJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"RateLimitException\\|retryUntil\\|backoff\\|maxExceptions\\|maxTries\\|release\\|failed\" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"17:use Jiminny\\Exceptions\\RateLimitException;\n68: RateLimitException |\n75: $this->release(30);","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry config in MatchActivityCrmData","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"retryAfter\\|RETRY_WINDOW\\|retryUntil\\|maxTries\" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"34: private const int RETRY_WINDOW_MINUTES = 30;\n45: public function retryUntil(): \\DateTimeInterface\n47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n77: return self::RETRY_WINDOW_MINUTES * 60 + 60;","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check the cache key format","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"getRateLimitCacheKey\\|hubspot:ratelimit\" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"78: $cacheKey = $this->getRateLimitCacheKey();\n110: private function getRateLimitCacheKey(): string\n112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find other jobs that might use the rate limit middleware","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"class.*Middleware\\|HandleHubspotRateLimit\" /Users/lukas/jiminny/app/app/Jobs --include=\"*.php\" | grep -v \"HandleHubspotRateLimit.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\\Jobs\\Middleware\\HandleHubspotRateLimit;\n/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"HubspotClientInterface.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"HubspotClientInterface.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Thought for 73s","depth":23,"on_screen":false,"role_description":"disclosure triangle","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thought for 73s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Now I have a complete picture. Here's my full analysis:","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Review Summary","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Review Summary","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"RuntimeException vs LogicException — Safe ✓","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"RuntimeException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"vs","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LogicException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"— Safe ✓","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The change is semantically correct and won't break anything.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LogicException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is for programmer errors (invalid arguments, contract violations). A rate limit from an external API is a runtime condition —","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RuntimeException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is the right parent. All callers catch","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"by name explicitly (union catches in","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateAiCallScoringJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SummarizeTranscriptionJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateAiActivityTypeJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":") or check","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"instanceof RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"— none relies on the parent class type, so nothing breaks.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 1 — Circuit breaker stores stale delay (most important bug)","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 1 — Circuit breaker stores stale delay (most important bug)","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"Client.php:94","depth":23,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Client.php:94","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Redis::setex($cacheKey, $retryAfter, (string) $retryAfter);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The TTL is","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"$retryAfter","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"seconds, but the","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"stored value","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is also the original","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"$retryAfter","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":". If a second job reads this cache 20 seconds later, it throws","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException(retryAfter=30)","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"and gets delayed 30 more seconds — even though only 10 remain. In a spike of 100 jobs, each subsequent job inflates its wait.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Fix:","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"store the absolute expiry timestamp, reconstruct the remaining delay when reading:","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"// on 429:\nRedis::setex($cacheKey, $retryAfter, (string) (time() + $retryAfter));\n\n// in the cached-read branch:\n$remaining = max(1, (int) $cachedRetryAfter - time());\nthrow new RateLimitException('Hubspot rate limit (cached circuit-breaker)', $remaining);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 2 — retryUntil() window may expire under sustained rate limiting","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 2 —","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"retryUntil()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"window may expire under sustained rate limiting","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"MatchActivityCrmData.php:45-47","depth":23,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"MatchActivityCrmData.php:45-47","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"retryUntil()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is calculated as","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"now() + 30 min","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"at","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"dispatch time","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"and stored in the job payload. With","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MAX_RETRY_DELAY = 600s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":", you only get ~3 rate-limit-triggered releases before the 30-minute window closes and the job is permanently failed. For a heavy 429 spike (daily limit, parsed as 600s), this can easily happen.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Consider increasing","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RETRY_WINDOW_MINUTES","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"to 60 or more, or making it configurable. The","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"uniqueFor()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"value should be updated to match.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 3 — Only search() uses the circuit breaker, but other methods can still hit 429","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 3 — Only","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"search()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"uses the circuit breaker, but other methods can still hit 429","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"Client.php:76-108","depth":23,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Client.php:76-108","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"executeRequest()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is only called from","depth":23,"on_screen":false,"role_description":"text"}]...
|
9892511419285144
|
2153254831735572582
|
idle
|
accessibility
|
NULL
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G) - 17 pending changes
17
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Testing
Claude Code
SOURCE CONTROL
SOURCE CONTROL
Changes Section
CHANGES
CHANGES
Commit Changes on "JY-20725-handle-HS-search-rate-limit"
Commit
More Actions...
Changes
17
.env.local
M
.env.nikilocal
U
.env.other
U
ids.txt
U
raw_sql_query.sql
U
WEBHOOK_FILTERING_IMPLEMENTATION.md
U
JiminnyDebugCommand.php
app/Console/Commands
M
SimulateWebhooksCommand.php
app/Console/Commands/Crm/Hubspot
U
CreateMockAskJiminnyReportResultCommand.php
app/Console/Commands/Reports
U
RateLimitException.php
app/Exceptions
M
MatchActivityCrmData.php
app/Jobs/Crm
M
Client.php
app/Services/Crm/Hubspot
9, M
logging.php
config
M
favicon.ico
public
U
HandleHubspotRateLimitTest.php
tests/Unit/Jobs/Middleware
M
CanAccessAiReportsTest.php
tests/Unit/Policies
U
ClientTest.php
tests/Unit/Services/Crm/Hubspot
M
Graph Section
GRAPH
GRAPH
JY-20725 add more tests
Lukas Kovalik
JY-20725-handle-HS-search-rate-limit
JY-20725 add tests
Lukas Kovalik
JY-20725 add HS rate limit handling on activities rematching
Lukas Kovalik
Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
origin/master
Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit
mihailmihaylovjiminny
JY-20819: Increase download transcription rate limit
mihail.mihaylov
JY-20818 move ask jiminny reports to its own datadog metric
Lukas Kovalik
Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge branch 'master' into JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge pull request #12047 from jiminny/JY-20662-remove-word-boost
ilian-jiminny
JY-20662 return void
ilian
JY-20662 Removed word_boost parameter from Assembly transcription request
ilian
JY-20817: Fix deleting old tracks
mihail.mihaylov
Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge branch 'master' into JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update
ilian-jiminny
JY-19938 Added consent information and recording error reason to Elastic Search mapping
ilian
JY-19938 reorder array map
ilian
JY-19938: Address PR review comments
ilian
JY-19938 lint fixes
ilian
HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1
ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1
Client.php (Working Tree) (Client.php), Editor Group 1
MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1
MatchActivityCrmData.php, Editor Group 1
RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1
Client.php, preview, Editor Group 1
<?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 {
<?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 {
Review rate limit handli…, Editor Group 2
remote
app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...
JY-20725-handle-HS-search-rate-limit*
app (Git) - Synchronize Changes
Errors: 6, Warnings: 11, Infos: 28
6
11
28
Notifications
key, PHP extension: Premium features not active.
8.3, Language Level: 8.3
Sign In
Sign In
PHP
Editor Language Status: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 4
Ln 120, Col 1
Diff editor
Clear
Review rate limit handling for HubSpot job queue
Session history
New session
Message actions
RateLimitException.php#9-9
RateLimitException.php#9-9
Reveiw the diff. Mostly
@app/Jobs/Crm/MatchActivityCrmData.php
,
@app/Exceptions/RateLimitException.php
,
@app/Jobs/Middleware/HandleHubspotRateLimit.php
,
@app/Services/Crm/Hubspot/Client.php
and
@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php
. The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?
Thought for 5s
Thought for 5s
I'll review the implementation carefully. Let me first check how
RateLimitException
is used elsewhere and look at the test files and
PaginationState
.
Bash
Find all usages of RateLimitException outside the changed files
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/app --include="*.php" | grep -v "HandleHubspotRateLimit\|MatchActivityCrmData\|Client.php\|RateLimitException.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is
Read
PaginationState.php
PaginationState.php
Read
HandleHubspotRateLimitTest.php
HandleHubspotRateLimitTest.php
Bash
Check how RateLimitException is used in GenerateActionItemsJob
IN
grep -rn "RateLimitException\|LogicException\|RuntimeException" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
Bash
Find all catch blocks or instanceof checks for RateLimitException
IN
grep -rn "catch.*RateLimitException\|instanceof RateLimitException" /Users/lukas/jiminny/app/app --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {
Bash
Find all test references to RateLimitException
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/tests --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);
Bash
Check retry/failure handling in GenerateActionItemsJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30
Copy code to clipboard
OUT
13:use Jiminny\Exceptions\RateLimitException;
51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
56: $this->release(30);
Bash
Check retry/failure handling in GenerateAiCallScoringJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30
Copy code to clipboard
OUT
17:use Jiminny\Exceptions\RateLimitException;
68: RateLimitException |
75: $this->release(30);
Bash
Check retry config in MatchActivityCrmData
IN
grep -n "retryAfter\|RETRY_WINDOW\|retryUntil\|maxTries" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php
Copy code to clipboard
OUT
34: private const int RETRY_WINDOW_MINUTES = 30;
45: public function retryUntil(): \DateTimeInterface
47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);
77: return self::RETRY_WINDOW_MINUTES * 60 + 60;
Bash
Check the cache key format
IN
grep -n "getRateLimitCacheKey\|hubspot:ratelimit" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Copy code to clipboard
OUT
78: $cacheKey = $this->getRateLimitCacheKey();
110: private function getRateLimitCacheKey(): string
112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());
Bash
Find other jobs that might use the rate limit middleware
IN
grep -rn "class.*Middleware\|HandleHubspotRateLimit" /Users/lukas/jiminny/app/app/Jobs --include="*.php" | grep -v "HandleHubspotRateLimit.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];
Read
HubspotClientInterface.php
HubspotClientInterface.php
Thought for 73s
Thought for 73s
Now I have a complete picture. Here's my full analysis:
Review Summary
Review Summary
RuntimeException vs LogicException — Safe ✓
RuntimeException
vs
LogicException
— Safe ✓
The change is semantically correct and won't break anything.
LogicException
is for programmer errors (invalid arguments, contract violations). A rate limit from an external API is a runtime condition —
RuntimeException
is the right parent. All callers catch
RateLimitException
by name explicitly (union catches in
GenerateActionItemsJob
,
GenerateAiCallScoringJob
,
SummarizeTranscriptionJob
,
GenerateAiActivityTypeJob
) or check
instanceof RateLimitException
— none relies on the parent class type, so nothing breaks.
Issue 1 — Circuit breaker stores stale delay (most important bug)
Issue 1 — Circuit breaker stores stale delay (most important bug)
Client.php:94
Client.php:94
Copy code to clipboard
Redis::setex($cacheKey, $retryAfter, (string) $retryAfter);
The TTL is
$retryAfter
seconds, but the
stored value
is also the original
$retryAfter
. If a second job reads this cache 20 seconds later, it throws
RateLimitException(retryAfter=30)
and gets delayed 30 more seconds — even though only 10 remain. In a spike of 100 jobs, each subsequent job inflates its wait.
Fix:
store the absolute expiry timestamp, reconstruct the remaining delay when reading:
Copy code to clipboard
// on 429:
Redis::setex($cacheKey, $retryAfter, (string) (time() + $retryAfter));
// in the cached-read branch:
$remaining = max(1, (int) $cachedRetryAfter - time());
throw new RateLimitException('Hubspot rate limit (cached circuit-breaker)', $remaining);
Issue 2 — retryUntil() window may expire under sustained rate limiting
Issue 2 —
retryUntil()
window may expire under sustained rate limiting
MatchActivityCrmData.php:45-47
MatchActivityCrmData.php:45-47
retryUntil()
is calculated as
now() + 30 min
at
dispatch time
and stored in the job payload. With
MAX_RETRY_DELAY = 600s
, you only get ~3 rate-limit-triggered releases before the 30-minute window closes and the job is permanently failed. For a heavy 429 spike (daily limit, parsed as 600s), this can easily happen.
Consider increasing
RETRY_WINDOW_MINUTES
to 60 or more, or making it configurable. The
uniqueFor()
value should be updated to match.
Issue 3 — Only search() uses the circuit breaker, but other methods can still hit 429
Issue 3 — Only
search()
uses the circuit breaker, but other methods can still hit 429
Client.php:76-108
Client.php:76-108
executeRequest()
is only called from...
|
NULL
|
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/ /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php...
|
NULL
|
NULL
|
|
20412
|
NULL
|
0
|
2026-05-11T15:24:30.479983+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778513070479_m2.jpg...
|
Code
|
Client.php — app — 9 problems in this file • Modif Client.php — app — 9 problems in this file • Modified...
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G) - 17 pending changes
17
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Testing
Claude Code
SOURCE CONTROL
SOURCE CONTROL
Changes Section
CHANGES
CHANGES
Commit Changes on "JY-20725-handle-HS-search-rate-limit"
Commit
More Actions...
Changes
17
.env.local
M
.env.nikilocal
U
.env.other
U
ids.txt
U
raw_sql_query.sql
U
WEBHOOK_FILTERING_IMPLEMENTATION.md
U
JiminnyDebugCommand.php
app/Console/Commands
M
SimulateWebhooksCommand.php
app/Console/Commands/Crm/Hubspot
U
CreateMockAskJiminnyReportResultCommand.php
app/Console/Commands/Reports
U
RateLimitException.php
app/Exceptions
M
MatchActivityCrmData.php
app/Jobs/Crm
M
Client.php
app/Services/Crm/Hubspot
9, M
logging.php
config
M
favicon.ico
public
U
HandleHubspotRateLimitTest.php
tests/Unit/Jobs/Middleware
M
CanAccessAiReportsTest.php
tests/Unit/Policies
U
ClientTest.php
tests/Unit/Services/Crm/Hubspot
M
Graph Section
GRAPH
GRAPH
JY-20725 add more tests
Lukas Kovalik
JY-20725-handle-HS-search-rate-limit
JY-20725 add tests
Lukas Kovalik
JY-20725 add HS rate limit handling on activities rematching
Lukas Kovalik
Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
origin/master
Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit
mihailmihaylovjiminny
JY-20819: Increase download transcription rate limit
mihail.mihaylov
JY-20818 move ask jiminny reports to its own datadog metric
Lukas Kovalik
Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge branch 'master' into JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge pull request #12047 from jiminny/JY-20662-remove-word-boost
ilian-jiminny
JY-20662 return void
ilian
JY-20662 Removed word_boost parameter from Assembly transcription request
ilian
JY-20817: Fix deleting old tracks
mihail.mihaylov
Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge branch 'master' into JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update
ilian-jiminny
JY-19938 Added consent information and recording error reason to Elastic Search mapping
ilian
JY-19938 reorder array map
ilian
JY-19938: Address PR review comments
ilian
JY-19938 lint fixes
ilian
HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1
ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1
Client.php (Working Tree) (Client.php), Editor Group 1
MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1
MatchActivityCrmData.php, Editor Group 1
RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1
Client.php, preview, Editor Group 1
<?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 {
<?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 {
Review rate limit handli…, Editor Group 2
remote
app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...
JY-20725-handle-HS-search-rate-limit*
app (Git) - Synchronize Changes
Errors: 6, Warnings: 11, Infos: 28
6
11
28
Notifications
key, PHP extension: Premium features not active.
8.3, Language Level: 8.3
Sign In
Sign In
PHP
Editor Language Status: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 4
Ln 120, Col 1
Diff editor
Clear
Review rate limit handling for HubSpot job queue
Session history
New session
Message actions
RateLimitException.php#9-9
RateLimitException.php#9-9
Reveiw the diff. Mostly
@app/Jobs/Crm/MatchActivityCrmData.php
,
@app/Exceptions/RateLimitException.php
,
@app/Jobs/Middleware/HandleHubspotRateLimit.php
,
@app/Services/Crm/Hubspot/Client.php
and
@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php
. The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?
Thought for 5s
Thought for 5s
I'll review the implementation carefully. Let me first check how
RateLimitException
is used elsewhere and look at the test files and
PaginationState
.
Bash
Find all usages of RateLimitException outside the changed files
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/app --include="*.php" | grep -v "HandleHubspotRateLimit\|MatchActivityCrmData\|Client.php\|RateLimitException.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is
Read
PaginationState.php
PaginationState.php
Read
HandleHubspotRateLimitTest.php
HandleHubspotRateLimitTest.php
Bash
Check how RateLimitException is used in GenerateActionItemsJob
IN
grep -rn "RateLimitException\|LogicException\|RuntimeException" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
Bash
Find all catch blocks or instanceof checks for RateLimitException
IN
grep -rn "catch.*RateLimitException\|instanceof RateLimitException" /Users/lukas/jiminny/app/app --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {
Bash
Find all test references to RateLimitException
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/tests --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);
Bash
Check retry/failure handling in GenerateActionItemsJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30
Copy code to clipboard
OUT
13:use Jiminny\Exceptions\RateLimitException;
51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
56: $this->release(30);
Bash
Check retry/failure handling in GenerateAiCallScoringJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30
Copy code to clipboard
OUT
17:use Jiminny\Exceptions\RateLimitException;
68: RateLimitException |
75: $this->release(30);
Bash
Check retry config in MatchActivityCrmData
IN
grep -n "retryAfter\|RETRY_WINDOW\|retryUntil\|maxTries" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php
Copy code to clipboard
OUT
34: private const int RETRY_WINDOW_MINUTES = 30;
45: public function retryUntil(): \DateTimeInterface
47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);
77: return self::RETRY_WINDOW_MINUTES * 60 + 60;
Bash
Check the cache key format
IN
grep -n "getRateLimitCacheKey\|hubspot:ratelimit" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Copy code to clipboard
OUT
78: $cacheKey = $this->getRateLimitCacheKey();
110: private function getRateLimitCacheKey(): string
112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());
Bash
Find other jobs that might use the rate limit middleware
IN
grep -rn "class.*Middleware\|HandleHubspotRateLimit" /Users/lukas/jiminny/app/app/Jobs --include="*.php" | grep -v "HandleHubspotRateLimit.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];
Read
HubspotClientInterface.php
HubspotClientInterface.php
Thought for 73s
Thought for 73s
Now I have a complete picture. Here's my full analysis:
Review Summary
Review Summary
RuntimeException vs LogicException — Safe ✓
RuntimeException
vs
LogicException
— Safe ✓
The change is semantically correct and won't break anything.
LogicException
is for programmer errors (invalid arguments, contract violations). A rate limit from an external API is a runtime condition —
RuntimeException
is the right parent. All callers catch
RateLimitException
by name explicitly (union catches in
GenerateActionItemsJob
,
GenerateAiCallScoringJob
,
SummarizeTranscriptionJob
,
GenerateAiActivityTypeJob
) or check
instanceof RateLimitException
— none relies on the parent class type, so nothing breaks.
Issue 1 — Circuit breaker stores stale delay (most important bug)
Issue 1 — Circuit breaker stores stale delay (most important bug)
Client.php:94
Client.php:94
Copy code to clipboard
Redis::setex($cacheKey, $retryAfter, (string) $retryAfter);
The TTL is
$retryAfter
seconds, but the
stored value
is also the original
$retryAfter
. If a second job reads this cache 20 seconds later, it throws
RateLimitException(retryAfter=30)
and gets delayed 30 more seconds — even though only 10 remain. In a spike of 100 jobs, each subsequent job inflates its wait.
Fix:
store the absolute expiry timestamp, reconstruct the remaining delay when reading:...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Explorer (⇧⌘E)","depth":19,"bounds":{"left":0.0,"top":0.047885075,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.057462092,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Search (⇧⌘F)","depth":19,"bounds":{"left":0.0,"top":0.08619314,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.09577015,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Source Control (⌃⇧G) - 17 pending changes","depth":19,"bounds":{"left":0.0,"top":0.1245012,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":true},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.13407822,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"17","depth":22,"bounds":{"left":0.00831117,"top":0.1452514,"width":0.003656915,"height":0.008778931},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.008643617,"top":0.14604948,"width":0.0016622341,"height":0.007980846}},{"char_start":1,"char_count":1,"bounds":{"left":0.009973404,"top":0.14604948,"width":0.0019946808,"height":0.007980846}}],"role_description":"text"},{"role":"AXRadioButton","text":"Run and Debug (⇧⌘D)","depth":19,"bounds":{"left":0.0,"top":0.16280925,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.17238627,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Remote Explorer","depth":19,"bounds":{"left":0.0,"top":0.20111732,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.21069433,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Extensions (⇧⌘X) - 2 require update","depth":19,"bounds":{"left":0.0,"top":0.23942538,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.2490024,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"2","depth":22,"bounds":{"left":0.009640957,"top":0.2601756,"width":0.0019946808,"height":0.008778931},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Testing","depth":19,"bounds":{"left":0.0,"top":0.27773345,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.28731045,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Claude Code","depth":19,"bounds":{"left":0.0,"top":0.3160415,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"SOURCE CONTROL","depth":17,"bounds":{"left":0.022606382,"top":0.047885075,"width":0.034242023,"height":0.02793296},"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"SOURCE CONTROL","depth":18,"bounds":{"left":0.022606382,"top":0.056664005,"width":0.034242023,"height":0.0103751},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Changes Section","depth":21,"bounds":{"left":0.015957447,"top":0.07581804,"width":0.09940159,"height":0.017557861},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"","depth":23,"bounds":{"left":0.01662234,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"CHANGES","depth":22,"bounds":{"left":0.022606382,"top":0.07581804,"width":0.01861702,"height":0.017557861},"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"CHANGES","depth":23,"bounds":{"left":0.022606382,"top":0.079010375,"width":0.01861702,"height":0.0103751},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"","depth":28,"bounds":{"left":0.024601065,"top":0.09896249,"width":0.07247341,"height":0.015961692},"on_screen":true,"value":"","role_description":"editor","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":29,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Commit Changes on \"JY-20725-handle-HS-search-rate-limit\"","depth":26,"bounds":{"left":0.022273935,"top":0.1245012,"width":0.08045213,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.052526597,"top":0.12849163,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Commit","depth":27,"bounds":{"left":0.058843084,"top":0.1292897,"width":0.014960106,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.059175532,"top":0.1292897,"width":0.0029920214,"height":0.011173184}},{"char_start":1,"char_count":5,"bounds":{"left":0.061835106,"top":0.1292897,"width":0.011968086,"height":0.011173184}}],"role_description":"text"},{"role":"AXPopUpButton","text":"More Actions...","depth":26,"bounds":{"left":0.10305851,"top":0.1245012,"width":0.00831117,"height":0.0207502},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.1043883,"top":0.12849163,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.019614361,"top":0.15163608,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Changes","depth":27,"bounds":{"left":0.025930852,"top":0.15163608,"width":0.01761968,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.025930852,"top":0.15243416,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":6,"bounds":{"left":0.028922873,"top":0.15243416,"width":0.014960106,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"17","depth":27,"bounds":{"left":0.10571808,"top":0.15243416,"width":0.0039893617,"height":0.0103751},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.10605053,"top":0.15243416,"width":0.0016622341,"height":0.0103751}},{"char_start":1,"char_count":1,"bounds":{"left":0.10771277,"top":0.15243416,"width":0.0019946808,"height":0.0103751}}],"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.16759777,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.local","depth":28,"bounds":{"left":0.028590426,"top":0.16919394,"width":0.01861702,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.16999201,"width":0.0013297872,"height":0.011971269}},{"char_start":1,"char_count":9,"bounds":{"left":0.029920213,"top":0.16999201,"width":0.01761968,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.16999201,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.18515563,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.nikilocal","depth":28,"bounds":{"left":0.028590426,"top":0.1867518,"width":0.025598405,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.18754987,"width":0.0013297872,"height":0.011971269}},{"char_start":1,"char_count":13,"bounds":{"left":0.029920213,"top":0.18754987,"width":0.024268618,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.18754987,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.20271349,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.other","depth":28,"bounds":{"left":0.028590426,"top":0.20430966,"width":0.019946808,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.20510775,"width":0.0013297872,"height":0.011971269}},{"char_start":1,"char_count":9,"bounds":{"left":0.029920213,"top":0.20510775,"width":0.01861702,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.20510775,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.22027135,"width":0.0076462766,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ids.txt","depth":28,"bounds":{"left":0.028590426,"top":0.22186752,"width":0.012300532,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.22266561,"width":0.0009973404,"height":0.011971269}},{"char_start":1,"char_count":6,"bounds":{"left":0.029587766,"top":0.22266561,"width":0.011635638,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.22266561,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.23782921,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"raw_sql_query.sql","depth":28,"bounds":{"left":0.028590426,"top":0.23942538,"width":0.035904255,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.24022347,"width":0.0016622341,"height":0.011971269}},{"char_start":1,"char_count":16,"bounds":{"left":0.03025266,"top":0.24022347,"width":0.034242023,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.24022347,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.25538707,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"WEBHOOK_FILTERING_IMPLEMENTATION.md","depth":28,"bounds":{"left":0.028590426,"top":0.25698325,"width":0.076130316,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.25778133,"width":0.0043218085,"height":0.011971269}},{"char_start":1,"char_count":34,"bounds":{"left":0.032912236,"top":0.25778133,"width":0.08909574,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.25778133,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.27294493,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JiminnyDebugCommand.php","depth":28,"bounds":{"left":0.028590426,"top":0.2745411,"width":0.05817819,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.2753392,"width":0.0023271276,"height":0.011971269}},{"char_start":1,"char_count":22,"bounds":{"left":0.030917553,"top":0.2753392,"width":0.055851065,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands","depth":28,"bounds":{"left":0.08843085,"top":0.2745411,"width":0.01462766,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.0887633,"top":0.27613726,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":19,"bounds":{"left":0.09075798,"top":0.27613726,"width":0.043218084,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.2753392,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.2905028,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"SimulateWebhooksCommand.php","depth":28,"bounds":{"left":0.028590426,"top":0.29209897,"width":0.06781915,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.29289705,"width":0.0026595744,"height":0.011971269}},{"char_start":1,"char_count":26,"bounds":{"left":0.03125,"top":0.29289705,"width":0.065159574,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands/Crm/Hubspot","depth":28,"bounds":{"left":0.098071806,"top":0.29209897,"width":0.005984043,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.09840426,"top":0.29369512,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":31,"bounds":{"left":0.100398935,"top":0.29369512,"width":0.06881649,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.29289705,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.30806065,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CreateMockAskJiminnyReportResultCommand.php","depth":28,"bounds":{"left":0.028590426,"top":0.30965683,"width":0.07646277,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.3104549,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":42,"bounds":{"left":0.03158245,"top":0.3104549,"width":0.099734046,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands/Reports","depth":28,"bounds":{"left":0.10538564,"top":0.3104549,"width":0.0003324468,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.13331117,"top":0.31125298,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":27,"bounds":{"left":0.13530585,"top":0.31125298,"width":0.05851064,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.3104549,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.3256185,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException.php","depth":28,"bounds":{"left":0.028590426,"top":0.3272147,"width":0.047539894,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.32801276,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":21,"bounds":{"left":0.03158245,"top":0.32801276,"width":0.04488032,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Exceptions","depth":28,"bounds":{"left":0.078125,"top":0.3272147,"width":0.024933511,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.078125,"top":0.32881084,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":13,"bounds":{"left":0.08045213,"top":0.32881084,"width":0.026263298,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.32801276,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.34317636,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"MatchActivityCrmData.php","depth":28,"bounds":{"left":0.028590426,"top":0.34477255,"width":0.054521278,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.34557062,"width":0.0039893617,"height":0.011971269}},{"char_start":1,"char_count":23,"bounds":{"left":0.032579787,"top":0.34557062,"width":0.050531916,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Jobs/Crm","depth":28,"bounds":{"left":0.084773935,"top":0.34477255,"width":0.020279255,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.084773935,"top":0.3463687,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":11,"bounds":{"left":0.087101065,"top":0.3463687,"width":0.023603724,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.34557062,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.36073422,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Client.php","depth":28,"bounds":{"left":0.028590426,"top":0.3623304,"width":0.020611702,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.36312848,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":9,"bounds":{"left":0.03158245,"top":0.36312848,"width":0.01761968,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Services/Crm/Hubspot","depth":28,"bounds":{"left":0.05119681,"top":0.3623304,"width":0.028590426,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.05119681,"top":0.3639266,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":23,"bounds":{"left":0.053523935,"top":0.3639266,"width":0.047539894,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"9, M","depth":28,"bounds":{"left":0.10206117,"top":0.36312848,"width":0.00831117,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.10206117,"top":0.36312848,"width":0.0026595744,"height":0.011173184}},{"char_start":1,"char_count":3,"bounds":{"left":0.10472074,"top":0.36312848,"width":0.005984043,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.3782921,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"logging.php","depth":28,"bounds":{"left":0.028590426,"top":0.37988827,"width":0.023936171,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.38068634,"width":0.0009973404,"height":0.011971269}},{"char_start":1,"char_count":10,"bounds":{"left":0.029587766,"top":0.38068634,"width":0.022938829,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"config","depth":28,"bounds":{"left":0.05418883,"top":0.38068634,"width":0.011968086,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.054521278,"top":0.38148445,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":5,"bounds":{"left":0.05651596,"top":0.38148445,"width":0.009640957,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.38068634,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.39584997,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"favicon.ico","depth":28,"bounds":{"left":0.028590426,"top":0.39744613,"width":0.021609042,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.3982442,"width":0.0016622341,"height":0.011971269}},{"char_start":1,"char_count":10,"bounds":{"left":0.03025266,"top":0.3982442,"width":0.020279255,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"public","depth":28,"bounds":{"left":0.05219415,"top":0.3982442,"width":0.011303191,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.05219415,"top":0.3990423,"width":0.0026595744,"height":0.011173184}},{"char_start":1,"char_count":5,"bounds":{"left":0.054521278,"top":0.3990423,"width":0.008976064,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.3982442,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.41340783,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php","depth":28,"bounds":{"left":0.028590426,"top":0.415004,"width":0.06781915,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.41580206,"width":0.0033244682,"height":0.011971269}},{"char_start":1,"char_count":29,"bounds":{"left":0.031914894,"top":0.41580206,"width":0.064494684,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Jobs/Middleware","depth":28,"bounds":{"left":0.098071806,"top":0.415004,"width":0.004986702,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.098071806,"top":0.41660017,"width":0.0016622341,"height":0.011173184}},{"char_start":1,"char_count":25,"bounds":{"left":0.09940159,"top":0.41660017,"width":0.04886968,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.41580206,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.4309657,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CanAccessAiReportsTest.php","depth":28,"bounds":{"left":0.028590426,"top":0.43256184,"width":0.059175532,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.43335995,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":25,"bounds":{"left":0.03158245,"top":0.43335995,"width":0.05618351,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Policies","depth":28,"bounds":{"left":0.089428194,"top":0.43256184,"width":0.013962766,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.08976064,"top":0.43415803,"width":0.0013297872,"height":0.011173184}},{"char_start":1,"char_count":18,"bounds":{"left":0.091090426,"top":0.43415803,"width":0.031914894,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.43335995,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.44852355,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClientTest.php","depth":28,"bounds":{"left":0.028590426,"top":0.4501197,"width":0.028922873,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.4509178,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":13,"bounds":{"left":0.03158245,"top":0.4509178,"width":0.026263298,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Services/Crm/Hubspot","depth":28,"bounds":{"left":0.059507977,"top":0.4501197,"width":0.043882977,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.059507977,"top":0.4517159,"width":0.0016622341,"height":0.011173184}},{"char_start":1,"char_count":30,"bounds":{"left":0.060837764,"top":0.4517159,"width":0.059507977,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.4509178,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Graph Section","depth":21,"bounds":{"left":0.015957447,"top":0.60015965,"width":0.09940159,"height":0.017557861},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"","depth":23,"bounds":{"left":0.01662234,"top":0.60255384,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"GRAPH","depth":22,"bounds":{"left":0.022606382,"top":0.60015965,"width":0.013297873,"height":0.017557861},"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"GRAPH","depth":23,"bounds":{"left":0.022606382,"top":0.60415006,"width":0.013297873,"height":0.0103751},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add more tests","depth":28,"bounds":{"left":0.023271276,"top":0.6201117,"width":0.038231384,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.023271276,"top":0.6209098,"width":0.0026595744,"height":0.011971269}},{"char_start":1,"char_count":22,"bounds":{"left":0.025930852,"top":0.6209098,"width":0.05086436,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.061835106,"top":0.6209098,"width":0.0003324468,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.078457445,"top":0.6217079,"width":0.0026595744,"height":0.011173184}},{"char_start":1,"char_count":12,"bounds":{"left":0.080784574,"top":0.6217079,"width":0.023271276,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.06382979,"top":0.6201117,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit","depth":27,"bounds":{"left":0.06948138,"top":0.6209098,"width":0.032579787,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.06948138,"top":0.6209098,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":35,"bounds":{"left":0.07180851,"top":0.6209098,"width":0.071476065,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.10571808,"top":0.6201117,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add tests","depth":28,"bounds":{"left":0.023271276,"top":0.63766956,"width":0.03956117,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.023271276,"top":0.63846767,"width":0.0023271276,"height":0.011971269}},{"char_start":1,"char_count":17,"bounds":{"left":0.025598405,"top":0.63846767,"width":0.03723404,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.064494684,"top":0.63846767,"width":0.024933511,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.06482713,"top":0.6392658,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":12,"bounds":{"left":0.066821806,"top":0.6392658,"width":0.022938829,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching","depth":28,"bounds":{"left":0.023271276,"top":0.6552275,"width":0.08577128,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.023271276,"top":0.6560255,"width":0.0023271276,"height":0.011971269}},{"char_start":1,"char_count":59,"bounds":{"left":0.025598405,"top":0.6560255,"width":0.11968085,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.109707445,"top":0.6560255,"width":0.0003324468,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.14727394,"top":0.65682364,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":12,"bounds":{"left":0.14926861,"top":0.65682364,"width":0.022938829,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric","depth":28,"bounds":{"left":0.026928192,"top":0.67278534,"width":0.049534574,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.0774601,"top":0.6735834,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.07945479,"top":0.67278534,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"origin/master","depth":27,"bounds":{"left":0.08510638,"top":0.6735834,"width":0.024933511,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric","depth":28,"bounds":{"left":0.030585106,"top":0.6903432,"width":0.078457445,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.109707445,"top":0.69114125,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit","depth":28,"bounds":{"left":0.030585106,"top":0.70790106,"width":0.078457445,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"bounds":{"left":0.109707445,"top":0.7086991,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20819: Increase download transcription rate limit","depth":28,"bounds":{"left":0.030585106,"top":0.7254589,"width":0.078457445,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihail.mihaylov","depth":28,"bounds":{"left":0.109707445,"top":0.72625697,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20818 move ask jiminny reports to its own datadog metric","depth":28,"bounds":{"left":0.030585106,"top":0.7430168,"width":0.07945479,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.109707445,"top":0.7438148,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks","depth":28,"bounds":{"left":0.030585106,"top":0.76057464,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"bounds":{"left":0.109707445,"top":0.7613727,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-20817-fix-deleting-old-tracks","depth":28,"bounds":{"left":0.030585106,"top":0.7781325,"width":0.078125,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"bounds":{"left":0.109707445,"top":0.77893054,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12047 from jiminny/JY-20662-remove-word-boost","depth":28,"bounds":{"left":0.030585106,"top":0.79569036,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"bounds":{"left":0.109707445,"top":0.7964884,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20662 return void","depth":28,"bounds":{"left":0.030585106,"top":0.8132482,"width":0.043218084,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.075465426,"top":0.81404626,"width":0.0076462766,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20662 Removed word_boost parameter from Assembly transcription request","depth":28,"bounds":{"left":0.030585106,"top":0.8308061,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.109707445,"top":0.8316041,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20817: Fix deleting old tracks","depth":28,"bounds":{"left":0.030585106,"top":0.84836394,"width":0.06582447,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihail.mihaylov","depth":28,"bounds":{"left":0.098071806,"top":0.84836394,"width":0.010305851,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es","depth":28,"bounds":{"left":0.030585106,"top":0.8659218,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"bounds":{"left":0.109707445,"top":0.8667199,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-19938-bot-status-in-csv-es","depth":28,"bounds":{"left":0.030585106,"top":0.88347965,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"bounds":{"left":0.109707445,"top":0.88427776,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update","depth":28,"bounds":{"left":0.030585106,"top":0.9010375,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"bounds":{"left":0.109707445,"top":0.9018356,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 Added consent information and recording error reason to Elastic Search mapping","depth":28,"bounds":{"left":0.030585106,"top":0.9185954,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.109707445,"top":0.9193935,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 reorder array map","depth":28,"bounds":{"left":0.030585106,"top":0.93615323,"width":0.056848403,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.089428194,"top":0.93695134,"width":0.00731383,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938: Address PR review comments","depth":28,"bounds":{"left":0.030585106,"top":0.9537111,"width":0.07912234,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.109707445,"top":0.9545092,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 lint fixes","depth":28,"bounds":{"left":0.030585106,"top":0.97126895,"width":0.03756649,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.06981383,"top":0.97206706,"width":0.0076462766,"height":0.0103751},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1","depth":28,"on_screen":false,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1","depth":28,"bounds":{"left":0.11569149,"top":0.047885075,"width":0.022606382,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Client.php (Working Tree) (Client.php), Editor Group 1","depth":28,"bounds":{"left":0.13796543,"top":0.047885075,"width":0.08211436,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1","depth":28,"bounds":{"left":0.21974733,"top":0.047885075,"width":0.111369684,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"MatchActivityCrmData.php, Editor Group 1","depth":28,"bounds":{"left":0.3307846,"top":0.047885075,"width":0.080119684,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1","depth":28,"bounds":{"left":0.4105718,"top":0.047885075,"width":0.1043883,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Client.php, preview, Editor Group 1","depth":28,"bounds":{"left":0.5149601,"top":0.047885075,"width":0.05119681,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.12832446,"top":0.07821229,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.15093085,"top":0.07821229,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.16489361,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.1875,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.22074468,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.22606383,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.24401596,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.24933511,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"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 {","depth":28,"bounds":{"left":0.13996011,"top":0.53790903,"width":0.2762633,"height":0.014365523},"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 {","role_description":"editor","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","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 {","depth":29,"bounds":{"left":0.13996011,"top":0.53790903,"width":0.2762633,"height":0.014365523},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Review rate limit handli…, Editor Group 2","depth":28,"bounds":{"left":0.61768615,"top":0.047885075,"width":0.07014628,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXButton","text":"remote","depth":16,"bounds":{"left":0.0006648936,"top":0.98244214,"width":0.010638298,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...","depth":16,"bounds":{"left":0.012965426,"top":0.98244214,"width":0.084773935,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":0.013962766,"top":0.9848364,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit*","depth":17,"bounds":{"left":0.019281914,"top":0.9856345,"width":0.0774601,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"app (Git) - Synchronize Changes","depth":16,"bounds":{"left":0.09740692,"top":0.98244214,"width":0.0076462766,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Errors: 6, Warnings: 11, Infos: 28","depth":16,"bounds":{"left":0.107380316,"top":0.98244214,"width":0.03756649,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":0.109042555,"top":0.9848364,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"6","depth":17,"bounds":{"left":0.1143617,"top":0.9856345,"width":0.004986702,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":0.11934841,"top":0.9848364,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":17,"bounds":{"left":0.124667555,"top":0.9856345,"width":0.00731383,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":0.13164894,"top":0.9848364,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"28","depth":17,"bounds":{"left":0.13696809,"top":0.9856345,"width":0.0063164895,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Notifications","depth":16,"bounds":{"left":0.9886968,"top":0.98244214,"width":0.010638298,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"key, PHP extension: Premium features not active.","depth":16,"bounds":{"left":0.9790558,"top":0.98244214,"width":0.008643617,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"8.3, Language Level: 8.3","depth":16,"bounds":{"left":0.9674202,"top":0.98244214,"width":0.009640957,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sign In","depth":16,"bounds":{"left":0.9428192,"top":0.98244214,"width":0.022938829,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":0.9444814,"top":0.9848364,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Sign In","depth":17,"bounds":{"left":0.94980055,"top":0.9856345,"width":0.014295213,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"PHP","depth":16,"bounds":{"left":0.93018615,"top":0.98244214,"width":0.010305851,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Editor Language Status: $(copilot) No inline suggestion available, Inline suggestions","depth":16,"bounds":{"left":0.92287236,"top":0.98244214,"width":0.0076462766,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"LF","depth":16,"bounds":{"left":0.9125665,"top":0.98244214,"width":0.007978723,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UTF-8","depth":16,"bounds":{"left":0.8949468,"top":0.98244214,"width":0.015625,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Spaces: 4","depth":16,"bounds":{"left":0.87101066,"top":0.98244214,"width":0.022273935,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Ln 120, Col 1","depth":16,"bounds":{"left":0.84042555,"top":0.98244214,"width":0.028922873,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Diff editor","depth":12,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Clear","depth":12,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Review rate limit handling for HubSpot job queue","depth":19,"bounds":{"left":0.6200133,"top":0.08060654,"width":0.099734046,"height":0.022346368},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Session history","depth":19,"bounds":{"left":0.9780585,"top":0.08060654,"width":0.00930851,"height":0.022346368},"on_screen":true,"help_text":"Session history","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New session","depth":19,"bounds":{"left":0.9886968,"top":0.08060654,"width":0.00930851,"height":0.022346368},"on_screen":true,"help_text":"New session","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Message actions","depth":24,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"RateLimitException.php#9-9","depth":23,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"RateLimitException.php#9-9","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Reveiw the diff. Mostly","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Jobs/Crm/MatchActivityCrmData.php","depth":26,"on_screen":false,"help_text":"Open app/Jobs/Crm/MatchActivityCrmData.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Exceptions/RateLimitException.php","depth":26,"on_screen":false,"help_text":"Open app/Exceptions/RateLimitException.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Jobs/Middleware/HandleHubspotRateLimit.php","depth":26,"on_screen":false,"help_text":"Open app/Jobs/Middleware/HandleHubspotRateLimit.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Services/Crm/Hubspot/Client.php","depth":26,"on_screen":false,"help_text":"Open app/Services/Crm/Hubspot/Client.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"and","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php","depth":26,"on_screen":false,"help_text":"Open app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":". The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Thought for 5s","depth":23,"on_screen":false,"role_description":"disclosure triangle","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thought for 5s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"I'll review the implementation carefully. Let me first check how","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is used elsewhere and look at the test files and","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PaginationState","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all usages of RateLimitException outside the changed files","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\" /Users/lukas/jiminny/app/app --include=\"*.php\" | grep -v \"HandleHubspotRateLimit\\|MatchActivityCrmData\\|Client.php\\|RateLimitException.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |\n/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"PaginationState.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"PaginationState.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"HandleHubspotRateLimitTest.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check how RateLimitException is used in GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\\|LogicException\\|RuntimeException\" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all catch blocks or instanceof checks for RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"catch.*RateLimitException\\|instanceof RateLimitException\" /Users/lukas/jiminny/app/app --include=\"*.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls\n/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {\n/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all test references to RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\" /Users/lukas/jiminny/app/tests --include=\"*.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry/failure handling in GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"RateLimitException\\|retryUntil\\|backoff\\|maxExceptions\\|maxTries\\|release\\|failed\" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"13:use Jiminny\\Exceptions\\RateLimitException;\n51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n56: $this->release(30);","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry/failure handling in GenerateAiCallScoringJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"RateLimitException\\|retryUntil\\|backoff\\|maxExceptions\\|maxTries\\|release\\|failed\" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"17:use Jiminny\\Exceptions\\RateLimitException;\n68: RateLimitException |\n75: $this->release(30);","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry config in MatchActivityCrmData","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"retryAfter\\|RETRY_WINDOW\\|retryUntil\\|maxTries\" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"34: private const int RETRY_WINDOW_MINUTES = 30;\n45: public function retryUntil(): \\DateTimeInterface\n47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n77: return self::RETRY_WINDOW_MINUTES * 60 + 60;","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check the cache key format","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"getRateLimitCacheKey\\|hubspot:ratelimit\" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"78: $cacheKey = $this->getRateLimitCacheKey();\n110: private function getRateLimitCacheKey(): string\n112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find other jobs that might use the rate limit middleware","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"class.*Middleware\\|HandleHubspotRateLimit\" /Users/lukas/jiminny/app/app/Jobs --include=\"*.php\" | grep -v \"HandleHubspotRateLimit.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\\Jobs\\Middleware\\HandleHubspotRateLimit;\n/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"HubspotClientInterface.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"HubspotClientInterface.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Thought for 73s","depth":23,"on_screen":false,"role_description":"disclosure triangle","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thought for 73s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Now I have a complete picture. Here's my full analysis:","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Review Summary","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Review Summary","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"RuntimeException vs LogicException — Safe ✓","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"RuntimeException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"vs","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LogicException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"— Safe ✓","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The change is semantically correct and won't break anything.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LogicException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is for programmer errors (invalid arguments, contract violations). A rate limit from an external API is a runtime condition —","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RuntimeException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is the right parent. All callers catch","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"by name explicitly (union catches in","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateAiCallScoringJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SummarizeTranscriptionJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateAiActivityTypeJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":") or check","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"instanceof RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"— none relies on the parent class type, so nothing breaks.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 1 — Circuit breaker stores stale delay (most important bug)","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 1 — Circuit breaker stores stale delay (most important bug)","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"Client.php:94","depth":23,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Client.php:94","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Redis::setex($cacheKey, $retryAfter, (string) $retryAfter);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The TTL is","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"$retryAfter","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"seconds, but the","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"stored value","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is also the original","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"$retryAfter","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":". If a second job reads this cache 20 seconds later, it throws","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException(retryAfter=30)","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"and gets delayed 30 more seconds — even though only 10 remain. In a spike of 100 jobs, each subsequent job inflates its wait.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Fix:","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"store the absolute expiry timestamp, reconstruct the remaining delay when reading:","depth":23,"on_screen":false,"role_description":"text"}]...
|
-1958197525731446124
|
2153254833880959078
|
idle
|
accessibility
|
NULL
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G) - 17 pending changes
17
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Testing
Claude Code
SOURCE CONTROL
SOURCE CONTROL
Changes Section
CHANGES
CHANGES
Commit Changes on "JY-20725-handle-HS-search-rate-limit"
Commit
More Actions...
Changes
17
.env.local
M
.env.nikilocal
U
.env.other
U
ids.txt
U
raw_sql_query.sql
U
WEBHOOK_FILTERING_IMPLEMENTATION.md
U
JiminnyDebugCommand.php
app/Console/Commands
M
SimulateWebhooksCommand.php
app/Console/Commands/Crm/Hubspot
U
CreateMockAskJiminnyReportResultCommand.php
app/Console/Commands/Reports
U
RateLimitException.php
app/Exceptions
M
MatchActivityCrmData.php
app/Jobs/Crm
M
Client.php
app/Services/Crm/Hubspot
9, M
logging.php
config
M
favicon.ico
public
U
HandleHubspotRateLimitTest.php
tests/Unit/Jobs/Middleware
M
CanAccessAiReportsTest.php
tests/Unit/Policies
U
ClientTest.php
tests/Unit/Services/Crm/Hubspot
M
Graph Section
GRAPH
GRAPH
JY-20725 add more tests
Lukas Kovalik
JY-20725-handle-HS-search-rate-limit
JY-20725 add tests
Lukas Kovalik
JY-20725 add HS rate limit handling on activities rematching
Lukas Kovalik
Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
origin/master
Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit
mihailmihaylovjiminny
JY-20819: Increase download transcription rate limit
mihail.mihaylov
JY-20818 move ask jiminny reports to its own datadog metric
Lukas Kovalik
Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge branch 'master' into JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge pull request #12047 from jiminny/JY-20662-remove-word-boost
ilian-jiminny
JY-20662 return void
ilian
JY-20662 Removed word_boost parameter from Assembly transcription request
ilian
JY-20817: Fix deleting old tracks
mihail.mihaylov
Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge branch 'master' into JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update
ilian-jiminny
JY-19938 Added consent information and recording error reason to Elastic Search mapping
ilian
JY-19938 reorder array map
ilian
JY-19938: Address PR review comments
ilian
JY-19938 lint fixes
ilian
HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1
ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1
Client.php (Working Tree) (Client.php), Editor Group 1
MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1
MatchActivityCrmData.php, Editor Group 1
RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1
Client.php, preview, Editor Group 1
<?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 {
<?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 {
Review rate limit handli…, Editor Group 2
remote
app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...
JY-20725-handle-HS-search-rate-limit*
app (Git) - Synchronize Changes
Errors: 6, Warnings: 11, Infos: 28
6
11
28
Notifications
key, PHP extension: Premium features not active.
8.3, Language Level: 8.3
Sign In
Sign In
PHP
Editor Language Status: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 4
Ln 120, Col 1
Diff editor
Clear
Review rate limit handling for HubSpot job queue
Session history
New session
Message actions
RateLimitException.php#9-9
RateLimitException.php#9-9
Reveiw the diff. Mostly
@app/Jobs/Crm/MatchActivityCrmData.php
,
@app/Exceptions/RateLimitException.php
,
@app/Jobs/Middleware/HandleHubspotRateLimit.php
,
@app/Services/Crm/Hubspot/Client.php
and
@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php
. The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?
Thought for 5s
Thought for 5s
I'll review the implementation carefully. Let me first check how
RateLimitException
is used elsewhere and look at the test files and
PaginationState
.
Bash
Find all usages of RateLimitException outside the changed files
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/app --include="*.php" | grep -v "HandleHubspotRateLimit\|MatchActivityCrmData\|Client.php\|RateLimitException.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is
Read
PaginationState.php
PaginationState.php
Read
HandleHubspotRateLimitTest.php
HandleHubspotRateLimitTest.php
Bash
Check how RateLimitException is used in GenerateActionItemsJob
IN
grep -rn "RateLimitException\|LogicException\|RuntimeException" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
Bash
Find all catch blocks or instanceof checks for RateLimitException
IN
grep -rn "catch.*RateLimitException\|instanceof RateLimitException" /Users/lukas/jiminny/app/app --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {
Bash
Find all test references to RateLimitException
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/tests --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);
Bash
Check retry/failure handling in GenerateActionItemsJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30
Copy code to clipboard
OUT
13:use Jiminny\Exceptions\RateLimitException;
51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
56: $this->release(30);
Bash
Check retry/failure handling in GenerateAiCallScoringJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30
Copy code to clipboard
OUT
17:use Jiminny\Exceptions\RateLimitException;
68: RateLimitException |
75: $this->release(30);
Bash
Check retry config in MatchActivityCrmData
IN
grep -n "retryAfter\|RETRY_WINDOW\|retryUntil\|maxTries" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php
Copy code to clipboard
OUT
34: private const int RETRY_WINDOW_MINUTES = 30;
45: public function retryUntil(): \DateTimeInterface
47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);
77: return self::RETRY_WINDOW_MINUTES * 60 + 60;
Bash
Check the cache key format
IN
grep -n "getRateLimitCacheKey\|hubspot:ratelimit" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Copy code to clipboard
OUT
78: $cacheKey = $this->getRateLimitCacheKey();
110: private function getRateLimitCacheKey(): string
112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());
Bash
Find other jobs that might use the rate limit middleware
IN
grep -rn "class.*Middleware\|HandleHubspotRateLimit" /Users/lukas/jiminny/app/app/Jobs --include="*.php" | grep -v "HandleHubspotRateLimit.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];
Read
HubspotClientInterface.php
HubspotClientInterface.php
Thought for 73s
Thought for 73s
Now I have a complete picture. Here's my full analysis:
Review Summary
Review Summary
RuntimeException vs LogicException — Safe ✓
RuntimeException
vs
LogicException
— Safe ✓
The change is semantically correct and won't break anything.
LogicException
is for programmer errors (invalid arguments, contract violations). A rate limit from an external API is a runtime condition —
RuntimeException
is the right parent. All callers catch
RateLimitException
by name explicitly (union catches in
GenerateActionItemsJob
,
GenerateAiCallScoringJob
,
SummarizeTranscriptionJob
,
GenerateAiActivityTypeJob
) or check
instanceof RateLimitException
— none relies on the parent class type, so nothing breaks.
Issue 1 — Circuit breaker stores stale delay (most important bug)
Issue 1 — Circuit breaker stores stale delay (most important bug)
Client.php:94
Client.php:94
Copy code to clipboard
Redis::setex($cacheKey, $retryAfter, (string) $retryAfter);
The TTL is
$retryAfter
seconds, but the
stored value
is also the original
$retryAfter
. If a second job reads this cache 20 seconds later, it throws
RateLimitException(retryAfter=30)
and gets delayed 30 more seconds — even though only 10 remain. In a spike of 100 jobs, each subsequent job inflates its wait.
Fix:
store the absolute expiry timestamp, reconstruct the remaining delay when reading:...
|
20410
|
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/ /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php...
|
NULL
|
NULL
|
|
20413
|
882
|
0
|
2026-05-11T15:25:00.776677+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778513100776_m1.jpg...
|
Code
|
Client.php — app — 9 problems in this file • Modif Client.php — app — 9 problems in this file • Modified...
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G) - 17 pending changes
17
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Testing
Claude Code
SOURCE CONTROL
SOURCE CONTROL
Changes Section
CHANGES
CHANGES
Commit Changes on "JY-20725-handle-HS-search-rate-limit"
Commit
More Actions...
Changes
17
.env.local
M
.env.nikilocal
U
.env.other
U
ids.txt
U
raw_sql_query.sql
U
WEBHOOK_FILTERING_IMPLEMENTATION.md
U
JiminnyDebugCommand.php
app/Console/Commands
M
SimulateWebhooksCommand.php
app/Console/Commands/Crm/Hubspot
U
CreateMockAskJiminnyReportResultCommand.php
app/Console/Commands/Reports
U
RateLimitException.php
app/Exceptions
M
MatchActivityCrmData.php
app/Jobs/Crm
M
Client.php
app/Services/Crm/Hubspot
9, M
logging.php
config
M
favicon.ico
public
U
HandleHubspotRateLimitTest.php
tests/Unit/Jobs/Middleware
M
CanAccessAiReportsTest.php
tests/Unit/Policies
U
ClientTest.php
tests/Unit/Services/Crm/Hubspot
M
Graph Section
GRAPH
GRAPH
JY-20725 add more tests
Lukas Kovalik
JY-20725-handle-HS-search-rate-limit
JY-20725 add tests
Lukas Kovalik
JY-20725 add HS rate limit handling on activities rematching
Lukas Kovalik
Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
origin/master
Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit
mihailmihaylovjiminny
JY-20819: Increase download transcription rate limit
mihail.mihaylov
JY-20818 move ask jiminny reports to its own datadog metric
Lukas Kovalik
Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge branch 'master' into JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge pull request #12047 from jiminny/JY-20662-remove-word-boost
ilian-jiminny
JY-20662 return void
ilian
JY-20662 Removed word_boost parameter from Assembly transcription request
ilian
JY-20817: Fix deleting old tracks
mihail.mihaylov
Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge branch 'master' into JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update
ilian-jiminny
JY-19938 Added consent information and recording error reason to Elastic Search mapping
ilian
JY-19938 reorder array map
ilian
JY-19938: Address PR review comments
ilian
JY-19938 lint fixes
ilian
HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1
ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1
Client.php (Working Tree) (Client.php), Editor Group 1
MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1
MatchActivityCrmData.php, Editor Group 1
RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1
Client.php, preview, Editor Group 1
<?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 {
<?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 {
Review rate limit handli…, Editor Group 2
remote
app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...
JY-20725-handle-HS-search-rate-limit*
app (Git) - Synchronize Changes
Errors: 6, Warnings: 11, Infos: 28
6
11
28
Notifications
key, PHP extension: Premium features not active.
8.3, Language Level: 8.3
Sign In
Sign In
PHP
Editor Language Status: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 4
Ln 120, Col 1
Diff editor
Clear
Review rate limit handling for HubSpot job queue
Session history
New session
Message actions
RateLimitException.php#9-9
RateLimitException.php#9-9
Reveiw the diff. Mostly
@app/Jobs/Crm/MatchActivityCrmData.php
,
@app/Exceptions/RateLimitException.php
,
@app/Jobs/Middleware/HandleHubspotRateLimit.php
,
@app/Services/Crm/Hubspot/Client.php
and
@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php
. The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?
Thought for 5s
Thought for 5s
I'll review the implementation carefully. Let me first check how
RateLimitException
is used elsewhere and look at the test files and
PaginationState
.
Bash
Find all usages of RateLimitException outside the changed files
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/app --include="*.php" | grep -v "HandleHubspotRateLimit\|MatchActivityCrmData\|Client.php\|RateLimitException.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is
Read
PaginationState.php
PaginationState.php
Read
HandleHubspotRateLimitTest.php
HandleHubspotRateLimitTest.php
Bash
Check how RateLimitException is used in GenerateActionItemsJob
IN
grep -rn "RateLimitException\|LogicException\|RuntimeException" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
Bash
Find all catch blocks or instanceof checks for RateLimitException
IN
grep -rn "catch.*RateLimitException\|instanceof RateLimitException" /Users/lukas/jiminny/app/app --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {
Bash
Find all test references to RateLimitException
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/tests --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);
Bash
Check retry/failure handling in GenerateActionItemsJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30
Copy code to clipboard
OUT
13:use Jiminny\Exceptions\RateLimitException;
51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
56: $this->release(30);
Bash
Check retry/failure handling in GenerateAiCallScoringJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30
Copy code to clipboard
OUT
17:use Jiminny\Exceptions\RateLimitException;
68: RateLimitException |
75: $this->release(30);
Bash
Check retry config in MatchActivityCrmData
IN
grep -n "retryAfter\|RETRY_WINDOW\|retryUntil\|maxTries" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php
Copy code to clipboard
OUT
34: private const int RETRY_WINDOW_MINUTES = 30;
45: public function retryUntil(): \DateTimeInterface
47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);
77: return self::RETRY_WINDOW_MINUTES * 60 + 60;
Bash
Check the cache key format
IN
grep -n "getRateLimitCacheKey\|hubspot:ratelimit" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Copy code to clipboard
OUT
78: $cacheKey = $this->getRateLimitCacheKey();
110: private function getRateLimitCacheKey(): string
112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());
Bash
Find other jobs that might use the rate limit middleware
IN
grep -rn "class.*Middleware\|HandleHubspotRateLimit" /Users/lukas/jiminny/app/app/Jobs --include="*.php" | grep -v "HandleHubspotRateLimit.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];
Read
HubspotClientInterface.php
HubspotClientInterface.php
Thought for 73s
Thought for 73s
Now I have a complete picture. Here's my full analysis:
Review Summary
Review Summary
RuntimeException vs LogicException — Safe ✓
RuntimeException
vs
LogicException
— Safe ✓
The change is semantically correct and won't break anything.
LogicException
is for programmer errors (invalid arguments, contract violations). A rate limit from an external API is a runtime condition —
RuntimeException
is the right parent. All callers catch
RateLimitException
by name explicitly (union catches in
GenerateActionItemsJob
,
GenerateAiCallScoringJob
,
SummarizeTranscriptionJob
,
GenerateAiActivityTypeJob
) or check
instanceof RateLimitException
— none relies on the parent class type, so nothing breaks.
Issue 1 — Circuit breaker stores stale delay (most important bug)
Issue 1 — Circuit breaker stores stale delay (most important bug)
Client.php:94
Client.php:94
Copy code to clipboard
Redis::setex($cacheKey, $retryAfter, (string) $retryAfter);
The TTL is
$retryAfter
seconds, but the
stored value
is also the original...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Explorer (⇧⌘E)","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Search (⇧⌘F)","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Source Control (⌃⇧G) - 17 pending changes","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":true},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"17","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Run and Debug (⇧⌘D)","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Remote Explorer","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Extensions (⇧⌘X) - 2 require update","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"2","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Testing","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Claude Code","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"SOURCE CONTROL","depth":17,"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"SOURCE CONTROL","depth":18,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Changes Section","depth":21,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"CHANGES","depth":22,"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"CHANGES","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"","depth":28,"on_screen":true,"value":"","role_description":"editor","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":29,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Commit Changes on \"JY-20725-handle-HS-search-rate-limit\"","depth":26,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Commit","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"More Actions...","depth":26,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Changes","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"17","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.local","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.nikilocal","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.other","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ids.txt","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"raw_sql_query.sql","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"WEBHOOK_FILTERING_IMPLEMENTATION.md","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JiminnyDebugCommand.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"SimulateWebhooksCommand.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands/Crm/Hubspot","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CreateMockAskJiminnyReportResultCommand.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands/Reports","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Exceptions","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"MatchActivityCrmData.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Jobs/Crm","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Client.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Services/Crm/Hubspot","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"9, M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"logging.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"config","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"favicon.ico","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"public","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Jobs/Middleware","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CanAccessAiReportsTest.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Policies","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClientTest.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Services/Crm/Hubspot","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Graph Section","depth":21,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"GRAPH","depth":22,"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"GRAPH","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add more tests","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add tests","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"origin/master","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20819: Increase download transcription rate limit","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihail.mihaylov","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20818 move ask jiminny reports to its own datadog metric","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-20817-fix-deleting-old-tracks","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12047 from jiminny/JY-20662-remove-word-boost","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20662 return void","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20662 Removed word_boost parameter from Assembly transcription request","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20817: Fix deleting old tracks","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihail.mihaylov","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-19938-bot-status-in-csv-es","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 Added consent information and recording error reason to Elastic Search mapping","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 reorder array map","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938: Address PR review comments","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 lint fixes","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1","depth":28,"on_screen":false,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Client.php (Working Tree) (Client.php), Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"MatchActivityCrmData.php, Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Client.php, preview, Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"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 {","depth":28,"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 {","role_description":"editor","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","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 {","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Review rate limit handli…, Editor Group 2","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXButton","text":"remote","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit*","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"app (Git) - Synchronize Changes","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Errors: 6, Warnings: 11, Infos: 28","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"6","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"28","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Notifications","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"key, PHP extension: Premium features not active.","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"8.3, Language Level: 8.3","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sign In","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Sign In","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"PHP","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Editor Language Status: $(copilot) No inline suggestion available, Inline suggestions","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"LF","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UTF-8","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Spaces: 4","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Ln 120, Col 1","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Diff editor","depth":12,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Clear","depth":12,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Review rate limit handling for HubSpot job queue","depth":19,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Session history","depth":19,"on_screen":true,"help_text":"Session history","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New session","depth":19,"on_screen":true,"help_text":"New session","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Message actions","depth":24,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"RateLimitException.php#9-9","depth":23,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"RateLimitException.php#9-9","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Reveiw the diff. Mostly","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Jobs/Crm/MatchActivityCrmData.php","depth":26,"on_screen":false,"help_text":"Open app/Jobs/Crm/MatchActivityCrmData.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Exceptions/RateLimitException.php","depth":26,"on_screen":false,"help_text":"Open app/Exceptions/RateLimitException.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Jobs/Middleware/HandleHubspotRateLimit.php","depth":26,"on_screen":false,"help_text":"Open app/Jobs/Middleware/HandleHubspotRateLimit.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Services/Crm/Hubspot/Client.php","depth":26,"on_screen":false,"help_text":"Open app/Services/Crm/Hubspot/Client.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"and","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php","depth":26,"on_screen":false,"help_text":"Open app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":". The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Thought for 5s","depth":23,"on_screen":false,"role_description":"disclosure triangle","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thought for 5s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"I'll review the implementation carefully. Let me first check how","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is used elsewhere and look at the test files and","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PaginationState","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all usages of RateLimitException outside the changed files","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\" /Users/lukas/jiminny/app/app --include=\"*.php\" | grep -v \"HandleHubspotRateLimit\\|MatchActivityCrmData\\|Client.php\\|RateLimitException.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |\n/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"PaginationState.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"PaginationState.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"HandleHubspotRateLimitTest.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check how RateLimitException is used in GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\\|LogicException\\|RuntimeException\" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all catch blocks or instanceof checks for RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"catch.*RateLimitException\\|instanceof RateLimitException\" /Users/lukas/jiminny/app/app --include=\"*.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls\n/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {\n/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all test references to RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\" /Users/lukas/jiminny/app/tests --include=\"*.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry/failure handling in GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"RateLimitException\\|retryUntil\\|backoff\\|maxExceptions\\|maxTries\\|release\\|failed\" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"13:use Jiminny\\Exceptions\\RateLimitException;\n51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n56: $this->release(30);","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry/failure handling in GenerateAiCallScoringJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"RateLimitException\\|retryUntil\\|backoff\\|maxExceptions\\|maxTries\\|release\\|failed\" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"17:use Jiminny\\Exceptions\\RateLimitException;\n68: RateLimitException |\n75: $this->release(30);","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry config in MatchActivityCrmData","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"retryAfter\\|RETRY_WINDOW\\|retryUntil\\|maxTries\" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"34: private const int RETRY_WINDOW_MINUTES = 30;\n45: public function retryUntil(): \\DateTimeInterface\n47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n77: return self::RETRY_WINDOW_MINUTES * 60 + 60;","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check the cache key format","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"getRateLimitCacheKey\\|hubspot:ratelimit\" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"78: $cacheKey = $this->getRateLimitCacheKey();\n110: private function getRateLimitCacheKey(): string\n112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find other jobs that might use the rate limit middleware","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"class.*Middleware\\|HandleHubspotRateLimit\" /Users/lukas/jiminny/app/app/Jobs --include=\"*.php\" | grep -v \"HandleHubspotRateLimit.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\\Jobs\\Middleware\\HandleHubspotRateLimit;\n/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"HubspotClientInterface.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"HubspotClientInterface.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Thought for 73s","depth":23,"on_screen":false,"role_description":"disclosure triangle","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thought for 73s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Now I have a complete picture. Here's my full analysis:","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Review Summary","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Review Summary","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"RuntimeException vs LogicException — Safe ✓","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"RuntimeException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"vs","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LogicException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"— Safe ✓","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The change is semantically correct and won't break anything.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LogicException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is for programmer errors (invalid arguments, contract violations). A rate limit from an external API is a runtime condition —","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RuntimeException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is the right parent. All callers catch","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"by name explicitly (union catches in","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateAiCallScoringJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SummarizeTranscriptionJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateAiActivityTypeJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":") or check","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"instanceof RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"— none relies on the parent class type, so nothing breaks.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 1 — Circuit breaker stores stale delay (most important bug)","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 1 — Circuit breaker stores stale delay (most important bug)","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"Client.php:94","depth":23,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Client.php:94","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Redis::setex($cacheKey, $retryAfter, (string) $retryAfter);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The TTL is","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"$retryAfter","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"seconds, but the","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"stored value","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is also the original","depth":23,"on_screen":false,"role_description":"text"}]...
|
-8841056026039525000
|
2153254833883056230
|
idle
|
accessibility
|
NULL
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G) - 17 pending changes
17
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Testing
Claude Code
SOURCE CONTROL
SOURCE CONTROL
Changes Section
CHANGES
CHANGES
Commit Changes on "JY-20725-handle-HS-search-rate-limit"
Commit
More Actions...
Changes
17
.env.local
M
.env.nikilocal
U
.env.other
U
ids.txt
U
raw_sql_query.sql
U
WEBHOOK_FILTERING_IMPLEMENTATION.md
U
JiminnyDebugCommand.php
app/Console/Commands
M
SimulateWebhooksCommand.php
app/Console/Commands/Crm/Hubspot
U
CreateMockAskJiminnyReportResultCommand.php
app/Console/Commands/Reports
U
RateLimitException.php
app/Exceptions
M
MatchActivityCrmData.php
app/Jobs/Crm
M
Client.php
app/Services/Crm/Hubspot
9, M
logging.php
config
M
favicon.ico
public
U
HandleHubspotRateLimitTest.php
tests/Unit/Jobs/Middleware
M
CanAccessAiReportsTest.php
tests/Unit/Policies
U
ClientTest.php
tests/Unit/Services/Crm/Hubspot
M
Graph Section
GRAPH
GRAPH
JY-20725 add more tests
Lukas Kovalik
JY-20725-handle-HS-search-rate-limit
JY-20725 add tests
Lukas Kovalik
JY-20725 add HS rate limit handling on activities rematching
Lukas Kovalik
Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
origin/master
Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit
mihailmihaylovjiminny
JY-20819: Increase download transcription rate limit
mihail.mihaylov
JY-20818 move ask jiminny reports to its own datadog metric
Lukas Kovalik
Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge branch 'master' into JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge pull request #12047 from jiminny/JY-20662-remove-word-boost
ilian-jiminny
JY-20662 return void
ilian
JY-20662 Removed word_boost parameter from Assembly transcription request
ilian
JY-20817: Fix deleting old tracks
mihail.mihaylov
Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge branch 'master' into JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update
ilian-jiminny
JY-19938 Added consent information and recording error reason to Elastic Search mapping
ilian
JY-19938 reorder array map
ilian
JY-19938: Address PR review comments
ilian
JY-19938 lint fixes
ilian
HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1
ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1
Client.php (Working Tree) (Client.php), Editor Group 1
MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1
MatchActivityCrmData.php, Editor Group 1
RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1
Client.php, preview, Editor Group 1
<?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 {
<?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 {
Review rate limit handli…, Editor Group 2
remote
app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...
JY-20725-handle-HS-search-rate-limit*
app (Git) - Synchronize Changes
Errors: 6, Warnings: 11, Infos: 28
6
11
28
Notifications
key, PHP extension: Premium features not active.
8.3, Language Level: 8.3
Sign In
Sign In
PHP
Editor Language Status: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 4
Ln 120, Col 1
Diff editor
Clear
Review rate limit handling for HubSpot job queue
Session history
New session
Message actions
RateLimitException.php#9-9
RateLimitException.php#9-9
Reveiw the diff. Mostly
@app/Jobs/Crm/MatchActivityCrmData.php
,
@app/Exceptions/RateLimitException.php
,
@app/Jobs/Middleware/HandleHubspotRateLimit.php
,
@app/Services/Crm/Hubspot/Client.php
and
@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php
. The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?
Thought for 5s
Thought for 5s
I'll review the implementation carefully. Let me first check how
RateLimitException
is used elsewhere and look at the test files and
PaginationState
.
Bash
Find all usages of RateLimitException outside the changed files
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/app --include="*.php" | grep -v "HandleHubspotRateLimit\|MatchActivityCrmData\|Client.php\|RateLimitException.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is
Read
PaginationState.php
PaginationState.php
Read
HandleHubspotRateLimitTest.php
HandleHubspotRateLimitTest.php
Bash
Check how RateLimitException is used in GenerateActionItemsJob
IN
grep -rn "RateLimitException\|LogicException\|RuntimeException" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
Bash
Find all catch blocks or instanceof checks for RateLimitException
IN
grep -rn "catch.*RateLimitException\|instanceof RateLimitException" /Users/lukas/jiminny/app/app --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {
Bash
Find all test references to RateLimitException
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/tests --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);
Bash
Check retry/failure handling in GenerateActionItemsJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30
Copy code to clipboard
OUT
13:use Jiminny\Exceptions\RateLimitException;
51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
56: $this->release(30);
Bash
Check retry/failure handling in GenerateAiCallScoringJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30
Copy code to clipboard
OUT
17:use Jiminny\Exceptions\RateLimitException;
68: RateLimitException |
75: $this->release(30);
Bash
Check retry config in MatchActivityCrmData
IN
grep -n "retryAfter\|RETRY_WINDOW\|retryUntil\|maxTries" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php
Copy code to clipboard
OUT
34: private const int RETRY_WINDOW_MINUTES = 30;
45: public function retryUntil(): \DateTimeInterface
47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);
77: return self::RETRY_WINDOW_MINUTES * 60 + 60;
Bash
Check the cache key format
IN
grep -n "getRateLimitCacheKey\|hubspot:ratelimit" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Copy code to clipboard
OUT
78: $cacheKey = $this->getRateLimitCacheKey();
110: private function getRateLimitCacheKey(): string
112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());
Bash
Find other jobs that might use the rate limit middleware
IN
grep -rn "class.*Middleware\|HandleHubspotRateLimit" /Users/lukas/jiminny/app/app/Jobs --include="*.php" | grep -v "HandleHubspotRateLimit.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];
Read
HubspotClientInterface.php
HubspotClientInterface.php
Thought for 73s
Thought for 73s
Now I have a complete picture. Here's my full analysis:
Review Summary
Review Summary
RuntimeException vs LogicException — Safe ✓
RuntimeException
vs
LogicException
— Safe ✓
The change is semantically correct and won't break anything.
LogicException
is for programmer errors (invalid arguments, contract violations). A rate limit from an external API is a runtime condition —
RuntimeException
is the right parent. All callers catch
RateLimitException
by name explicitly (union catches in
GenerateActionItemsJob
,
GenerateAiCallScoringJob
,
SummarizeTranscriptionJob
,
GenerateAiActivityTypeJob
) or check
instanceof RateLimitException
— none relies on the parent class type, so nothing breaks.
Issue 1 — Circuit breaker stores stale delay (most important bug)
Issue 1 — Circuit breaker stores stale delay (most important bug)
Client.php:94
Client.php:94
Copy code to clipboard
Redis::setex($cacheKey, $retryAfter, (string) $retryAfter);
The TTL is
$retryAfter
seconds, but the
stored value
is also the original...
|
20411
|
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/ /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php...
|
NULL
|
NULL
|
|
20414
|
883
|
0
|
2026-05-11T15:25:00.897280+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778513100897_m2.jpg...
|
Code
|
Client.php — app — 9 problems in this file • Modif Client.php — app — 9 problems in this file • Modified...
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G) - 17 pending changes
17
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Testing
Claude Code
SOURCE CONTROL
SOURCE CONTROL
Changes Section
CHANGES
CHANGES
Commit Changes on "JY-20725-handle-HS-search-rate-limit"
Commit
More Actions...
Changes
17
.env.local
M
.env.nikilocal
U
.env.other
U
ids.txt
U
raw_sql_query.sql
U
WEBHOOK_FILTERING_IMPLEMENTATION.md
U
JiminnyDebugCommand.php
app/Console/Commands
M
SimulateWebhooksCommand.php
app/Console/Commands/Crm/Hubspot
U
CreateMockAskJiminnyReportResultCommand.php
app/Console/Commands/Reports
U
RateLimitException.php
app/Exceptions
M
MatchActivityCrmData.php
app/Jobs/Crm
M
Client.php
app/Services/Crm/Hubspot
9, M
logging.php
config
M
favicon.ico
public
U
HandleHubspotRateLimitTest.php
tests/Unit/Jobs/Middleware
M
CanAccessAiReportsTest.php
tests/Unit/Policies
U
ClientTest.php
tests/Unit/Services/Crm/Hubspot
M
Graph Section
GRAPH
GRAPH
JY-20725 add more tests
Lukas Kovalik
JY-20725-handle-HS-search-rate-limit
JY-20725 add tests
Lukas Kovalik
JY-20725 add HS rate limit handling on activities rematching
Lukas Kovalik
Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
origin/master
Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit
mihailmihaylovjiminny
JY-20819: Increase download transcription rate limit
mihail.mihaylov
JY-20818 move ask jiminny reports to its own datadog metric
Lukas Kovalik
Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge branch 'master' into JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge pull request #12047 from jiminny/JY-20662-remove-word-boost
ilian-jiminny
JY-20662 return void
ilian
JY-20662 Removed word_boost parameter from Assembly transcription request
ilian
JY-20817: Fix deleting old tracks
mihail.mihaylov
Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge branch 'master' into JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update
ilian-jiminny
JY-19938 Added consent information and recording error reason to Elastic Search mapping
ilian
JY-19938 reorder array map
ilian
JY-19938: Address PR review comments
ilian
JY-19938 lint fixes
ilian
HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1
ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1
Client.php (Working Tree) (Client.php), Editor Group 1
MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1
MatchActivityCrmData.php, Editor Group 1
RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1
Client.php, preview, Editor Group 1
<?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 {
<?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 {
Review rate limit handli…, Editor Group 2
remote
app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...
JY-20725-handle-HS-search-rate-limit*
app (Git) - Synchronize Changes
Errors: 6, Warnings: 11, Infos: 28
6
11
28
Notifications
key, PHP extension: Premium features not active.
8.3, Language Level: 8.3
Sign In
Sign In
PHP
Editor Language Status: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 4
Ln 120, Col 1
Diff editor
Clear
Review rate limit handling for HubSpot job queue
Session history
New session
Message actions
RateLimitException.php#9-9
RateLimitException.php#9-9
Reveiw the diff. Mostly
@app/Jobs/Crm/MatchActivityCrmData.php
,
@app/Exceptions/RateLimitException.php
,
@app/Jobs/Middleware/HandleHubspotRateLimit.php
,
@app/Services/Crm/Hubspot/Client.php
and
@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php
. The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?
Thought for 5s
Thought for 5s
I'll review the implementation carefully. Let me first check how
RateLimitException
is used elsewhere and look at the test files and
PaginationState
.
Bash
Find all usages of RateLimitException outside the changed files
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/app --include="*.php" | grep -v "HandleHubspotRateLimit\|MatchActivityCrmData\|Client.php\|RateLimitException.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is
Read
PaginationState.php
PaginationState.php
Read
HandleHubspotRateLimitTest.php
HandleHubspotRateLimitTest.php
Bash
Check how RateLimitException is used in GenerateActionItemsJob
IN
grep -rn "RateLimitException\|LogicException\|RuntimeException" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
Bash
Find all catch blocks or instanceof checks for RateLimitException
IN
grep -rn "catch.*RateLimitException\|instanceof RateLimitException" /Users/lukas/jiminny/app/app --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {
Bash
Find all test references to RateLimitException
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/tests --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);
Bash
Check retry/failure handling in GenerateActionItemsJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30
Copy code to clipboard
OUT
13:use Jiminny\Exceptions\RateLimitException;
51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
56: $this->release(30);
Bash
Check retry/failure handling in GenerateAiCallScoringJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30
Copy code to clipboard
OUT
17:use Jiminny\Exceptions\RateLimitException;
68: RateLimitException |
75: $this->release(30);
Bash
Check retry config in MatchActivityCrmData
IN
grep -n "retryAfter\|RETRY_WINDOW\|retryUntil\|maxTries" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php
Copy code to clipboard
OUT
34: private const int RETRY_WINDOW_MINUTES = 30;
45: public function retryUntil(): \DateTimeInterface
47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);
77: return self::RETRY_WINDOW_MINUTES * 60 + 60;
Bash
Check the cache key format
IN
grep -n "getRateLimitCacheKey\|hubspot:ratelimit" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Copy code to clipboard
OUT
78: $cacheKey = $this->getRateLimitCacheKey();
110: private function getRateLimitCacheKey(): string
112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());
Bash
Find other jobs that might use the rate limit middleware
IN
grep -rn "class.*Middleware\|HandleHubspotRateLimit" /Users/lukas/jiminny/app/app/Jobs --include="*.php" | grep -v "HandleHubspotRateLimit.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Explorer (⇧⌘E)","depth":19,"bounds":{"left":0.0,"top":0.047885075,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.057462092,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Search (⇧⌘F)","depth":19,"bounds":{"left":0.0,"top":0.08619314,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.09577015,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Source Control (⌃⇧G) - 17 pending changes","depth":19,"bounds":{"left":0.0,"top":0.1245012,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":true},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.13407822,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"17","depth":22,"bounds":{"left":0.00831117,"top":0.1452514,"width":0.003656915,"height":0.008778931},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.008643617,"top":0.14604948,"width":0.0016622341,"height":0.007980846}},{"char_start":1,"char_count":1,"bounds":{"left":0.009973404,"top":0.14604948,"width":0.0019946808,"height":0.007980846}}],"role_description":"text"},{"role":"AXRadioButton","text":"Run and Debug (⇧⌘D)","depth":19,"bounds":{"left":0.0,"top":0.16280925,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.17238627,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Remote Explorer","depth":19,"bounds":{"left":0.0,"top":0.20111732,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.21069433,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Extensions (⇧⌘X) - 2 require update","depth":19,"bounds":{"left":0.0,"top":0.23942538,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.2490024,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"2","depth":22,"bounds":{"left":0.009640957,"top":0.2601756,"width":0.0019946808,"height":0.008778931},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Testing","depth":19,"bounds":{"left":0.0,"top":0.27773345,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.28731045,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Claude Code","depth":19,"bounds":{"left":0.0,"top":0.3160415,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"SOURCE CONTROL","depth":17,"bounds":{"left":0.022606382,"top":0.047885075,"width":0.034242023,"height":0.02793296},"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"SOURCE CONTROL","depth":18,"bounds":{"left":0.022606382,"top":0.056664005,"width":0.034242023,"height":0.0103751},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Changes Section","depth":21,"bounds":{"left":0.015957447,"top":0.07581804,"width":0.09940159,"height":0.017557861},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"","depth":23,"bounds":{"left":0.01662234,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"CHANGES","depth":22,"bounds":{"left":0.022606382,"top":0.07581804,"width":0.01861702,"height":0.017557861},"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"CHANGES","depth":23,"bounds":{"left":0.022606382,"top":0.079010375,"width":0.01861702,"height":0.0103751},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"","depth":28,"bounds":{"left":0.024601065,"top":0.09896249,"width":0.07247341,"height":0.015961692},"on_screen":true,"value":"","role_description":"editor","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":29,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Commit Changes on \"JY-20725-handle-HS-search-rate-limit\"","depth":26,"bounds":{"left":0.022273935,"top":0.1245012,"width":0.08045213,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.052526597,"top":0.12849163,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Commit","depth":27,"bounds":{"left":0.058843084,"top":0.1292897,"width":0.014960106,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.059175532,"top":0.1292897,"width":0.0029920214,"height":0.011173184}},{"char_start":1,"char_count":5,"bounds":{"left":0.061835106,"top":0.1292897,"width":0.011968086,"height":0.011173184}}],"role_description":"text"},{"role":"AXPopUpButton","text":"More Actions...","depth":26,"bounds":{"left":0.10305851,"top":0.1245012,"width":0.00831117,"height":0.0207502},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.1043883,"top":0.12849163,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.019614361,"top":0.15163608,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Changes","depth":27,"bounds":{"left":0.025930852,"top":0.15163608,"width":0.01761968,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.025930852,"top":0.15243416,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":6,"bounds":{"left":0.028922873,"top":0.15243416,"width":0.014960106,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"17","depth":27,"bounds":{"left":0.10571808,"top":0.15243416,"width":0.0039893617,"height":0.0103751},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.10605053,"top":0.15243416,"width":0.0016622341,"height":0.0103751}},{"char_start":1,"char_count":1,"bounds":{"left":0.10771277,"top":0.15243416,"width":0.0019946808,"height":0.0103751}}],"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.16759777,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.local","depth":28,"bounds":{"left":0.028590426,"top":0.16919394,"width":0.01861702,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.16999201,"width":0.0013297872,"height":0.011971269}},{"char_start":1,"char_count":9,"bounds":{"left":0.029920213,"top":0.16999201,"width":0.01761968,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.16999201,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.18515563,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.nikilocal","depth":28,"bounds":{"left":0.028590426,"top":0.1867518,"width":0.025598405,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.18754987,"width":0.0013297872,"height":0.011971269}},{"char_start":1,"char_count":13,"bounds":{"left":0.029920213,"top":0.18754987,"width":0.024268618,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.18754987,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.20271349,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.other","depth":28,"bounds":{"left":0.028590426,"top":0.20430966,"width":0.019946808,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.20510775,"width":0.0013297872,"height":0.011971269}},{"char_start":1,"char_count":9,"bounds":{"left":0.029920213,"top":0.20510775,"width":0.01861702,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.20510775,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.22027135,"width":0.0076462766,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ids.txt","depth":28,"bounds":{"left":0.028590426,"top":0.22186752,"width":0.012300532,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.22266561,"width":0.0009973404,"height":0.011971269}},{"char_start":1,"char_count":6,"bounds":{"left":0.029587766,"top":0.22266561,"width":0.011635638,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.22266561,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.23782921,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"raw_sql_query.sql","depth":28,"bounds":{"left":0.028590426,"top":0.23942538,"width":0.035904255,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.24022347,"width":0.0016622341,"height":0.011971269}},{"char_start":1,"char_count":16,"bounds":{"left":0.03025266,"top":0.24022347,"width":0.034242023,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.24022347,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.25538707,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"WEBHOOK_FILTERING_IMPLEMENTATION.md","depth":28,"bounds":{"left":0.028590426,"top":0.25698325,"width":0.076130316,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.25778133,"width":0.0043218085,"height":0.011971269}},{"char_start":1,"char_count":34,"bounds":{"left":0.032912236,"top":0.25778133,"width":0.08909574,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.25778133,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.27294493,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JiminnyDebugCommand.php","depth":28,"bounds":{"left":0.028590426,"top":0.2745411,"width":0.05817819,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.2753392,"width":0.0023271276,"height":0.011971269}},{"char_start":1,"char_count":22,"bounds":{"left":0.030917553,"top":0.2753392,"width":0.055851065,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands","depth":28,"bounds":{"left":0.08843085,"top":0.2745411,"width":0.01462766,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.0887633,"top":0.27613726,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":19,"bounds":{"left":0.09075798,"top":0.27613726,"width":0.043218084,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.2753392,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.2905028,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"SimulateWebhooksCommand.php","depth":28,"bounds":{"left":0.028590426,"top":0.29209897,"width":0.06781915,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.29289705,"width":0.0026595744,"height":0.011971269}},{"char_start":1,"char_count":26,"bounds":{"left":0.03125,"top":0.29289705,"width":0.065159574,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands/Crm/Hubspot","depth":28,"bounds":{"left":0.098071806,"top":0.29209897,"width":0.005984043,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.09840426,"top":0.29369512,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":31,"bounds":{"left":0.100398935,"top":0.29369512,"width":0.06881649,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.29289705,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.30806065,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CreateMockAskJiminnyReportResultCommand.php","depth":28,"bounds":{"left":0.028590426,"top":0.30965683,"width":0.07646277,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.3104549,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":42,"bounds":{"left":0.03158245,"top":0.3104549,"width":0.099734046,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands/Reports","depth":28,"bounds":{"left":0.10538564,"top":0.3104549,"width":0.0003324468,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.13331117,"top":0.31125298,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":27,"bounds":{"left":0.13530585,"top":0.31125298,"width":0.05851064,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.3104549,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.3256185,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException.php","depth":28,"bounds":{"left":0.028590426,"top":0.3272147,"width":0.047539894,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.32801276,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":21,"bounds":{"left":0.03158245,"top":0.32801276,"width":0.04488032,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Exceptions","depth":28,"bounds":{"left":0.078125,"top":0.3272147,"width":0.024933511,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.078125,"top":0.32881084,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":13,"bounds":{"left":0.08045213,"top":0.32881084,"width":0.026263298,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.32801276,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.34317636,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"MatchActivityCrmData.php","depth":28,"bounds":{"left":0.028590426,"top":0.34477255,"width":0.054521278,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.34557062,"width":0.0039893617,"height":0.011971269}},{"char_start":1,"char_count":23,"bounds":{"left":0.032579787,"top":0.34557062,"width":0.050531916,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Jobs/Crm","depth":28,"bounds":{"left":0.084773935,"top":0.34477255,"width":0.020279255,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.084773935,"top":0.3463687,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":11,"bounds":{"left":0.087101065,"top":0.3463687,"width":0.023603724,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.34557062,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.36073422,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Client.php","depth":28,"bounds":{"left":0.028590426,"top":0.3623304,"width":0.020611702,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.36312848,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":9,"bounds":{"left":0.03158245,"top":0.36312848,"width":0.01761968,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Services/Crm/Hubspot","depth":28,"bounds":{"left":0.05119681,"top":0.3623304,"width":0.028590426,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.05119681,"top":0.3639266,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":23,"bounds":{"left":0.053523935,"top":0.3639266,"width":0.047539894,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"9, M","depth":28,"bounds":{"left":0.10206117,"top":0.36312848,"width":0.00831117,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.10206117,"top":0.36312848,"width":0.0026595744,"height":0.011173184}},{"char_start":1,"char_count":3,"bounds":{"left":0.10472074,"top":0.36312848,"width":0.005984043,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.3782921,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"logging.php","depth":28,"bounds":{"left":0.028590426,"top":0.37988827,"width":0.023936171,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.38068634,"width":0.0009973404,"height":0.011971269}},{"char_start":1,"char_count":10,"bounds":{"left":0.029587766,"top":0.38068634,"width":0.022938829,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"config","depth":28,"bounds":{"left":0.05418883,"top":0.38068634,"width":0.011968086,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.054521278,"top":0.38148445,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":5,"bounds":{"left":0.05651596,"top":0.38148445,"width":0.009640957,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.38068634,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.39584997,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"favicon.ico","depth":28,"bounds":{"left":0.028590426,"top":0.39744613,"width":0.021609042,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.3982442,"width":0.0016622341,"height":0.011971269}},{"char_start":1,"char_count":10,"bounds":{"left":0.03025266,"top":0.3982442,"width":0.020279255,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"public","depth":28,"bounds":{"left":0.05219415,"top":0.3982442,"width":0.011303191,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.05219415,"top":0.3990423,"width":0.0026595744,"height":0.011173184}},{"char_start":1,"char_count":5,"bounds":{"left":0.054521278,"top":0.3990423,"width":0.008976064,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.3982442,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.41340783,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php","depth":28,"bounds":{"left":0.028590426,"top":0.415004,"width":0.06781915,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.41580206,"width":0.0033244682,"height":0.011971269}},{"char_start":1,"char_count":29,"bounds":{"left":0.031914894,"top":0.41580206,"width":0.064494684,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Jobs/Middleware","depth":28,"bounds":{"left":0.098071806,"top":0.415004,"width":0.004986702,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.098071806,"top":0.41660017,"width":0.0016622341,"height":0.011173184}},{"char_start":1,"char_count":25,"bounds":{"left":0.09940159,"top":0.41660017,"width":0.04886968,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.41580206,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.4309657,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CanAccessAiReportsTest.php","depth":28,"bounds":{"left":0.028590426,"top":0.43256184,"width":0.059175532,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.43335995,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":25,"bounds":{"left":0.03158245,"top":0.43335995,"width":0.05618351,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Policies","depth":28,"bounds":{"left":0.089428194,"top":0.43256184,"width":0.013962766,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.08976064,"top":0.43415803,"width":0.0013297872,"height":0.011173184}},{"char_start":1,"char_count":18,"bounds":{"left":0.091090426,"top":0.43415803,"width":0.031914894,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.43335995,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.44852355,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClientTest.php","depth":28,"bounds":{"left":0.028590426,"top":0.4501197,"width":0.028922873,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.4509178,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":13,"bounds":{"left":0.03158245,"top":0.4509178,"width":0.026263298,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Services/Crm/Hubspot","depth":28,"bounds":{"left":0.059507977,"top":0.4501197,"width":0.043882977,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.059507977,"top":0.4517159,"width":0.0016622341,"height":0.011173184}},{"char_start":1,"char_count":30,"bounds":{"left":0.060837764,"top":0.4517159,"width":0.059507977,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.4509178,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Graph Section","depth":21,"bounds":{"left":0.015957447,"top":0.60015965,"width":0.09940159,"height":0.017557861},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"","depth":23,"bounds":{"left":0.01662234,"top":0.60255384,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"GRAPH","depth":22,"bounds":{"left":0.022606382,"top":0.60015965,"width":0.013297873,"height":0.017557861},"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"GRAPH","depth":23,"bounds":{"left":0.022606382,"top":0.60415006,"width":0.013297873,"height":0.0103751},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add more tests","depth":28,"bounds":{"left":0.023271276,"top":0.6201117,"width":0.038231384,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.023271276,"top":0.6209098,"width":0.0026595744,"height":0.011971269}},{"char_start":1,"char_count":22,"bounds":{"left":0.025930852,"top":0.6209098,"width":0.05086436,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.061835106,"top":0.6209098,"width":0.0003324468,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.078457445,"top":0.6217079,"width":0.0026595744,"height":0.011173184}},{"char_start":1,"char_count":12,"bounds":{"left":0.080784574,"top":0.6217079,"width":0.023271276,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.06382979,"top":0.6201117,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit","depth":27,"bounds":{"left":0.06948138,"top":0.6209098,"width":0.032579787,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.06948138,"top":0.6209098,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":35,"bounds":{"left":0.07180851,"top":0.6209098,"width":0.071476065,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.10571808,"top":0.6201117,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add tests","depth":28,"bounds":{"left":0.023271276,"top":0.63766956,"width":0.03956117,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.023271276,"top":0.63846767,"width":0.0023271276,"height":0.011971269}},{"char_start":1,"char_count":17,"bounds":{"left":0.025598405,"top":0.63846767,"width":0.03723404,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.064494684,"top":0.63846767,"width":0.024933511,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.06482713,"top":0.6392658,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":12,"bounds":{"left":0.066821806,"top":0.6392658,"width":0.022938829,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching","depth":28,"bounds":{"left":0.023271276,"top":0.6552275,"width":0.08577128,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.023271276,"top":0.6560255,"width":0.0023271276,"height":0.011971269}},{"char_start":1,"char_count":59,"bounds":{"left":0.025598405,"top":0.6560255,"width":0.11968085,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.109707445,"top":0.6560255,"width":0.0003324468,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.14727394,"top":0.65682364,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":12,"bounds":{"left":0.14926861,"top":0.65682364,"width":0.022938829,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric","depth":28,"bounds":{"left":0.026928192,"top":0.67278534,"width":0.049534574,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.0774601,"top":0.6735834,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.07945479,"top":0.67278534,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"origin/master","depth":27,"bounds":{"left":0.08510638,"top":0.6735834,"width":0.024933511,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric","depth":28,"bounds":{"left":0.030585106,"top":0.6903432,"width":0.078457445,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.109707445,"top":0.69114125,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit","depth":28,"bounds":{"left":0.030585106,"top":0.70790106,"width":0.078457445,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"bounds":{"left":0.109707445,"top":0.7086991,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20819: Increase download transcription rate limit","depth":28,"bounds":{"left":0.030585106,"top":0.7254589,"width":0.078457445,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihail.mihaylov","depth":28,"bounds":{"left":0.109707445,"top":0.72625697,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20818 move ask jiminny reports to its own datadog metric","depth":28,"bounds":{"left":0.030585106,"top":0.7430168,"width":0.07945479,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.109707445,"top":0.7438148,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks","depth":28,"bounds":{"left":0.030585106,"top":0.76057464,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"bounds":{"left":0.109707445,"top":0.7613727,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-20817-fix-deleting-old-tracks","depth":28,"bounds":{"left":0.030585106,"top":0.7781325,"width":0.078125,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"bounds":{"left":0.109707445,"top":0.77893054,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12047 from jiminny/JY-20662-remove-word-boost","depth":28,"bounds":{"left":0.030585106,"top":0.79569036,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"bounds":{"left":0.109707445,"top":0.7964884,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20662 return void","depth":28,"bounds":{"left":0.030585106,"top":0.8132482,"width":0.043218084,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.075465426,"top":0.81404626,"width":0.0076462766,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20662 Removed word_boost parameter from Assembly transcription request","depth":28,"bounds":{"left":0.030585106,"top":0.8308061,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.109707445,"top":0.8316041,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20817: Fix deleting old tracks","depth":28,"bounds":{"left":0.030585106,"top":0.84836394,"width":0.06582447,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihail.mihaylov","depth":28,"bounds":{"left":0.098071806,"top":0.84836394,"width":0.010305851,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es","depth":28,"bounds":{"left":0.030585106,"top":0.8659218,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"bounds":{"left":0.109707445,"top":0.8667199,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-19938-bot-status-in-csv-es","depth":28,"bounds":{"left":0.030585106,"top":0.88347965,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"bounds":{"left":0.109707445,"top":0.88427776,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update","depth":28,"bounds":{"left":0.030585106,"top":0.9010375,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"bounds":{"left":0.109707445,"top":0.9018356,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 Added consent information and recording error reason to Elastic Search mapping","depth":28,"bounds":{"left":0.030585106,"top":0.9185954,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.109707445,"top":0.9193935,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 reorder array map","depth":28,"bounds":{"left":0.030585106,"top":0.93615323,"width":0.056848403,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.089428194,"top":0.93695134,"width":0.00731383,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938: Address PR review comments","depth":28,"bounds":{"left":0.030585106,"top":0.9537111,"width":0.07912234,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.109707445,"top":0.9545092,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 lint fixes","depth":28,"bounds":{"left":0.030585106,"top":0.97126895,"width":0.03756649,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.06981383,"top":0.97206706,"width":0.0076462766,"height":0.0103751},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1","depth":28,"on_screen":false,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1","depth":28,"bounds":{"left":0.11569149,"top":0.047885075,"width":0.022606382,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Client.php (Working Tree) (Client.php), Editor Group 1","depth":28,"bounds":{"left":0.13796543,"top":0.047885075,"width":0.08211436,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1","depth":28,"bounds":{"left":0.21974733,"top":0.047885075,"width":0.111369684,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"MatchActivityCrmData.php, Editor Group 1","depth":28,"bounds":{"left":0.3307846,"top":0.047885075,"width":0.080119684,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1","depth":28,"bounds":{"left":0.4105718,"top":0.047885075,"width":0.1043883,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Client.php, preview, Editor Group 1","depth":28,"bounds":{"left":0.5149601,"top":0.047885075,"width":0.05119681,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.12832446,"top":0.07821229,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.15093085,"top":0.07821229,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.16489361,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.1875,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.22074468,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.22606383,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.24401596,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.24933511,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"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 {","depth":28,"bounds":{"left":0.13996011,"top":0.53790903,"width":0.2762633,"height":0.014365523},"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 {","role_description":"editor","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","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 {","depth":29,"bounds":{"left":0.13996011,"top":0.53790903,"width":0.2762633,"height":0.014365523},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Review rate limit handli…, Editor Group 2","depth":28,"bounds":{"left":0.61768615,"top":0.047885075,"width":0.07014628,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXButton","text":"remote","depth":16,"bounds":{"left":0.0006648936,"top":0.98244214,"width":0.010638298,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...","depth":16,"bounds":{"left":0.012965426,"top":0.98244214,"width":0.084773935,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":0.013962766,"top":0.9848364,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit*","depth":17,"bounds":{"left":0.019281914,"top":0.9856345,"width":0.0774601,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"app (Git) - Synchronize Changes","depth":16,"bounds":{"left":0.09740692,"top":0.98244214,"width":0.0076462766,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Errors: 6, Warnings: 11, Infos: 28","depth":16,"bounds":{"left":0.107380316,"top":0.98244214,"width":0.03756649,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":0.109042555,"top":0.9848364,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"6","depth":17,"bounds":{"left":0.1143617,"top":0.9856345,"width":0.004986702,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":0.11934841,"top":0.9848364,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":17,"bounds":{"left":0.124667555,"top":0.9856345,"width":0.00731383,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":0.13164894,"top":0.9848364,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"28","depth":17,"bounds":{"left":0.13696809,"top":0.9856345,"width":0.0063164895,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Notifications","depth":16,"bounds":{"left":0.9886968,"top":0.98244214,"width":0.010638298,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"key, PHP extension: Premium features not active.","depth":16,"bounds":{"left":0.9790558,"top":0.98244214,"width":0.008643617,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"8.3, Language Level: 8.3","depth":16,"bounds":{"left":0.9674202,"top":0.98244214,"width":0.009640957,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sign In","depth":16,"bounds":{"left":0.9428192,"top":0.98244214,"width":0.022938829,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":0.9444814,"top":0.9848364,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Sign In","depth":17,"bounds":{"left":0.94980055,"top":0.9856345,"width":0.014295213,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"PHP","depth":16,"bounds":{"left":0.93018615,"top":0.98244214,"width":0.010305851,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Editor Language Status: $(copilot) No inline suggestion available, Inline suggestions","depth":16,"bounds":{"left":0.92287236,"top":0.98244214,"width":0.0076462766,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"LF","depth":16,"bounds":{"left":0.9125665,"top":0.98244214,"width":0.007978723,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UTF-8","depth":16,"bounds":{"left":0.8949468,"top":0.98244214,"width":0.015625,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Spaces: 4","depth":16,"bounds":{"left":0.87101066,"top":0.98244214,"width":0.022273935,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Ln 120, Col 1","depth":16,"bounds":{"left":0.84042555,"top":0.98244214,"width":0.028922873,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Diff editor","depth":12,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Clear","depth":12,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Review rate limit handling for HubSpot job queue","depth":19,"bounds":{"left":0.6200133,"top":0.08060654,"width":0.099734046,"height":0.022346368},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Session history","depth":19,"bounds":{"left":0.9780585,"top":0.08060654,"width":0.00930851,"height":0.022346368},"on_screen":true,"help_text":"Session history","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New session","depth":19,"bounds":{"left":0.9886968,"top":0.08060654,"width":0.00930851,"height":0.022346368},"on_screen":true,"help_text":"New session","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Message actions","depth":24,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"RateLimitException.php#9-9","depth":23,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"RateLimitException.php#9-9","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Reveiw the diff. Mostly","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Jobs/Crm/MatchActivityCrmData.php","depth":26,"on_screen":false,"help_text":"Open app/Jobs/Crm/MatchActivityCrmData.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Exceptions/RateLimitException.php","depth":26,"on_screen":false,"help_text":"Open app/Exceptions/RateLimitException.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Jobs/Middleware/HandleHubspotRateLimit.php","depth":26,"on_screen":false,"help_text":"Open app/Jobs/Middleware/HandleHubspotRateLimit.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Services/Crm/Hubspot/Client.php","depth":26,"on_screen":false,"help_text":"Open app/Services/Crm/Hubspot/Client.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"and","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php","depth":26,"on_screen":false,"help_text":"Open app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":". The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Thought for 5s","depth":23,"on_screen":false,"role_description":"disclosure triangle","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thought for 5s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"I'll review the implementation carefully. Let me first check how","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is used elsewhere and look at the test files and","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PaginationState","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all usages of RateLimitException outside the changed files","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\" /Users/lukas/jiminny/app/app --include=\"*.php\" | grep -v \"HandleHubspotRateLimit\\|MatchActivityCrmData\\|Client.php\\|RateLimitException.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |\n/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"PaginationState.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"PaginationState.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"HandleHubspotRateLimitTest.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check how RateLimitException is used in GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\\|LogicException\\|RuntimeException\" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all catch blocks or instanceof checks for RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"catch.*RateLimitException\\|instanceof RateLimitException\" /Users/lukas/jiminny/app/app --include=\"*.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls\n/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {\n/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all test references to RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\" /Users/lukas/jiminny/app/tests --include=\"*.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry/failure handling in GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"RateLimitException\\|retryUntil\\|backoff\\|maxExceptions\\|maxTries\\|release\\|failed\" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"13:use Jiminny\\Exceptions\\RateLimitException;\n51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n56: $this->release(30);","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry/failure handling in GenerateAiCallScoringJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"RateLimitException\\|retryUntil\\|backoff\\|maxExceptions\\|maxTries\\|release\\|failed\" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"17:use Jiminny\\Exceptions\\RateLimitException;\n68: RateLimitException |\n75: $this->release(30);","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry config in MatchActivityCrmData","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"retryAfter\\|RETRY_WINDOW\\|retryUntil\\|maxTries\" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"34: private const int RETRY_WINDOW_MINUTES = 30;\n45: public function retryUntil(): \\DateTimeInterface\n47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n77: return self::RETRY_WINDOW_MINUTES * 60 + 60;","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check the cache key format","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"getRateLimitCacheKey\\|hubspot:ratelimit\" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"78: $cacheKey = $this->getRateLimitCacheKey();\n110: private function getRateLimitCacheKey(): string\n112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find other jobs that might use the rate limit middleware","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"class.*Middleware\\|HandleHubspotRateLimit\" /Users/lukas/jiminny/app/app/Jobs --include=\"*.php\" | grep -v \"HandleHubspotRateLimit.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\\Jobs\\Middleware\\HandleHubspotRateLimit;\n/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];","depth":24,"on_screen":false,"role_description":"text"}]...
|
8740021331991216881
|
2153254832004008038
|
idle
|
accessibility
|
NULL
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G) - 17 pending changes
17
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Testing
Claude Code
SOURCE CONTROL
SOURCE CONTROL
Changes Section
CHANGES
CHANGES
Commit Changes on "JY-20725-handle-HS-search-rate-limit"
Commit
More Actions...
Changes
17
.env.local
M
.env.nikilocal
U
.env.other
U
ids.txt
U
raw_sql_query.sql
U
WEBHOOK_FILTERING_IMPLEMENTATION.md
U
JiminnyDebugCommand.php
app/Console/Commands
M
SimulateWebhooksCommand.php
app/Console/Commands/Crm/Hubspot
U
CreateMockAskJiminnyReportResultCommand.php
app/Console/Commands/Reports
U
RateLimitException.php
app/Exceptions
M
MatchActivityCrmData.php
app/Jobs/Crm
M
Client.php
app/Services/Crm/Hubspot
9, M
logging.php
config
M
favicon.ico
public
U
HandleHubspotRateLimitTest.php
tests/Unit/Jobs/Middleware
M
CanAccessAiReportsTest.php
tests/Unit/Policies
U
ClientTest.php
tests/Unit/Services/Crm/Hubspot
M
Graph Section
GRAPH
GRAPH
JY-20725 add more tests
Lukas Kovalik
JY-20725-handle-HS-search-rate-limit
JY-20725 add tests
Lukas Kovalik
JY-20725 add HS rate limit handling on activities rematching
Lukas Kovalik
Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
origin/master
Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit
mihailmihaylovjiminny
JY-20819: Increase download transcription rate limit
mihail.mihaylov
JY-20818 move ask jiminny reports to its own datadog metric
Lukas Kovalik
Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge branch 'master' into JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge pull request #12047 from jiminny/JY-20662-remove-word-boost
ilian-jiminny
JY-20662 return void
ilian
JY-20662 Removed word_boost parameter from Assembly transcription request
ilian
JY-20817: Fix deleting old tracks
mihail.mihaylov
Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge branch 'master' into JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update
ilian-jiminny
JY-19938 Added consent information and recording error reason to Elastic Search mapping
ilian
JY-19938 reorder array map
ilian
JY-19938: Address PR review comments
ilian
JY-19938 lint fixes
ilian
HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1
ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1
Client.php (Working Tree) (Client.php), Editor Group 1
MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1
MatchActivityCrmData.php, Editor Group 1
RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1
Client.php, preview, Editor Group 1
<?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 {
<?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 {
Review rate limit handli…, Editor Group 2
remote
app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...
JY-20725-handle-HS-search-rate-limit*
app (Git) - Synchronize Changes
Errors: 6, Warnings: 11, Infos: 28
6
11
28
Notifications
key, PHP extension: Premium features not active.
8.3, Language Level: 8.3
Sign In
Sign In
PHP
Editor Language Status: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 4
Ln 120, Col 1
Diff editor
Clear
Review rate limit handling for HubSpot job queue
Session history
New session
Message actions
RateLimitException.php#9-9
RateLimitException.php#9-9
Reveiw the diff. Mostly
@app/Jobs/Crm/MatchActivityCrmData.php
,
@app/Exceptions/RateLimitException.php
,
@app/Jobs/Middleware/HandleHubspotRateLimit.php
,
@app/Services/Crm/Hubspot/Client.php
and
@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php
. The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?
Thought for 5s
Thought for 5s
I'll review the implementation carefully. Let me first check how
RateLimitException
is used elsewhere and look at the test files and
PaginationState
.
Bash
Find all usages of RateLimitException outside the changed files
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/app --include="*.php" | grep -v "HandleHubspotRateLimit\|MatchActivityCrmData\|Client.php\|RateLimitException.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is
Read
PaginationState.php
PaginationState.php
Read
HandleHubspotRateLimitTest.php
HandleHubspotRateLimitTest.php
Bash
Check how RateLimitException is used in GenerateActionItemsJob
IN
grep -rn "RateLimitException\|LogicException\|RuntimeException" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
Bash
Find all catch blocks or instanceof checks for RateLimitException
IN
grep -rn "catch.*RateLimitException\|instanceof RateLimitException" /Users/lukas/jiminny/app/app --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {
Bash
Find all test references to RateLimitException
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/tests --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);
Bash
Check retry/failure handling in GenerateActionItemsJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30
Copy code to clipboard
OUT
13:use Jiminny\Exceptions\RateLimitException;
51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
56: $this->release(30);
Bash
Check retry/failure handling in GenerateAiCallScoringJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30
Copy code to clipboard
OUT
17:use Jiminny\Exceptions\RateLimitException;
68: RateLimitException |
75: $this->release(30);
Bash
Check retry config in MatchActivityCrmData
IN
grep -n "retryAfter\|RETRY_WINDOW\|retryUntil\|maxTries" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php
Copy code to clipboard
OUT
34: private const int RETRY_WINDOW_MINUTES = 30;
45: public function retryUntil(): \DateTimeInterface
47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);
77: return self::RETRY_WINDOW_MINUTES * 60 + 60;
Bash
Check the cache key format
IN
grep -n "getRateLimitCacheKey\|hubspot:ratelimit" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Copy code to clipboard
OUT
78: $cacheKey = $this->getRateLimitCacheKey();
110: private function getRateLimitCacheKey(): string
112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());
Bash
Find other jobs that might use the rate limit middleware
IN
grep -rn "class.*Middleware\|HandleHubspotRateLimit" /Users/lukas/jiminny/app/app/Jobs --include="*.php" | grep -v "HandleHubspotRateLimit.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];...
|
NULL
|
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/ /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php...
|
NULL
|
NULL
|
|
20435
|
NULL
|
0
|
2026-05-11T15:29:07.591195+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778513347591_m1.jpg...
|
Code
|
Client.php — app — 9 problems in this file • Modif Client.php — app — 9 problems in this file • Modified...
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G) - 17 pending changes
17
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Testing
Claude Code
SOURCE CONTROL
SOURCE CONTROL
Changes Section
CHANGES
CHANGES
Commit Changes on "JY-20725-handle-HS-search-rate-limit"
Commit
More Actions...
Changes
17
.env.local
M
.env.nikilocal
U
.env.other
U
ids.txt
U
raw_sql_query.sql
U
WEBHOOK_FILTERING_IMPLEMENTATION.md
U
JiminnyDebugCommand.php
app/Console/Commands
M
SimulateWebhooksCommand.php
app/Console/Commands/Crm/Hubspot
U
CreateMockAskJiminnyReportResultCommand.php
app/Console/Commands/Reports
U
RateLimitException.php
app/Exceptions
M
MatchActivityCrmData.php
app/Jobs/Crm
M
Client.php
app/Services/Crm/Hubspot
9, M
logging.php
config
M
favicon.ico
public
U
HandleHubspotRateLimitTest.php
tests/Unit/Jobs/Middleware
M
CanAccessAiReportsTest.php
tests/Unit/Policies
U
ClientTest.php
tests/Unit/Services/Crm/Hubspot
M
Graph Section
GRAPH
GRAPH
JY-20725 add more tests
Lukas Kovalik
JY-20725-handle-HS-search-rate-limit
JY-20725 add tests
Lukas Kovalik
JY-20725 add HS rate limit handling on activities rematching
Lukas Kovalik
Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
origin/master
Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit
mihailmihaylovjiminny
JY-20819: Increase download transcription rate limit
mihail.mihaylov
JY-20818 move ask jiminny reports to its own datadog metric
Lukas Kovalik
Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge branch 'master' into JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge pull request #12047 from jiminny/JY-20662-remove-word-boost
ilian-jiminny
JY-20662 return void
ilian
JY-20662 Removed word_boost parameter from Assembly transcription request
ilian
JY-20817: Fix deleting old tracks
mihail.mihaylov
Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge branch 'master' into JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update
ilian-jiminny
JY-19938 Added consent information and recording error reason to Elastic Search mapping
ilian
JY-19938 reorder array map
ilian
JY-19938: Address PR review comments
ilian
JY-19938 lint fixes
ilian
HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1
ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1
Client.php (Working Tree) (Client.php), Editor Group 1
MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1
MatchActivityCrmData.php, Editor Group 1
RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1
Client.php, preview, Editor Group 1
<?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 {
<?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 {
Review rate limit handli…, Editor Group 2
remote
app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...
JY-20725-handle-HS-search-rate-limit*
app (Git) - Synchronize Changes
Errors: 6, Warnings: 11, Infos: 28
6
11
28
Notifications
key, PHP extension: Premium features not active.
8.3, Language Level: 8.3
Sign In
Sign In
PHP
Editor Language Status: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 4
Ln 320, Col 30
git-commit Lukas Kovalik (5 months ago)
Lukas Kovalik (5 months ago)
Found 1 symbol in /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Clear
Review rate limit handling for HubSpot job queue
Session history
New session
Message actions
RateLimitException.php#9-9
RateLimitException.php#9-9
Reveiw the diff. Mostly
@app/Jobs/Crm/MatchActivityCrmData.php
,
@app/Exceptions/RateLimitException.php
,
@app/Jobs/Middleware/HandleHubspotRateLimit.php
,
@app/Services/Crm/Hubspot/Client.php
and
@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php
. The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?
Thought for 5s
Thought for 5s
I'll review the implementation carefully. Let me first check how
RateLimitException
is used elsewhere and look at the test files and
PaginationState
.
Bash
Find all usages of RateLimitException outside the changed files
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/app --include="*.php" | grep -v "HandleHubspotRateLimit\|MatchActivityCrmData\|Client.php\|RateLimitException.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is
Read
PaginationState.php
PaginationState.php
Read
HandleHubspotRateLimitTest.php
HandleHubspotRateLimitTest.php
Bash
Check how RateLimitException is used in GenerateActionItemsJob
IN
grep -rn "RateLimitException\|LogicException\|RuntimeException" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
Bash
Find all catch blocks or instanceof checks for RateLimitException
IN
grep -rn "catch.*RateLimitException\|instanceof RateLimitException" /Users/lukas/jiminny/app/app --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {
Bash
Find all test references to RateLimitException
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/tests --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);
Bash
Check retry/failure handling in GenerateActionItemsJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30
Copy code to clipboard
OUT
13:use Jiminny\Exceptions\RateLimitException;
51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
56: $this->release(30);
Bash
Check retry/failure handling in GenerateAiCallScoringJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30
Copy code to clipboard
OUT
17:use Jiminny\Exceptions\RateLimitException;
68: RateLimitException |
75: $this->release(30);
Bash
Check retry config in MatchActivityCrmData
IN
grep -n "retryAfter\|RETRY_WINDOW\|retryUntil\|maxTries" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php
Copy code to clipboard
OUT
34: private const int RETRY_WINDOW_MINUTES = 30;
45: public function retryUntil(): \DateTimeInterface
47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);
77: return self::RETRY_WINDOW_MINUTES * 60 + 60;
Bash
Check the cache key format
IN
grep -n "getRateLimitCacheKey\|hubspot:ratelimit" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Copy code to clipboard
OUT
78: $cacheKey = $this->getRateLimitCacheKey();
110: private function getRateLimitCacheKey(): string
112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());
Bash
Find other jobs that might use the rate limit middleware
IN
grep -rn "class.*Middleware\|HandleHubspotRateLimit" /Users/lukas/jiminny/app/app/Jobs --include="*.php" | grep -v "HandleHubspotRateLimit.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];
Read
HubspotClientInterface.php
HubspotClientInterface.php
Thought for 73s
Thought for 73s
Now I have a complete picture. Here's my full analysis:
Review Summary...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Explorer (⇧⌘E)","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Search (⇧⌘F)","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Source Control (⌃⇧G) - 17 pending changes","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":true},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"17","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Run and Debug (⇧⌘D)","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Remote Explorer","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Extensions (⇧⌘X) - 2 require update","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"2","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Testing","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Claude Code","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"SOURCE CONTROL","depth":17,"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"SOURCE CONTROL","depth":18,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Changes Section","depth":21,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"CHANGES","depth":22,"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"CHANGES","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"","depth":28,"on_screen":true,"value":"","role_description":"editor","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":29,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Commit Changes on \"JY-20725-handle-HS-search-rate-limit\"","depth":26,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Commit","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"More Actions...","depth":26,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Changes","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"17","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.local","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.nikilocal","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.other","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ids.txt","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"raw_sql_query.sql","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"WEBHOOK_FILTERING_IMPLEMENTATION.md","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JiminnyDebugCommand.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"SimulateWebhooksCommand.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands/Crm/Hubspot","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CreateMockAskJiminnyReportResultCommand.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands/Reports","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Exceptions","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"MatchActivityCrmData.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Jobs/Crm","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Client.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Services/Crm/Hubspot","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"9, M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"logging.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"config","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"favicon.ico","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"public","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Jobs/Middleware","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CanAccessAiReportsTest.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Policies","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClientTest.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Services/Crm/Hubspot","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Graph Section","depth":21,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"GRAPH","depth":22,"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"GRAPH","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add more tests","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add tests","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"origin/master","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20819: Increase download transcription rate limit","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihail.mihaylov","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20818 move ask jiminny reports to its own datadog metric","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-20817-fix-deleting-old-tracks","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12047 from jiminny/JY-20662-remove-word-boost","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20662 return void","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20662 Removed word_boost parameter from Assembly transcription request","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20817: Fix deleting old tracks","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihail.mihaylov","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-19938-bot-status-in-csv-es","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 Added consent information and recording error reason to Elastic Search mapping","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 reorder array map","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938: Address PR review comments","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 lint fixes","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1","depth":28,"on_screen":false,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Client.php (Working Tree) (Client.php), Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"MatchActivityCrmData.php, Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Client.php, preview, Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"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 {","depth":28,"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 {","role_description":"editor","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","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 {","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Review rate limit handli…, Editor Group 2","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXButton","text":"remote","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit*","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"app (Git) - Synchronize Changes","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Errors: 6, Warnings: 11, Infos: 28","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"6","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"28","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Notifications","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"key, PHP extension: Premium features not active.","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"8.3, Language Level: 8.3","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sign In","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Sign In","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"PHP","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Editor Language Status: $(copilot) No inline suggestion available, Inline suggestions","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"LF","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UTF-8","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Spaces: 4","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Ln 320, Col 30","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"git-commit Lukas Kovalik (5 months ago)","depth":16,"bounds":{"left":1.0,"top":0.0,"width":-0.045138836,"height":0.024444444},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":1.0,"top":0.0,"width":-0.048611164,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik (5 months ago)","depth":17,"bounds":{"left":1.0,"top":0.0,"width":-0.059722185,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Found 1 symbol in /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php","depth":12,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Clear","depth":12,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Review rate limit handling for HubSpot job queue","depth":19,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Session history","depth":19,"on_screen":true,"help_text":"Session history","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New session","depth":19,"on_screen":true,"help_text":"New session","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Message actions","depth":24,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"RateLimitException.php#9-9","depth":23,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"RateLimitException.php#9-9","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Reveiw the diff. Mostly","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Jobs/Crm/MatchActivityCrmData.php","depth":26,"on_screen":false,"help_text":"Open app/Jobs/Crm/MatchActivityCrmData.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Exceptions/RateLimitException.php","depth":26,"on_screen":false,"help_text":"Open app/Exceptions/RateLimitException.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Jobs/Middleware/HandleHubspotRateLimit.php","depth":26,"on_screen":false,"help_text":"Open app/Jobs/Middleware/HandleHubspotRateLimit.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Services/Crm/Hubspot/Client.php","depth":26,"on_screen":false,"help_text":"Open app/Services/Crm/Hubspot/Client.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"and","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php","depth":26,"on_screen":false,"help_text":"Open app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":". The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Thought for 5s","depth":23,"on_screen":false,"role_description":"disclosure triangle","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thought for 5s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"I'll review the implementation carefully. Let me first check how","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is used elsewhere and look at the test files and","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PaginationState","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all usages of RateLimitException outside the changed files","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\" /Users/lukas/jiminny/app/app --include=\"*.php\" | grep -v \"HandleHubspotRateLimit\\|MatchActivityCrmData\\|Client.php\\|RateLimitException.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |\n/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"PaginationState.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"PaginationState.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"HandleHubspotRateLimitTest.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check how RateLimitException is used in GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\\|LogicException\\|RuntimeException\" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all catch blocks or instanceof checks for RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"catch.*RateLimitException\\|instanceof RateLimitException\" /Users/lukas/jiminny/app/app --include=\"*.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls\n/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {\n/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all test references to RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\" /Users/lukas/jiminny/app/tests --include=\"*.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry/failure handling in GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"RateLimitException\\|retryUntil\\|backoff\\|maxExceptions\\|maxTries\\|release\\|failed\" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"13:use Jiminny\\Exceptions\\RateLimitException;\n51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n56: $this->release(30);","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry/failure handling in GenerateAiCallScoringJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"RateLimitException\\|retryUntil\\|backoff\\|maxExceptions\\|maxTries\\|release\\|failed\" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"17:use Jiminny\\Exceptions\\RateLimitException;\n68: RateLimitException |\n75: $this->release(30);","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry config in MatchActivityCrmData","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"retryAfter\\|RETRY_WINDOW\\|retryUntil\\|maxTries\" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"34: private const int RETRY_WINDOW_MINUTES = 30;\n45: public function retryUntil(): \\DateTimeInterface\n47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n77: return self::RETRY_WINDOW_MINUTES * 60 + 60;","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check the cache key format","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"getRateLimitCacheKey\\|hubspot:ratelimit\" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"78: $cacheKey = $this->getRateLimitCacheKey();\n110: private function getRateLimitCacheKey(): string\n112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find other jobs that might use the rate limit middleware","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"class.*Middleware\\|HandleHubspotRateLimit\" /Users/lukas/jiminny/app/app/Jobs --include=\"*.php\" | grep -v \"HandleHubspotRateLimit.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\\Jobs\\Middleware\\HandleHubspotRateLimit;\n/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"HubspotClientInterface.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"HubspotClientInterface.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Thought for 73s","depth":23,"on_screen":false,"role_description":"disclosure triangle","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thought for 73s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Now I have a complete picture. Here's my full analysis:","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Review Summary","depth":22,"on_screen":false,"role_description":"heading"}]...
|
7272708798267465497
|
2153254832004008038
|
idle
|
accessibility
|
NULL
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G) - 17 pending changes
17
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Testing
Claude Code
SOURCE CONTROL
SOURCE CONTROL
Changes Section
CHANGES
CHANGES
Commit Changes on "JY-20725-handle-HS-search-rate-limit"
Commit
More Actions...
Changes
17
.env.local
M
.env.nikilocal
U
.env.other
U
ids.txt
U
raw_sql_query.sql
U
WEBHOOK_FILTERING_IMPLEMENTATION.md
U
JiminnyDebugCommand.php
app/Console/Commands
M
SimulateWebhooksCommand.php
app/Console/Commands/Crm/Hubspot
U
CreateMockAskJiminnyReportResultCommand.php
app/Console/Commands/Reports
U
RateLimitException.php
app/Exceptions
M
MatchActivityCrmData.php
app/Jobs/Crm
M
Client.php
app/Services/Crm/Hubspot
9, M
logging.php
config
M
favicon.ico
public
U
HandleHubspotRateLimitTest.php
tests/Unit/Jobs/Middleware
M
CanAccessAiReportsTest.php
tests/Unit/Policies
U
ClientTest.php
tests/Unit/Services/Crm/Hubspot
M
Graph Section
GRAPH
GRAPH
JY-20725 add more tests
Lukas Kovalik
JY-20725-handle-HS-search-rate-limit
JY-20725 add tests
Lukas Kovalik
JY-20725 add HS rate limit handling on activities rematching
Lukas Kovalik
Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
origin/master
Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit
mihailmihaylovjiminny
JY-20819: Increase download transcription rate limit
mihail.mihaylov
JY-20818 move ask jiminny reports to its own datadog metric
Lukas Kovalik
Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge branch 'master' into JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge pull request #12047 from jiminny/JY-20662-remove-word-boost
ilian-jiminny
JY-20662 return void
ilian
JY-20662 Removed word_boost parameter from Assembly transcription request
ilian
JY-20817: Fix deleting old tracks
mihail.mihaylov
Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge branch 'master' into JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update
ilian-jiminny
JY-19938 Added consent information and recording error reason to Elastic Search mapping
ilian
JY-19938 reorder array map
ilian
JY-19938: Address PR review comments
ilian
JY-19938 lint fixes
ilian
HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1
ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1
Client.php (Working Tree) (Client.php), Editor Group 1
MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1
MatchActivityCrmData.php, Editor Group 1
RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1
Client.php, preview, Editor Group 1
<?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 {
<?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 {
Review rate limit handli…, Editor Group 2
remote
app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...
JY-20725-handle-HS-search-rate-limit*
app (Git) - Synchronize Changes
Errors: 6, Warnings: 11, Infos: 28
6
11
28
Notifications
key, PHP extension: Premium features not active.
8.3, Language Level: 8.3
Sign In
Sign In
PHP
Editor Language Status: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 4
Ln 320, Col 30
git-commit Lukas Kovalik (5 months ago)
Lukas Kovalik (5 months ago)
Found 1 symbol in /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Clear
Review rate limit handling for HubSpot job queue
Session history
New session
Message actions
RateLimitException.php#9-9
RateLimitException.php#9-9
Reveiw the diff. Mostly
@app/Jobs/Crm/MatchActivityCrmData.php
,
@app/Exceptions/RateLimitException.php
,
@app/Jobs/Middleware/HandleHubspotRateLimit.php
,
@app/Services/Crm/Hubspot/Client.php
and
@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php
. The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?
Thought for 5s
Thought for 5s
I'll review the implementation carefully. Let me first check how
RateLimitException
is used elsewhere and look at the test files and
PaginationState
.
Bash
Find all usages of RateLimitException outside the changed files
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/app --include="*.php" | grep -v "HandleHubspotRateLimit\|MatchActivityCrmData\|Client.php\|RateLimitException.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is
Read
PaginationState.php
PaginationState.php
Read
HandleHubspotRateLimitTest.php
HandleHubspotRateLimitTest.php
Bash
Check how RateLimitException is used in GenerateActionItemsJob
IN
grep -rn "RateLimitException\|LogicException\|RuntimeException" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
Bash
Find all catch blocks or instanceof checks for RateLimitException
IN
grep -rn "catch.*RateLimitException\|instanceof RateLimitException" /Users/lukas/jiminny/app/app --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {
Bash
Find all test references to RateLimitException
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/tests --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);
Bash
Check retry/failure handling in GenerateActionItemsJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30
Copy code to clipboard
OUT
13:use Jiminny\Exceptions\RateLimitException;
51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
56: $this->release(30);
Bash
Check retry/failure handling in GenerateAiCallScoringJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30
Copy code to clipboard
OUT
17:use Jiminny\Exceptions\RateLimitException;
68: RateLimitException |
75: $this->release(30);
Bash
Check retry config in MatchActivityCrmData
IN
grep -n "retryAfter\|RETRY_WINDOW\|retryUntil\|maxTries" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php
Copy code to clipboard
OUT
34: private const int RETRY_WINDOW_MINUTES = 30;
45: public function retryUntil(): \DateTimeInterface
47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);
77: return self::RETRY_WINDOW_MINUTES * 60 + 60;
Bash
Check the cache key format
IN
grep -n "getRateLimitCacheKey\|hubspot:ratelimit" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Copy code to clipboard
OUT
78: $cacheKey = $this->getRateLimitCacheKey();
110: private function getRateLimitCacheKey(): string
112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());
Bash
Find other jobs that might use the rate limit middleware
IN
grep -rn "class.*Middleware\|HandleHubspotRateLimit" /Users/lukas/jiminny/app/app/Jobs --include="*.php" | grep -v "HandleHubspotRateLimit.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];
Read
HubspotClientInterface.php
HubspotClientInterface.php
Thought for 73s
Thought for 73s
Now I have a complete picture. Here's my full analysis:
Review Summary...
|
20433
|
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/ /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php...
|
NULL
|
NULL
|
|
20436
|
NULL
|
0
|
2026-05-11T15:29:08.388954+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778513348388_m2.jpg...
|
Code
|
Client.php — app — 9 problems in this file • Modif Client.php — app — 9 problems in this file • Modified...
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Testing
Claude Code
SOURCE CONTROL
SOURCE CONTROL
Changes Section
CHANGES
CHANGES
Commit Changes on "JY-20725-handle-HS-search-rate-limit"
Commit
More Actions...
Changes
17
.env.local
M
.env.nikilocal
U
.env.other
U
ids.txt
U
raw_sql_query.sql
U
WEBHOOK_FILTERING_IMPLEMENTATION.md
U
JiminnyDebugCommand.php
app/Console/Commands
M
SimulateWebhooksCommand.php
app/Console/Commands/Crm/Hubspot
U
CreateMockAskJiminnyReportResultCommand.php
app/Console/Commands/Reports
U
RateLimitException.php
app/Exceptions
M
MatchActivityCrmData.php
app/Jobs/Crm
M
Client.php
app/Services/Crm/Hubspot
9, M
logging.php
config
M
favicon.ico
public
U
HandleHubspotRateLimitTest.php
tests/Unit/Jobs/Middleware
M
CanAccessAiReportsTest.php
tests/Unit/Policies
U
ClientTest.php
tests/Unit/Services/Crm/Hubspot
M
Graph Section
GRAPH
GRAPH
JY-20725 add more tests
Lukas Kovalik
JY-20725-handle-HS-search-rate-limit
JY-20725 add tests
Lukas Kovalik
JY-20725 add HS rate limit handling on activities rematching
Lukas Kovalik
Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
origin/master
Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit
mihailmihaylovjiminny
JY-20819: Increase download transcription rate limit
mihail.mihaylov
JY-20818 move ask jiminny reports to its own datadog metric
Lukas Kovalik
Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge branch 'master' into JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge pull request #12047 from jiminny/JY-20662-remove-word-boost
ilian-jiminny
JY-20662 return void
ilian
JY-20662 Removed word_boost parameter from Assembly transcription request
ilian
JY-20817: Fix deleting old tracks
mihail.mihaylov
Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge branch 'master' into JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update
ilian-jiminny
JY-19938 Added consent information and recording error reason to Elastic Search mapping
ilian
JY-19938 reorder array map
ilian
JY-19938: Address PR review comments
ilian
JY-19938 lint fixes
ilian
HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1
ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1
Client.php (Working Tree) (Client.php), Editor Group 1
MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1
MatchActivityCrmData.php, Editor Group 1
RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1
Client.php, preview, Editor Group 1
<?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 {
<?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 {
Review rate limit handli…, Editor Group 2
remote
app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...
JY-20725-handle-HS-search-rate-limit*
app (Git) - Synchronize Changes
Errors: 6, Warnings: 11, Infos: 28
6
11
28
Notifications
key, PHP extension: Premium features not active.
8.3, Language Level: 8.3
Sign In
Sign In
PHP
Editor Language Status: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 4
Ln 320, Col 30
git-commit Lukas Kovalik (5 months ago)
Lukas Kovalik (5 months ago)
Found 1 symbol in /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Clear
Review rate limit handling for HubSpot job queue
Session history
New session
Message actions
RateLimitException.php#9-9
RateLimitException.php#9-9
Reveiw the diff. Mostly
@app/Jobs/Crm/MatchActivityCrmData.php
,
@app/Exceptions/RateLimitException.php
,
@app/Jobs/Middleware/HandleHubspotRateLimit.php
,
@app/Services/Crm/Hubspot/Client.php
and
@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php
. The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?
Thought for 5s
Thought for 5s
I'll review the implementation carefully. Let me first check how
RateLimitException
is used elsewhere and look at the test files and
PaginationState
.
Bash
Find all usages of RateLimitException outside the changed files
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/app --include="*.php" | grep -v "HandleHubspotRateLimit\|MatchActivityCrmData\|Client.php\|RateLimitException.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is
Read
PaginationState.php
PaginationState.php
Read
HandleHubspotRateLimitTest.php
HandleHubspotRateLimitTest.php
Bash
Check how RateLimitException is used in GenerateActionItemsJob
IN
grep -rn "RateLimitException\|LogicException\|RuntimeException" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
Bash
Find all catch blocks or instanceof checks for RateLimitException
IN
grep -rn "catch.*RateLimitException\|instanceof RateLimitException" /Users/lukas/jiminny/app/app --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {
Bash
Find all test references to RateLimitException
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/tests --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);
Bash
Check retry/failure handling in GenerateActionItemsJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30
Copy code to clipboard
OUT
13:use Jiminny\Exceptions\RateLimitException;
51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
56: $this->release(30);
Bash
Check retry/failure handling in GenerateAiCallScoringJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30
Copy code to clipboard
OUT
17:use Jiminny\Exceptions\RateLimitException;
68: RateLimitException |
75: $this->release(30);
Bash
Check retry config in MatchActivityCrmData
IN
grep -n "retryAfter\|RETRY_WINDOW\|retryUntil\|maxTries" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php
Copy code to clipboard
OUT
34: private const int RETRY_WINDOW_MINUTES = 30;
45: public function retryUntil(): \DateTimeInterface
47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);
77: return self::RETRY_WINDOW_MINUTES * 60 + 60;
Bash
Check the cache key format
IN
grep -n "getRateLimitCacheKey\|hubspot:ratelimit" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Copy code to clipboard
OUT
78: $cacheKey = $this->getRateLimitCacheKey();
110: private function getRateLimitCacheKey(): string
112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());
Bash
Find other jobs that might use the rate limit middleware
IN
grep -rn "class.*Middleware\|HandleHubspotRateLimit" /Users/lukas/jiminny/app/app/Jobs --include="*.php" | grep -v "HandleHubspotRateLimit.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];
Read
HubspotClientInterface.php
HubspotClientInterface.php
Thought for 73s
Thought for 73s
Now I have a complete picture. Here's my full analysis:
Review Summary
Review Summary
RuntimeException vs LogicException — Safe ✓
RuntimeException
vs
LogicException
— Safe ✓
The change is semantically correct and won't break anything.
LogicException
is for programmer errors (invalid arguments, contract violations). A rate limit from an external API is a runtime condition —
RuntimeException
is the right parent. All callers catch
RateLimitException
by name explicitly (union catches in
GenerateActionItemsJob
,
GenerateAiCallScoringJob
,
SummarizeTranscriptionJob
,
GenerateAiActivityTypeJob
) or check
instanceof RateLimitException
— none relies on the parent class type, so nothing breaks.
Issue 1 — Circuit breaker stores stale delay (most important bug)
Issue 1 — Circuit breaker stores stale delay (most important bug)
Client.php:94
Client.php:94
Copy code to clipboard
Redis::setex($cacheKey, $retryAfter, (string) $retryAfter);
The TTL is
$retryAfter
seconds, but the
stored value
is also the original
$retryAfter
. If a second job reads this cache 20 seconds later, it throws
RateLimitException(retryAfter=30)
and gets delayed 30 more seconds — even though only 10 remain. In a spike of 100 jobs, each subsequent job inflates its wait.
Fix:
store the absolute expiry timestamp, reconstruct the remaining delay when reading:
Copy code to clipboard
// on 429:
Redis::setex($cacheKey, $retryAfter, (string) (time() + $retryAfter));
// in the cached-read branch:
$remaining = max(1, (int) $cachedRetryAfter - time());
throw new RateLimitException('Hubspot rate limit (cached circuit-breaker)', $remaining);
Issue 2 — retryUntil() window may expire under sustained rate limiting
Issue 2 —
retryUntil()
window may expire under sustained rate limiting
MatchActivityCrmData.php:45-47
MatchActivityCrmData.php:45-47
retryUntil()
is calculated as
now() + 30 min
at
dispatch time
and stored in the job payload. With
MAX_RETRY_DELAY = 600s
, you only get ~3 rate-limit-triggered releases before the 30-minute window closes and the job is permanently failed. For a heavy 429 spike (daily limit, parsed as 600s), this can easily happen.
Consider increasing
RETRY_WINDOW_MINUTES
to 60 or more, or making it configurable. The
uniqueFor()
value should be updated to match.
Issue 3 — Only search() uses the circuit breaker, but other methods can still hit 429
Issue 3 — Only
search()
uses the circuit breaker, but other methods can still hit 429
Client.php:76-108
Client.php:76-108
executeRequest()
is only called from
search()
. Methods like
getOpportunityById()
,
batchReadObjects()
,
getContactByEmail()
bypass it entirely. If HubSpot's 429 is account-wide (not search-endpoint-specific), those methods will keep firing even while the circuit breaker is supposedly open. This is likely fine because the search endpoint has its own tighter quota (5 RPS), but it's worth being explicit in a comment.
Issue 4 — object $job type in middleware has no compile-time safety
Issue 4 —
object $job
type in middleware has no compile-time safety
HandleHubspotRateLimit.php:20
HandleHubspotRateLimit.php:20
Copy code to clipboard
public function handle(object $job, callable $next): void
Calling
$job->attempts()
and
$job->release()
on
object
will work at runtime since Laravel always passes jobs that use
InteractsWithQueue
, but PHPStan/psalm won't verify this and it could silently break in tests with bare mocks (as your test does use a
Job
mock that has
attempts()
— so the test is fine, but the real type contract is invisible). Minor, but worth tightening to
\Illuminate\Contracts\Queue\Job
.
What works well ✓
What works well ✓
RateLimitException
is caught by the middleware
before
it increments
maxExceptions
, so r...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Explorer (⇧⌘E)","depth":19,"bounds":{"left":0.0,"top":0.047885075,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.057462092,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Search (⇧⌘F)","depth":19,"bounds":{"left":0.0,"top":0.08619314,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.09577015,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Source Control (⌃⇧G)","depth":19,"bounds":{"left":0.0,"top":0.1245012,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":true},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.13407822,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Run and Debug (⇧⌘D)","depth":19,"bounds":{"left":0.0,"top":0.16280925,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.17238627,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Remote Explorer","depth":19,"bounds":{"left":0.0,"top":0.20111732,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.21069433,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Extensions (⇧⌘X) - 2 require update","depth":19,"bounds":{"left":0.0,"top":0.23942538,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.2490024,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"2","depth":22,"bounds":{"left":0.009640957,"top":0.2601756,"width":0.0019946808,"height":0.008778931},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Testing","depth":19,"bounds":{"left":0.0,"top":0.27773345,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.28731045,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Claude Code","depth":19,"bounds":{"left":0.0,"top":0.3160415,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"SOURCE CONTROL","depth":17,"bounds":{"left":0.022606382,"top":0.047885075,"width":0.034242023,"height":0.02793296},"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"SOURCE CONTROL","depth":18,"bounds":{"left":0.022606382,"top":0.056664005,"width":0.034242023,"height":0.0103751},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Changes Section","depth":21,"bounds":{"left":0.015957447,"top":0.07581804,"width":0.09940159,"height":0.017557861},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"","depth":23,"bounds":{"left":0.01662234,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"CHANGES","depth":22,"bounds":{"left":0.022606382,"top":0.07581804,"width":0.01861702,"height":0.017557861},"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"CHANGES","depth":23,"bounds":{"left":0.022606382,"top":0.079010375,"width":0.01861702,"height":0.0103751},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"","depth":28,"bounds":{"left":0.024601065,"top":0.09896249,"width":0.07247341,"height":0.015961692},"on_screen":true,"value":"","role_description":"editor","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":29,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Commit Changes on \"JY-20725-handle-HS-search-rate-limit\"","depth":26,"bounds":{"left":0.022273935,"top":0.1245012,"width":0.08045213,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.052526597,"top":0.12849163,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Commit","depth":27,"bounds":{"left":0.058843084,"top":0.1292897,"width":0.014960106,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.059175532,"top":0.1292897,"width":0.0029920214,"height":0.011173184}},{"char_start":1,"char_count":5,"bounds":{"left":0.061835106,"top":0.1292897,"width":0.011968086,"height":0.011173184}}],"role_description":"text"},{"role":"AXPopUpButton","text":"More Actions...","depth":26,"bounds":{"left":0.10305851,"top":0.1245012,"width":0.00831117,"height":0.0207502},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.1043883,"top":0.12849163,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.019614361,"top":0.15163608,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Changes","depth":27,"bounds":{"left":0.025930852,"top":0.15163608,"width":0.01761968,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.025930852,"top":0.15243416,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":6,"bounds":{"left":0.028922873,"top":0.15243416,"width":0.014960106,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"17","depth":27,"bounds":{"left":0.10571808,"top":0.15243416,"width":0.0039893617,"height":0.0103751},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.10605053,"top":0.15243416,"width":0.0016622341,"height":0.0103751}},{"char_start":1,"char_count":1,"bounds":{"left":0.10771277,"top":0.15243416,"width":0.0019946808,"height":0.0103751}}],"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.16759777,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.local","depth":28,"bounds":{"left":0.028590426,"top":0.16919394,"width":0.01861702,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.16999201,"width":0.0013297872,"height":0.011971269}},{"char_start":1,"char_count":9,"bounds":{"left":0.029920213,"top":0.16999201,"width":0.01761968,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.16999201,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.18515563,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.nikilocal","depth":28,"bounds":{"left":0.028590426,"top":0.1867518,"width":0.025598405,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.18754987,"width":0.0013297872,"height":0.011971269}},{"char_start":1,"char_count":13,"bounds":{"left":0.029920213,"top":0.18754987,"width":0.024268618,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.18754987,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.20271349,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.other","depth":28,"bounds":{"left":0.028590426,"top":0.20430966,"width":0.019946808,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.20510775,"width":0.0013297872,"height":0.011971269}},{"char_start":1,"char_count":9,"bounds":{"left":0.029920213,"top":0.20510775,"width":0.01861702,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.20510775,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.22027135,"width":0.0076462766,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ids.txt","depth":28,"bounds":{"left":0.028590426,"top":0.22186752,"width":0.012300532,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.22266561,"width":0.0009973404,"height":0.011971269}},{"char_start":1,"char_count":6,"bounds":{"left":0.029587766,"top":0.22266561,"width":0.011635638,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.22266561,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.23782921,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"raw_sql_query.sql","depth":28,"bounds":{"left":0.028590426,"top":0.23942538,"width":0.035904255,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.24022347,"width":0.0016622341,"height":0.011971269}},{"char_start":1,"char_count":16,"bounds":{"left":0.03025266,"top":0.24022347,"width":0.034242023,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.24022347,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.25538707,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"WEBHOOK_FILTERING_IMPLEMENTATION.md","depth":28,"bounds":{"left":0.028590426,"top":0.25698325,"width":0.076130316,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.25778133,"width":0.0043218085,"height":0.011971269}},{"char_start":1,"char_count":34,"bounds":{"left":0.032912236,"top":0.25778133,"width":0.08909574,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.25778133,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.27294493,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JiminnyDebugCommand.php","depth":28,"bounds":{"left":0.028590426,"top":0.2745411,"width":0.05817819,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.2753392,"width":0.0023271276,"height":0.011971269}},{"char_start":1,"char_count":22,"bounds":{"left":0.030917553,"top":0.2753392,"width":0.055851065,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands","depth":28,"bounds":{"left":0.08843085,"top":0.2745411,"width":0.01462766,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.0887633,"top":0.27613726,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":19,"bounds":{"left":0.09075798,"top":0.27613726,"width":0.043218084,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.2753392,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.2905028,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"SimulateWebhooksCommand.php","depth":28,"bounds":{"left":0.028590426,"top":0.29209897,"width":0.06781915,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.29289705,"width":0.0026595744,"height":0.011971269}},{"char_start":1,"char_count":26,"bounds":{"left":0.03125,"top":0.29289705,"width":0.065159574,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands/Crm/Hubspot","depth":28,"bounds":{"left":0.098071806,"top":0.29209897,"width":0.005984043,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.09840426,"top":0.29369512,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":31,"bounds":{"left":0.100398935,"top":0.29369512,"width":0.06881649,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.29289705,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.30806065,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CreateMockAskJiminnyReportResultCommand.php","depth":28,"bounds":{"left":0.028590426,"top":0.30965683,"width":0.07646277,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.3104549,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":42,"bounds":{"left":0.03158245,"top":0.3104549,"width":0.099734046,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands/Reports","depth":28,"bounds":{"left":0.10538564,"top":0.3104549,"width":0.0003324468,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.13331117,"top":0.31125298,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":27,"bounds":{"left":0.13530585,"top":0.31125298,"width":0.05851064,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.3104549,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.3256185,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException.php","depth":28,"bounds":{"left":0.028590426,"top":0.3272147,"width":0.047539894,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.32801276,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":21,"bounds":{"left":0.03158245,"top":0.32801276,"width":0.04488032,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Exceptions","depth":28,"bounds":{"left":0.078125,"top":0.3272147,"width":0.024933511,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.078125,"top":0.32881084,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":13,"bounds":{"left":0.08045213,"top":0.32881084,"width":0.026263298,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.32801276,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.34317636,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"MatchActivityCrmData.php","depth":28,"bounds":{"left":0.028590426,"top":0.34477255,"width":0.054521278,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.34557062,"width":0.0039893617,"height":0.011971269}},{"char_start":1,"char_count":23,"bounds":{"left":0.032579787,"top":0.34557062,"width":0.050531916,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Jobs/Crm","depth":28,"bounds":{"left":0.084773935,"top":0.34477255,"width":0.020279255,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.084773935,"top":0.3463687,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":11,"bounds":{"left":0.087101065,"top":0.3463687,"width":0.023603724,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.34557062,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.36073422,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Client.php","depth":28,"bounds":{"left":0.028590426,"top":0.3623304,"width":0.020611702,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.36312848,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":9,"bounds":{"left":0.03158245,"top":0.36312848,"width":0.01761968,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Services/Crm/Hubspot","depth":28,"bounds":{"left":0.05119681,"top":0.3623304,"width":0.028590426,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.05119681,"top":0.3639266,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":23,"bounds":{"left":0.053523935,"top":0.3639266,"width":0.047539894,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"9, M","depth":28,"bounds":{"left":0.10206117,"top":0.36312848,"width":0.00831117,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.10206117,"top":0.36312848,"width":0.0026595744,"height":0.011173184}},{"char_start":1,"char_count":3,"bounds":{"left":0.10472074,"top":0.36312848,"width":0.005984043,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.3782921,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"logging.php","depth":28,"bounds":{"left":0.028590426,"top":0.37988827,"width":0.023936171,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.38068634,"width":0.0009973404,"height":0.011971269}},{"char_start":1,"char_count":10,"bounds":{"left":0.029587766,"top":0.38068634,"width":0.022938829,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"config","depth":28,"bounds":{"left":0.05418883,"top":0.38068634,"width":0.011968086,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.054521278,"top":0.38148445,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":5,"bounds":{"left":0.05651596,"top":0.38148445,"width":0.009640957,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.38068634,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.39584997,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"favicon.ico","depth":28,"bounds":{"left":0.028590426,"top":0.39744613,"width":0.021609042,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.3982442,"width":0.0016622341,"height":0.011971269}},{"char_start":1,"char_count":10,"bounds":{"left":0.03025266,"top":0.3982442,"width":0.020279255,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"public","depth":28,"bounds":{"left":0.05219415,"top":0.3982442,"width":0.011303191,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.05219415,"top":0.3990423,"width":0.0026595744,"height":0.011173184}},{"char_start":1,"char_count":5,"bounds":{"left":0.054521278,"top":0.3990423,"width":0.008976064,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.3982442,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.41340783,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php","depth":28,"bounds":{"left":0.028590426,"top":0.415004,"width":0.06781915,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.41580206,"width":0.0033244682,"height":0.011971269}},{"char_start":1,"char_count":29,"bounds":{"left":0.031914894,"top":0.41580206,"width":0.064494684,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Jobs/Middleware","depth":28,"bounds":{"left":0.098071806,"top":0.415004,"width":0.004986702,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.098071806,"top":0.41660017,"width":0.0016622341,"height":0.011173184}},{"char_start":1,"char_count":25,"bounds":{"left":0.09940159,"top":0.41660017,"width":0.04886968,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.41580206,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.4309657,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CanAccessAiReportsTest.php","depth":28,"bounds":{"left":0.028590426,"top":0.43256184,"width":0.059175532,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.43335995,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":25,"bounds":{"left":0.03158245,"top":0.43335995,"width":0.05618351,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Policies","depth":28,"bounds":{"left":0.089428194,"top":0.43256184,"width":0.013962766,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.08976064,"top":0.43415803,"width":0.0013297872,"height":0.011173184}},{"char_start":1,"char_count":18,"bounds":{"left":0.091090426,"top":0.43415803,"width":0.031914894,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.43335995,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.44852355,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClientTest.php","depth":28,"bounds":{"left":0.028590426,"top":0.4501197,"width":0.028922873,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.4509178,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":13,"bounds":{"left":0.03158245,"top":0.4509178,"width":0.026263298,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Services/Crm/Hubspot","depth":28,"bounds":{"left":0.059507977,"top":0.4501197,"width":0.043882977,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.059507977,"top":0.4517159,"width":0.0016622341,"height":0.011173184}},{"char_start":1,"char_count":30,"bounds":{"left":0.060837764,"top":0.4517159,"width":0.059507977,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.4509178,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Graph Section","depth":21,"bounds":{"left":0.015957447,"top":0.60015965,"width":0.09940159,"height":0.017557861},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"","depth":23,"bounds":{"left":0.01662234,"top":0.60255384,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"GRAPH","depth":22,"bounds":{"left":0.022606382,"top":0.60015965,"width":0.013297873,"height":0.017557861},"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"GRAPH","depth":23,"bounds":{"left":0.022606382,"top":0.60415006,"width":0.013297873,"height":0.0103751},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add more tests","depth":28,"bounds":{"left":0.023271276,"top":0.6201117,"width":0.038231384,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.023271276,"top":0.6209098,"width":0.0026595744,"height":0.011971269}},{"char_start":1,"char_count":22,"bounds":{"left":0.025930852,"top":0.6209098,"width":0.05086436,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.061835106,"top":0.6209098,"width":0.0003324468,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.078457445,"top":0.6217079,"width":0.0026595744,"height":0.011173184}},{"char_start":1,"char_count":12,"bounds":{"left":0.080784574,"top":0.6217079,"width":0.023271276,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.06382979,"top":0.6201117,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit","depth":27,"bounds":{"left":0.06948138,"top":0.6209098,"width":0.032579787,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.06948138,"top":0.6209098,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":35,"bounds":{"left":0.07180851,"top":0.6209098,"width":0.071476065,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.10571808,"top":0.6201117,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add tests","depth":28,"bounds":{"left":0.023271276,"top":0.63766956,"width":0.03956117,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.023271276,"top":0.63846767,"width":0.0023271276,"height":0.011971269}},{"char_start":1,"char_count":17,"bounds":{"left":0.025598405,"top":0.63846767,"width":0.03723404,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.064494684,"top":0.63846767,"width":0.024933511,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.06482713,"top":0.6392658,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":12,"bounds":{"left":0.066821806,"top":0.6392658,"width":0.022938829,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching","depth":28,"bounds":{"left":0.023271276,"top":0.6552275,"width":0.08577128,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.023271276,"top":0.6560255,"width":0.0023271276,"height":0.011971269}},{"char_start":1,"char_count":59,"bounds":{"left":0.025598405,"top":0.6560255,"width":0.11968085,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.109707445,"top":0.6560255,"width":0.0003324468,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.14727394,"top":0.65682364,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":12,"bounds":{"left":0.14926861,"top":0.65682364,"width":0.022938829,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric","depth":28,"bounds":{"left":0.026928192,"top":0.67278534,"width":0.049534574,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.0774601,"top":0.6735834,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.07945479,"top":0.67278534,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"origin/master","depth":27,"bounds":{"left":0.08510638,"top":0.6735834,"width":0.024933511,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric","depth":28,"bounds":{"left":0.030585106,"top":0.6903432,"width":0.078457445,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.109707445,"top":0.69114125,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit","depth":28,"bounds":{"left":0.030585106,"top":0.70790106,"width":0.078457445,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"bounds":{"left":0.109707445,"top":0.7086991,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20819: Increase download transcription rate limit","depth":28,"bounds":{"left":0.030585106,"top":0.7254589,"width":0.078457445,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihail.mihaylov","depth":28,"bounds":{"left":0.109707445,"top":0.72625697,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20818 move ask jiminny reports to its own datadog metric","depth":28,"bounds":{"left":0.030585106,"top":0.7430168,"width":0.07945479,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.109707445,"top":0.7438148,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks","depth":28,"bounds":{"left":0.030585106,"top":0.76057464,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"bounds":{"left":0.109707445,"top":0.7613727,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-20817-fix-deleting-old-tracks","depth":28,"bounds":{"left":0.030585106,"top":0.7781325,"width":0.078125,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"bounds":{"left":0.109707445,"top":0.77893054,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12047 from jiminny/JY-20662-remove-word-boost","depth":28,"bounds":{"left":0.030585106,"top":0.79569036,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"bounds":{"left":0.109707445,"top":0.7964884,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20662 return void","depth":28,"bounds":{"left":0.030585106,"top":0.8132482,"width":0.043218084,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.075465426,"top":0.81404626,"width":0.0076462766,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20662 Removed word_boost parameter from Assembly transcription request","depth":28,"bounds":{"left":0.030585106,"top":0.8308061,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.109707445,"top":0.8316041,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20817: Fix deleting old tracks","depth":28,"bounds":{"left":0.030585106,"top":0.84836394,"width":0.06582447,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihail.mihaylov","depth":28,"bounds":{"left":0.098071806,"top":0.84836394,"width":0.010305851,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es","depth":28,"bounds":{"left":0.030585106,"top":0.8659218,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"bounds":{"left":0.109707445,"top":0.8667199,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-19938-bot-status-in-csv-es","depth":28,"bounds":{"left":0.030585106,"top":0.88347965,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"bounds":{"left":0.109707445,"top":0.88427776,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update","depth":28,"bounds":{"left":0.030585106,"top":0.9010375,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"bounds":{"left":0.109707445,"top":0.9018356,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 Added consent information and recording error reason to Elastic Search mapping","depth":28,"bounds":{"left":0.030585106,"top":0.9185954,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.109707445,"top":0.9193935,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 reorder array map","depth":28,"bounds":{"left":0.030585106,"top":0.93615323,"width":0.056848403,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.089428194,"top":0.93695134,"width":0.00731383,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938: Address PR review comments","depth":28,"bounds":{"left":0.030585106,"top":0.9537111,"width":0.07912234,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.109707445,"top":0.9545092,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 lint fixes","depth":28,"bounds":{"left":0.030585106,"top":0.97126895,"width":0.03756649,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.06981383,"top":0.97206706,"width":0.0076462766,"height":0.0103751},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1","depth":28,"on_screen":false,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1","depth":28,"bounds":{"left":0.11569149,"top":0.047885075,"width":0.022606382,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Client.php (Working Tree) (Client.php), Editor Group 1","depth":28,"bounds":{"left":0.13796543,"top":0.047885075,"width":0.08211436,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1","depth":28,"bounds":{"left":0.21974733,"top":0.047885075,"width":0.111369684,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"MatchActivityCrmData.php, Editor Group 1","depth":28,"bounds":{"left":0.3307846,"top":0.047885075,"width":0.080119684,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1","depth":28,"bounds":{"left":0.4105718,"top":0.047885075,"width":0.1043883,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Client.php, preview, Editor Group 1","depth":28,"bounds":{"left":0.5149601,"top":0.047885075,"width":0.05119681,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.12832446,"top":0.07821229,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.15093085,"top":0.07821229,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.16489361,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.1875,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.22074468,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.22606383,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.24401596,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.24933511,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"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 {","depth":28,"bounds":{"left":0.13996011,"top":0.53790903,"width":0.43783244,"height":0.014365523},"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 {","role_description":"editor","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","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 {","depth":29,"bounds":{"left":0.13996011,"top":0.53790903,"width":0.2762633,"height":0.014365523},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Review rate limit handli…, Editor Group 2","depth":28,"bounds":{"left":0.61768615,"top":0.047885075,"width":0.07014628,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXButton","text":"remote","depth":16,"bounds":{"left":0.0006648936,"top":0.98244214,"width":0.010638298,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...","depth":16,"bounds":{"left":0.012965426,"top":0.98244214,"width":0.084773935,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":0.013962766,"top":0.9848364,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit*","depth":17,"bounds":{"left":0.019281914,"top":0.9856345,"width":0.0774601,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"app (Git) - Synchronize Changes","depth":16,"bounds":{"left":0.09740692,"top":0.98244214,"width":0.0076462766,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Errors: 6, Warnings: 11, Infos: 28","depth":16,"bounds":{"left":0.107380316,"top":0.98244214,"width":0.03756649,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":0.109042555,"top":0.9848364,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"6","depth":17,"bounds":{"left":0.1143617,"top":0.9856345,"width":0.004986702,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":0.11934841,"top":0.9848364,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":17,"bounds":{"left":0.124667555,"top":0.9856345,"width":0.00731383,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":0.13164894,"top":0.9848364,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"28","depth":17,"bounds":{"left":0.13696809,"top":0.9856345,"width":0.0063164895,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Notifications","depth":16,"bounds":{"left":0.9886968,"top":0.98244214,"width":0.010638298,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"key, PHP extension: Premium features not active.","depth":16,"bounds":{"left":0.9790558,"top":0.98244214,"width":0.008643617,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"8.3, Language Level: 8.3","depth":16,"bounds":{"left":0.9674202,"top":0.98244214,"width":0.009640957,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sign In","depth":16,"bounds":{"left":0.9428192,"top":0.98244214,"width":0.022938829,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":0.9444814,"top":0.9848364,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Sign In","depth":17,"bounds":{"left":0.94980055,"top":0.9856345,"width":0.014295213,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"PHP","depth":16,"bounds":{"left":0.93018615,"top":0.98244214,"width":0.010305851,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Editor Language Status: $(copilot) No inline suggestion available, Inline suggestions","depth":16,"bounds":{"left":0.92287236,"top":0.98244214,"width":0.0076462766,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"LF","depth":16,"bounds":{"left":0.9125665,"top":0.98244214,"width":0.007978723,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UTF-8","depth":16,"bounds":{"left":0.8949468,"top":0.98244214,"width":0.015625,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Spaces: 4","depth":16,"bounds":{"left":0.87101066,"top":0.98244214,"width":0.022273935,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Ln 320, Col 30","depth":16,"bounds":{"left":0.83776593,"top":0.98244214,"width":0.03158245,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"git-commit Lukas Kovalik (5 months ago)","depth":16,"bounds":{"left":0.7706117,"top":0.98244214,"width":0.06549202,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":0.77227396,"top":0.9848364,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik (5 months ago)","depth":17,"bounds":{"left":0.7775931,"top":0.9856345,"width":0.056848403,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Found 1 symbol in /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php","depth":12,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Clear","depth":12,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Review rate limit handling for HubSpot job queue","depth":19,"bounds":{"left":0.6200133,"top":0.08060654,"width":0.099734046,"height":0.022346368},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Session history","depth":19,"bounds":{"left":0.9780585,"top":0.08060654,"width":0.00930851,"height":0.022346368},"on_screen":true,"help_text":"Session history","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New session","depth":19,"bounds":{"left":0.9886968,"top":0.08060654,"width":0.00930851,"height":0.022346368},"on_screen":true,"help_text":"New session","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Message actions","depth":24,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"RateLimitException.php#9-9","depth":23,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"RateLimitException.php#9-9","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Reveiw the diff. Mostly","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Jobs/Crm/MatchActivityCrmData.php","depth":26,"on_screen":false,"help_text":"Open app/Jobs/Crm/MatchActivityCrmData.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Exceptions/RateLimitException.php","depth":26,"on_screen":false,"help_text":"Open app/Exceptions/RateLimitException.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Jobs/Middleware/HandleHubspotRateLimit.php","depth":26,"on_screen":false,"help_text":"Open app/Jobs/Middleware/HandleHubspotRateLimit.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Services/Crm/Hubspot/Client.php","depth":26,"on_screen":false,"help_text":"Open app/Services/Crm/Hubspot/Client.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"and","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php","depth":26,"on_screen":false,"help_text":"Open app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":". The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Thought for 5s","depth":23,"on_screen":false,"role_description":"disclosure triangle","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thought for 5s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"I'll review the implementation carefully. Let me first check how","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is used elsewhere and look at the test files and","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PaginationState","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all usages of RateLimitException outside the changed files","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\" /Users/lukas/jiminny/app/app --include=\"*.php\" | grep -v \"HandleHubspotRateLimit\\|MatchActivityCrmData\\|Client.php\\|RateLimitException.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |\n/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"PaginationState.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"PaginationState.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"HandleHubspotRateLimitTest.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check how RateLimitException is used in GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\\|LogicException\\|RuntimeException\" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all catch blocks or instanceof checks for RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"catch.*RateLimitException\\|instanceof RateLimitException\" /Users/lukas/jiminny/app/app --include=\"*.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls\n/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {\n/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all test references to RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\" /Users/lukas/jiminny/app/tests --include=\"*.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry/failure handling in GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"RateLimitException\\|retryUntil\\|backoff\\|maxExceptions\\|maxTries\\|release\\|failed\" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"13:use Jiminny\\Exceptions\\RateLimitException;\n51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n56: $this->release(30);","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry/failure handling in GenerateAiCallScoringJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"RateLimitException\\|retryUntil\\|backoff\\|maxExceptions\\|maxTries\\|release\\|failed\" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"17:use Jiminny\\Exceptions\\RateLimitException;\n68: RateLimitException |\n75: $this->release(30);","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry config in MatchActivityCrmData","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"retryAfter\\|RETRY_WINDOW\\|retryUntil\\|maxTries\" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"34: private const int RETRY_WINDOW_MINUTES = 30;\n45: public function retryUntil(): \\DateTimeInterface\n47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n77: return self::RETRY_WINDOW_MINUTES * 60 + 60;","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check the cache key format","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"getRateLimitCacheKey\\|hubspot:ratelimit\" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"78: $cacheKey = $this->getRateLimitCacheKey();\n110: private function getRateLimitCacheKey(): string\n112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find other jobs that might use the rate limit middleware","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"class.*Middleware\\|HandleHubspotRateLimit\" /Users/lukas/jiminny/app/app/Jobs --include=\"*.php\" | grep -v \"HandleHubspotRateLimit.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\\Jobs\\Middleware\\HandleHubspotRateLimit;\n/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"HubspotClientInterface.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"HubspotClientInterface.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Thought for 73s","depth":23,"on_screen":false,"role_description":"disclosure triangle","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thought for 73s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Now I have a complete picture. Here's my full analysis:","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Review Summary","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Review Summary","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"RuntimeException vs LogicException — Safe ✓","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"RuntimeException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"vs","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LogicException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"— Safe ✓","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The change is semantically correct and won't break anything.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LogicException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is for programmer errors (invalid arguments, contract violations). A rate limit from an external API is a runtime condition —","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RuntimeException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is the right parent. All callers catch","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"by name explicitly (union catches in","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateAiCallScoringJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SummarizeTranscriptionJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateAiActivityTypeJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":") or check","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"instanceof RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"— none relies on the parent class type, so nothing breaks.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 1 — Circuit breaker stores stale delay (most important bug)","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 1 — Circuit breaker stores stale delay (most important bug)","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"Client.php:94","depth":23,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Client.php:94","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Redis::setex($cacheKey, $retryAfter, (string) $retryAfter);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The TTL is","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"$retryAfter","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"seconds, but the","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"stored value","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is also the original","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"$retryAfter","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":". If a second job reads this cache 20 seconds later, it throws","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException(retryAfter=30)","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"and gets delayed 30 more seconds — even though only 10 remain. In a spike of 100 jobs, each subsequent job inflates its wait.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Fix:","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"store the absolute expiry timestamp, reconstruct the remaining delay when reading:","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"// on 429:\nRedis::setex($cacheKey, $retryAfter, (string) (time() + $retryAfter));\n\n// in the cached-read branch:\n$remaining = max(1, (int) $cachedRetryAfter - time());\nthrow new RateLimitException('Hubspot rate limit (cached circuit-breaker)', $remaining);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 2 — retryUntil() window may expire under sustained rate limiting","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 2 —","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"retryUntil()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"window may expire under sustained rate limiting","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"MatchActivityCrmData.php:45-47","depth":23,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"MatchActivityCrmData.php:45-47","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"retryUntil()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is calculated as","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"now() + 30 min","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"at","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"dispatch time","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"and stored in the job payload. With","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MAX_RETRY_DELAY = 600s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":", you only get ~3 rate-limit-triggered releases before the 30-minute window closes and the job is permanently failed. For a heavy 429 spike (daily limit, parsed as 600s), this can easily happen.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Consider increasing","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RETRY_WINDOW_MINUTES","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"to 60 or more, or making it configurable. The","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"uniqueFor()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"value should be updated to match.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 3 — Only search() uses the circuit breaker, but other methods can still hit 429","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 3 — Only","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"search()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"uses the circuit breaker, but other methods can still hit 429","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"Client.php:76-108","depth":23,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Client.php:76-108","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"executeRequest()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is only called from","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"search()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":". Methods like","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getOpportunityById()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"batchReadObjects()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getContactByEmail()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"bypass it entirely. If HubSpot's 429 is account-wide (not search-endpoint-specific), those methods will keep firing even while the circuit breaker is supposedly open. This is likely fine because the search endpoint has its own tighter quota (5 RPS), but it's worth being explicit in a comment.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 4 — object $job type in middleware has no compile-time safety","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 4 —","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"object $job","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"type in middleware has no compile-time safety","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"HandleHubspotRateLimit.php:20","depth":23,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"HandleHubspotRateLimit.php:20","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"public function handle(object $job, callable $next): void","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Calling","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"$job->attempts()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"and","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"$job->release()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"on","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"object","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"will work at runtime since Laravel always passes jobs that use","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"InteractsWithQueue","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":", but PHPStan/psalm won't verify this and it could silently break in tests with bare mocks (as your test does use a","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Job","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"mock that has","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"attempts()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"— so the test is fine, but the real type contract is invisible). Minor, but worth tightening to","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"\\Illuminate\\Contracts\\Queue\\Job","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"What works well ✓","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"What works well ✓","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is caught by the middleware","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"before","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"it increments","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"maxExceptions","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":", so rate-limit releases don't consume the 3-exception budget for real failures.","depth":24,"on_screen":false,"role_description":"text"}]...
|
-11690007381413687
|
6764940852578748518
|
idle
|
accessibility
|
NULL
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G)
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Testing
Claude Code
SOURCE CONTROL
SOURCE CONTROL
Changes Section
CHANGES
CHANGES
Commit Changes on "JY-20725-handle-HS-search-rate-limit"
Commit
More Actions...
Changes
17
.env.local
M
.env.nikilocal
U
.env.other
U
ids.txt
U
raw_sql_query.sql
U
WEBHOOK_FILTERING_IMPLEMENTATION.md
U
JiminnyDebugCommand.php
app/Console/Commands
M
SimulateWebhooksCommand.php
app/Console/Commands/Crm/Hubspot
U
CreateMockAskJiminnyReportResultCommand.php
app/Console/Commands/Reports
U
RateLimitException.php
app/Exceptions
M
MatchActivityCrmData.php
app/Jobs/Crm
M
Client.php
app/Services/Crm/Hubspot
9, M
logging.php
config
M
favicon.ico
public
U
HandleHubspotRateLimitTest.php
tests/Unit/Jobs/Middleware
M
CanAccessAiReportsTest.php
tests/Unit/Policies
U
ClientTest.php
tests/Unit/Services/Crm/Hubspot
M
Graph Section
GRAPH
GRAPH
JY-20725 add more tests
Lukas Kovalik
JY-20725-handle-HS-search-rate-limit
JY-20725 add tests
Lukas Kovalik
JY-20725 add HS rate limit handling on activities rematching
Lukas Kovalik
Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
origin/master
Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit
mihailmihaylovjiminny
JY-20819: Increase download transcription rate limit
mihail.mihaylov
JY-20818 move ask jiminny reports to its own datadog metric
Lukas Kovalik
Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge branch 'master' into JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge pull request #12047 from jiminny/JY-20662-remove-word-boost
ilian-jiminny
JY-20662 return void
ilian
JY-20662 Removed word_boost parameter from Assembly transcription request
ilian
JY-20817: Fix deleting old tracks
mihail.mihaylov
Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge branch 'master' into JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update
ilian-jiminny
JY-19938 Added consent information and recording error reason to Elastic Search mapping
ilian
JY-19938 reorder array map
ilian
JY-19938: Address PR review comments
ilian
JY-19938 lint fixes
ilian
HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1
ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1
Client.php (Working Tree) (Client.php), Editor Group 1
MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1
MatchActivityCrmData.php, Editor Group 1
RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1
Client.php, preview, Editor Group 1
<?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 {
<?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 {
Review rate limit handli…, Editor Group 2
remote
app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...
JY-20725-handle-HS-search-rate-limit*
app (Git) - Synchronize Changes
Errors: 6, Warnings: 11, Infos: 28
6
11
28
Notifications
key, PHP extension: Premium features not active.
8.3, Language Level: 8.3
Sign In
Sign In
PHP
Editor Language Status: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 4
Ln 320, Col 30
git-commit Lukas Kovalik (5 months ago)
Lukas Kovalik (5 months ago)
Found 1 symbol in /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Clear
Review rate limit handling for HubSpot job queue
Session history
New session
Message actions
RateLimitException.php#9-9
RateLimitException.php#9-9
Reveiw the diff. Mostly
@app/Jobs/Crm/MatchActivityCrmData.php
,
@app/Exceptions/RateLimitException.php
,
@app/Jobs/Middleware/HandleHubspotRateLimit.php
,
@app/Services/Crm/Hubspot/Client.php
and
@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php
. The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?
Thought for 5s
Thought for 5s
I'll review the implementation carefully. Let me first check how
RateLimitException
is used elsewhere and look at the test files and
PaginationState
.
Bash
Find all usages of RateLimitException outside the changed files
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/app --include="*.php" | grep -v "HandleHubspotRateLimit\|MatchActivityCrmData\|Client.php\|RateLimitException.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is
Read
PaginationState.php
PaginationState.php
Read
HandleHubspotRateLimitTest.php
HandleHubspotRateLimitTest.php
Bash
Check how RateLimitException is used in GenerateActionItemsJob
IN
grep -rn "RateLimitException\|LogicException\|RuntimeException" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
Bash
Find all catch blocks or instanceof checks for RateLimitException
IN
grep -rn "catch.*RateLimitException\|instanceof RateLimitException" /Users/lukas/jiminny/app/app --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {
Bash
Find all test references to RateLimitException
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/tests --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);
Bash
Check retry/failure handling in GenerateActionItemsJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30
Copy code to clipboard
OUT
13:use Jiminny\Exceptions\RateLimitException;
51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
56: $this->release(30);
Bash
Check retry/failure handling in GenerateAiCallScoringJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30
Copy code to clipboard
OUT
17:use Jiminny\Exceptions\RateLimitException;
68: RateLimitException |
75: $this->release(30);
Bash
Check retry config in MatchActivityCrmData
IN
grep -n "retryAfter\|RETRY_WINDOW\|retryUntil\|maxTries" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php
Copy code to clipboard
OUT
34: private const int RETRY_WINDOW_MINUTES = 30;
45: public function retryUntil(): \DateTimeInterface
47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);
77: return self::RETRY_WINDOW_MINUTES * 60 + 60;
Bash
Check the cache key format
IN
grep -n "getRateLimitCacheKey\|hubspot:ratelimit" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Copy code to clipboard
OUT
78: $cacheKey = $this->getRateLimitCacheKey();
110: private function getRateLimitCacheKey(): string
112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());
Bash
Find other jobs that might use the rate limit middleware
IN
grep -rn "class.*Middleware\|HandleHubspotRateLimit" /Users/lukas/jiminny/app/app/Jobs --include="*.php" | grep -v "HandleHubspotRateLimit.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];
Read
HubspotClientInterface.php
HubspotClientInterface.php
Thought for 73s
Thought for 73s
Now I have a complete picture. Here's my full analysis:
Review Summary
Review Summary
RuntimeException vs LogicException — Safe ✓
RuntimeException
vs
LogicException
— Safe ✓
The change is semantically correct and won't break anything.
LogicException
is for programmer errors (invalid arguments, contract violations). A rate limit from an external API is a runtime condition —
RuntimeException
is the right parent. All callers catch
RateLimitException
by name explicitly (union catches in
GenerateActionItemsJob
,
GenerateAiCallScoringJob
,
SummarizeTranscriptionJob
,
GenerateAiActivityTypeJob
) or check
instanceof RateLimitException
— none relies on the parent class type, so nothing breaks.
Issue 1 — Circuit breaker stores stale delay (most important bug)
Issue 1 — Circuit breaker stores stale delay (most important bug)
Client.php:94
Client.php:94
Copy code to clipboard
Redis::setex($cacheKey, $retryAfter, (string) $retryAfter);
The TTL is
$retryAfter
seconds, but the
stored value
is also the original
$retryAfter
. If a second job reads this cache 20 seconds later, it throws
RateLimitException(retryAfter=30)
and gets delayed 30 more seconds — even though only 10 remain. In a spike of 100 jobs, each subsequent job inflates its wait.
Fix:
store the absolute expiry timestamp, reconstruct the remaining delay when reading:
Copy code to clipboard
// on 429:
Redis::setex($cacheKey, $retryAfter, (string) (time() + $retryAfter));
// in the cached-read branch:
$remaining = max(1, (int) $cachedRetryAfter - time());
throw new RateLimitException('Hubspot rate limit (cached circuit-breaker)', $remaining);
Issue 2 — retryUntil() window may expire under sustained rate limiting
Issue 2 —
retryUntil()
window may expire under sustained rate limiting
MatchActivityCrmData.php:45-47
MatchActivityCrmData.php:45-47
retryUntil()
is calculated as
now() + 30 min
at
dispatch time
and stored in the job payload. With
MAX_RETRY_DELAY = 600s
, you only get ~3 rate-limit-triggered releases before the 30-minute window closes and the job is permanently failed. For a heavy 429 spike (daily limit, parsed as 600s), this can easily happen.
Consider increasing
RETRY_WINDOW_MINUTES
to 60 or more, or making it configurable. The
uniqueFor()
value should be updated to match.
Issue 3 — Only search() uses the circuit breaker, but other methods can still hit 429
Issue 3 — Only
search()
uses the circuit breaker, but other methods can still hit 429
Client.php:76-108
Client.php:76-108
executeRequest()
is only called from
search()
. Methods like
getOpportunityById()
,
batchReadObjects()
,
getContactByEmail()
bypass it entirely. If HubSpot's 429 is account-wide (not search-endpoint-specific), those methods will keep firing even while the circuit breaker is supposedly open. This is likely fine because the search endpoint has its own tighter quota (5 RPS), but it's worth being explicit in a comment.
Issue 4 — object $job type in middleware has no compile-time safety
Issue 4 —
object $job
type in middleware has no compile-time safety
HandleHubspotRateLimit.php:20
HandleHubspotRateLimit.php:20
Copy code to clipboard
public function handle(object $job, callable $next): void
Calling
$job->attempts()
and
$job->release()
on
object
will work at runtime since Laravel always passes jobs that use
InteractsWithQueue
, but PHPStan/psalm won't verify this and it could silently break in tests with bare mocks (as your test does use a
Job
mock that has
attempts()
— so the test is fine, but the real type contract is invisible). Minor, but worth tightening to
\Illuminate\Contracts\Queue\Job
.
What works well ✓
What works well ✓
RateLimitException
is caught by the middleware
before
it increments
maxExceptions
, so r...
|
NULL
|
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/ /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php...
|
NULL
|
NULL
|
|
20437
|
884
|
0
|
2026-05-11T15:29:37.974699+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778513377974_m1.jpg...
|
Code
|
Client.php — app — 9 problems in this file • Modif Client.php — app — 9 problems in this file • Modified...
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G) - 17 pending changes
17
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Testing
Claude Code
SOURCE CONTROL
SOURCE CONTROL
Changes Section
CHANGES
CHANGES
Commit Changes on "JY-20725-handle-HS-search-rate-limit"
Commit
More Actions...
Changes
17
.env.local
M
.env.nikilocal
U
.env.other
U
ids.txt
U
raw_sql_query.sql
U
WEBHOOK_FILTERING_IMPLEMENTATION.md
U
JiminnyDebugCommand.php
app/Console/Commands
M
SimulateWebhooksCommand.php
app/Console/Commands/Crm/Hubspot
U
CreateMockAskJiminnyReportResultCommand.php
app/Console/Commands/Reports
U
RateLimitException.php
app/Exceptions
M
MatchActivityCrmData.php
app/Jobs/Crm
M
Client.php
app/Services/Crm/Hubspot
9, M
logging.php
config
M
favicon.ico
public
U
HandleHubspotRateLimitTest.php
tests/Unit/Jobs/Middleware
M
CanAccessAiReportsTest.php
tests/Unit/Policies
U
ClientTest.php
tests/Unit/Services/Crm/Hubspot
M
Graph Section
GRAPH
GRAPH
JY-20725 add more tests
Lukas Kovalik
JY-20725-handle-HS-search-rate-limit
JY-20725 add tests
Lukas Kovalik
JY-20725 add HS rate limit handling on activities rematching
Lukas Kovalik
Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
origin/master
Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit
mihailmihaylovjiminny
JY-20819: Increase download transcription rate limit
mihail.mihaylov
JY-20818 move ask jiminny reports to its own datadog metric
Lukas Kovalik
Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge branch 'master' into JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge pull request #12047 from jiminny/JY-20662-remove-word-boost
ilian-jiminny
JY-20662 return void
ilian
JY-20662 Removed word_boost parameter from Assembly transcription request
ilian
JY-20817: Fix deleting old tracks
mihail.mihaylov
Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge branch 'master' into JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update
ilian-jiminny
JY-19938 Added consent information and recording error reason to Elastic Search mapping
ilian
JY-19938 reorder array map
ilian
JY-19938: Address PR review comments
ilian
JY-19938 lint fixes
ilian
HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1
ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1
Client.php (Working Tree) (Client.php), Editor Group 1
MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1
MatchActivityCrmData.php, Editor Group 1
RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1
Client.php, preview, Editor Group 1
<?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 {
<?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 {
Review rate limit handli…, Editor Group 2
remote
app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...
JY-20725-handle-HS-search-rate-limit*
app (Git) - Synchronize Changes
Errors: 6, Warnings: 11, Infos: 28
6
11
28
Notifications
key, PHP extension: Premium features not active.
8.3, Language Level: 8.3
Sign In
Sign In
PHP
Editor Language Status: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 4
Ln 320, Col 30
git-commit Lukas Kovalik (5 months ago)
Lukas Kovalik (5 months ago)
Found 1 symbol in /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Clear
Review rate limit handling for HubSpot job queue
Session history
New session
Message actions
RateLimitException.php#9-9
RateLimitException.php#9-9
Reveiw the diff. Mostly
@app/Jobs/Crm/MatchActivityCrmData.php
,
@app/Exceptions/RateLimitException.php
,
@app/Jobs/Middleware/HandleHubspotRateLimit.php
,
@app/Services/Crm/Hubspot/Client.php
and
@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php
. The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?
Thought for 5s
Thought for 5s
I'll review the implementation carefully. Let me first check how
RateLimitException
is used elsewhere and look at the test files and
PaginationState
.
Bash
Find all usages of RateLimitException outside the changed files
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/app --include="*.php" | grep -v "HandleHubspotRateLimit\|MatchActivityCrmData\|Client.php\|RateLimitException.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is
Read
PaginationState.php
PaginationState.php
Read
HandleHubspotRateLimitTest.php
HandleHubspotRateLimitTest.php
Bash
Check how RateLimitException is used in GenerateActionItemsJob
IN
grep -rn "RateLimitException\|LogicException\|RuntimeException" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
Bash
Find all catch blocks or instanceof checks for RateLimitException
IN
grep -rn "catch.*RateLimitException\|instanceof RateLimitException" /Users/lukas/jiminny/app/app --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {
Bash
Find all test references to RateLimitException
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/tests --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);
Bash
Check retry/failure handling in GenerateActionItemsJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30
Copy code to clipboard
OUT
13:use Jiminny\Exceptions\RateLimitException;
51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
56: $this->release(30);
Bash
Check retry/failure handling in GenerateAiCallScoringJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30
Copy code to clipboard
OUT
17:use Jiminny\Exceptions\RateLimitException;
68: RateLimitException |
75: $this->release(30);
Bash
Check retry config in MatchActivityCrmData
IN
grep -n "retryAfter\|RETRY_WINDOW\|retryUntil\|maxTries" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php
Copy code to clipboard
OUT
34: private const int RETRY_WINDOW_MINUTES = 30;
45: public function retryUntil(): \DateTimeInterface
47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);
77: return self::RETRY_WINDOW_MINUTES * 60 + 60;
Bash
Check the cache key format
IN
grep -n "getRateLimitCacheKey\|hubspot:ratelimit" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Copy code to clipboard
OUT
78: $cacheKey = $this->getRateLimitCacheKey();
110: private function getRateLimitCacheKey(): string
112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());
Bash
Find other jobs that might use the rate limit middleware
IN
grep -rn "class.*Middleware\|HandleHubspotRateLimit" /Users/lukas/jiminny/app/app/Jobs --include="*.php" | grep -v "HandleHubspotRateLimit.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];
Read
HubspotClientInterface.php
HubspotClientInterface.php
Thought for 73s
Thought for 73s
Now I have a complete picture. Here's my full analysis:
Review Summary
Review Summary
RuntimeException vs LogicException — Safe ✓
RuntimeException
vs
LogicException
— Safe ✓
The change is semantically correct and won't break anything.
LogicException
is for programmer errors (invalid arguments, contract violations). A rate limit from an external API is a runtime condition —
RuntimeException
is the right parent. All callers catch
RateLimitException
by name explicitly (union catches in
GenerateActionItemsJob
,
GenerateAiCallScoringJob
,
SummarizeTranscriptionJob
,
GenerateAiActivityTypeJob
) or check
instanceof RateLimitException
— none relies on the parent class type, so nothing breaks.
Issue 1 — Circuit breaker stores stale delay (most important bug)
Issue 1 — Circuit breaker stores stale delay (most important bug)
Client.php:94
Client.php:94
Copy code to clipboard
Redis::setex($cacheKey, $retryAfter, (string) $retryAfter);
The TTL is
$retryAfter
seconds, but the
stored value
is also the original
$retryAfter
. If a second job reads this cache 20 seconds later, it throws
RateLimitException(retryAfter=30)
and gets delayed 30 more seconds — even though only 10 remain. In a spike of 100 jobs, each subsequent job inflates its wait.
Fix:
store the absolute expiry timestamp, reconstruct the remaining delay when reading:
Copy code to clipboard
// on 429:
Redis::setex($cacheKey, $retryAfter, (string) (time() + $retryAfter));
// in the cached-read branch:
$remaining = max(1, (int) $cachedRetryAfter - time());
throw new RateLimitException('Hubspot rate limit (cached circuit-breaker)', $remaining);
Issue 2 — retryUntil() window may expire under sustained rate limiting
Issue 2 —
retryUntil()
window may expire under sustained rate limiting
MatchActivityCrmData.php:45-47
MatchActivityCrmData.php:45-47
retryUntil()
is calculated as
now() + 30 min
at
dispatch time
and stored in the job payload. With
MAX_RETRY_DELAY = 600s
, you only get ~3 rate-limit-triggered releases before the 30-minute window closes and the job is permanently failed. For a heavy 429 spike (daily limit, parsed as 600s), this can easily happen.
Consider increasing
RETRY_WINDOW_MINUTES
to 60 or more, or making it configurable. The
uniqueFor()
value should be updated to match.
Issue 3 — Only search() uses the circuit breaker, but other methods can still hit 429
Issue 3 — Only
search()
uses the circuit breaker, but other methods can still hit 429
Client.php:76-108
Client.php:76-108
executeRequest()
is only called from
search()
. Methods like
getOpportunityById()
,
batchReadObjects()
,
getContactByEmail()
bypass it entirely. If HubSpot's 429 is account-wide (not search-endpoint-specific), those methods will keep firing even while the circuit breaker is supposedly open. This is likely fine because the search endpoint has its own tighter quota (5 RPS), but it's worth being explicit in a comment.
Issue 4 — object $job type in middleware has no compile-time safety
Issue 4 —
object $job
type in middleware has no compile-time safety
HandleHubspotRateLimit.php:20
HandleHubspotRateLimit.php:20
Copy code to clipboard
public function handle(object $job, callable $next): void
Calling
$job->attempts()
and
$job->release()
on
object
will work at runtime since Laravel always passes jobs that use
InteractsWithQueue
, but PHPStan/psalm won't verify this and it could silently break in tests with bare mocks (as your test does use a
Job
mock that has
attempts()
— so the test is fine, but the real type contract is invisible). Minor, but worth tightening to
\Illuminate\Contracts\Queue\Job
.
What works well ✓
What works well ✓
RateLimitException
is caught by the middleware
before
it increme...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Explorer (⇧⌘E)","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Search (⇧⌘F)","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Source Control (⌃⇧G) - 17 pending changes","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":true},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"17","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Run and Debug (⇧⌘D)","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Remote Explorer","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Extensions (⇧⌘X) - 2 require update","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"2","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Testing","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Claude Code","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"SOURCE CONTROL","depth":17,"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"SOURCE CONTROL","depth":18,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Changes Section","depth":21,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"CHANGES","depth":22,"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"CHANGES","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"","depth":28,"on_screen":true,"value":"","role_description":"editor","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":29,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Commit Changes on \"JY-20725-handle-HS-search-rate-limit\"","depth":26,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Commit","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"More Actions...","depth":26,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Changes","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"17","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.local","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.nikilocal","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.other","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ids.txt","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"raw_sql_query.sql","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"WEBHOOK_FILTERING_IMPLEMENTATION.md","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JiminnyDebugCommand.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"SimulateWebhooksCommand.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands/Crm/Hubspot","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CreateMockAskJiminnyReportResultCommand.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands/Reports","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Exceptions","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"MatchActivityCrmData.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Jobs/Crm","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Client.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app/Services/Crm/Hubspot","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"9, M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"logging.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"config","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"favicon.ico","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"public","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Jobs/Middleware","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CanAccessAiReportsTest.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Policies","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClientTest.php","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Services/Crm/Hubspot","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Graph Section","depth":21,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"GRAPH","depth":22,"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"GRAPH","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add more tests","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add tests","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"origin/master","depth":27,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20819: Increase download transcription rate limit","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihail.mihaylov","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20818 move ask jiminny reports to its own datadog metric","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-20817-fix-deleting-old-tracks","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12047 from jiminny/JY-20662-remove-word-boost","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20662 return void","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20662 Removed word_boost parameter from Assembly transcription request","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20817: Fix deleting old tracks","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihail.mihaylov","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-19938-bot-status-in-csv-es","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 Added consent information and recording error reason to Elastic Search mapping","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 reorder array map","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938: Address PR review comments","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 lint fixes","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1","depth":28,"on_screen":false,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Client.php (Working Tree) (Client.php), Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"MatchActivityCrmData.php, Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Client.php, preview, Editor Group 1","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"on_screen":true,"role_description":"text"},{"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 {","depth":28,"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 {","role_description":"editor","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","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 {","depth":29,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Review rate limit handli…, Editor Group 2","depth":28,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXButton","text":"remote","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit*","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"app (Git) - Synchronize Changes","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Errors: 6, Warnings: 11, Infos: 28","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"6","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"28","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Notifications","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"key, PHP extension: Premium features not active.","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"8.3, Language Level: 8.3","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sign In","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Sign In","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"PHP","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Editor Language Status: $(copilot) No inline suggestion available, Inline suggestions","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"LF","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UTF-8","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Spaces: 4","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Ln 320, Col 30","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"git-commit Lukas Kovalik (5 months ago)","depth":16,"bounds":{"left":1.0,"top":0.0,"width":-0.045138836,"height":0.024444444},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":1.0,"top":0.0,"width":-0.048611164,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik (5 months ago)","depth":17,"bounds":{"left":1.0,"top":0.0,"width":-0.059722185,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Found 1 symbol in /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php","depth":12,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Clear","depth":12,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Review rate limit handling for HubSpot job queue","depth":19,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Session history","depth":19,"on_screen":true,"help_text":"Session history","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New session","depth":19,"on_screen":true,"help_text":"New session","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Message actions","depth":24,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"RateLimitException.php#9-9","depth":23,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"RateLimitException.php#9-9","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Reveiw the diff. Mostly","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Jobs/Crm/MatchActivityCrmData.php","depth":26,"on_screen":false,"help_text":"Open app/Jobs/Crm/MatchActivityCrmData.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Exceptions/RateLimitException.php","depth":26,"on_screen":false,"help_text":"Open app/Exceptions/RateLimitException.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Jobs/Middleware/HandleHubspotRateLimit.php","depth":26,"on_screen":false,"help_text":"Open app/Jobs/Middleware/HandleHubspotRateLimit.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Services/Crm/Hubspot/Client.php","depth":26,"on_screen":false,"help_text":"Open app/Services/Crm/Hubspot/Client.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"and","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php","depth":26,"on_screen":false,"help_text":"Open app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":". The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Thought for 5s","depth":23,"on_screen":false,"role_description":"disclosure triangle","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thought for 5s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"I'll review the implementation carefully. Let me first check how","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is used elsewhere and look at the test files and","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PaginationState","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all usages of RateLimitException outside the changed files","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\" /Users/lukas/jiminny/app/app --include=\"*.php\" | grep -v \"HandleHubspotRateLimit\\|MatchActivityCrmData\\|Client.php\\|RateLimitException.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |\n/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"PaginationState.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"PaginationState.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"HandleHubspotRateLimitTest.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check how RateLimitException is used in GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\\|LogicException\\|RuntimeException\" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all catch blocks or instanceof checks for RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"catch.*RateLimitException\\|instanceof RateLimitException\" /Users/lukas/jiminny/app/app --include=\"*.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls\n/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {\n/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all test references to RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\" /Users/lukas/jiminny/app/tests --include=\"*.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry/failure handling in GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"RateLimitException\\|retryUntil\\|backoff\\|maxExceptions\\|maxTries\\|release\\|failed\" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"13:use Jiminny\\Exceptions\\RateLimitException;\n51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n56: $this->release(30);","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry/failure handling in GenerateAiCallScoringJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"RateLimitException\\|retryUntil\\|backoff\\|maxExceptions\\|maxTries\\|release\\|failed\" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"17:use Jiminny\\Exceptions\\RateLimitException;\n68: RateLimitException |\n75: $this->release(30);","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry config in MatchActivityCrmData","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"retryAfter\\|RETRY_WINDOW\\|retryUntil\\|maxTries\" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"34: private const int RETRY_WINDOW_MINUTES = 30;\n45: public function retryUntil(): \\DateTimeInterface\n47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n77: return self::RETRY_WINDOW_MINUTES * 60 + 60;","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check the cache key format","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"getRateLimitCacheKey\\|hubspot:ratelimit\" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"78: $cacheKey = $this->getRateLimitCacheKey();\n110: private function getRateLimitCacheKey(): string\n112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find other jobs that might use the rate limit middleware","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"class.*Middleware\\|HandleHubspotRateLimit\" /Users/lukas/jiminny/app/app/Jobs --include=\"*.php\" | grep -v \"HandleHubspotRateLimit.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\\Jobs\\Middleware\\HandleHubspotRateLimit;\n/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"HubspotClientInterface.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"HubspotClientInterface.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Thought for 73s","depth":23,"on_screen":false,"role_description":"disclosure triangle","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thought for 73s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Now I have a complete picture. Here's my full analysis:","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Review Summary","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Review Summary","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"RuntimeException vs LogicException — Safe ✓","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"RuntimeException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"vs","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LogicException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"— Safe ✓","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The change is semantically correct and won't break anything.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LogicException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is for programmer errors (invalid arguments, contract violations). A rate limit from an external API is a runtime condition —","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RuntimeException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is the right parent. All callers catch","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"by name explicitly (union catches in","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateAiCallScoringJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SummarizeTranscriptionJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateAiActivityTypeJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":") or check","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"instanceof RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"— none relies on the parent class type, so nothing breaks.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 1 — Circuit breaker stores stale delay (most important bug)","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 1 — Circuit breaker stores stale delay (most important bug)","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"Client.php:94","depth":23,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Client.php:94","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Redis::setex($cacheKey, $retryAfter, (string) $retryAfter);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The TTL is","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"$retryAfter","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"seconds, but the","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"stored value","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is also the original","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"$retryAfter","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":". If a second job reads this cache 20 seconds later, it throws","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException(retryAfter=30)","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"and gets delayed 30 more seconds — even though only 10 remain. In a spike of 100 jobs, each subsequent job inflates its wait.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Fix:","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"store the absolute expiry timestamp, reconstruct the remaining delay when reading:","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"// on 429:\nRedis::setex($cacheKey, $retryAfter, (string) (time() + $retryAfter));\n\n// in the cached-read branch:\n$remaining = max(1, (int) $cachedRetryAfter - time());\nthrow new RateLimitException('Hubspot rate limit (cached circuit-breaker)', $remaining);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 2 — retryUntil() window may expire under sustained rate limiting","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 2 —","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"retryUntil()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"window may expire under sustained rate limiting","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"MatchActivityCrmData.php:45-47","depth":23,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"MatchActivityCrmData.php:45-47","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"retryUntil()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is calculated as","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"now() + 30 min","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"at","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"dispatch time","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"and stored in the job payload. With","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MAX_RETRY_DELAY = 600s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":", you only get ~3 rate-limit-triggered releases before the 30-minute window closes and the job is permanently failed. For a heavy 429 spike (daily limit, parsed as 600s), this can easily happen.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Consider increasing","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RETRY_WINDOW_MINUTES","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"to 60 or more, or making it configurable. The","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"uniqueFor()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"value should be updated to match.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 3 — Only search() uses the circuit breaker, but other methods can still hit 429","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 3 — Only","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"search()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"uses the circuit breaker, but other methods can still hit 429","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"Client.php:76-108","depth":23,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Client.php:76-108","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"executeRequest()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is only called from","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"search()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":". Methods like","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getOpportunityById()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"batchReadObjects()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getContactByEmail()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"bypass it entirely. If HubSpot's 429 is account-wide (not search-endpoint-specific), those methods will keep firing even while the circuit breaker is supposedly open. This is likely fine because the search endpoint has its own tighter quota (5 RPS), but it's worth being explicit in a comment.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 4 — object $job type in middleware has no compile-time safety","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 4 —","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"object $job","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"type in middleware has no compile-time safety","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"HandleHubspotRateLimit.php:20","depth":23,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"HandleHubspotRateLimit.php:20","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"public function handle(object $job, callable $next): void","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Calling","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"$job->attempts()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"and","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"$job->release()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"on","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"object","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"will work at runtime since Laravel always passes jobs that use","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"InteractsWithQueue","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":", but PHPStan/psalm won't verify this and it could silently break in tests with bare mocks (as your test does use a","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Job","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"mock that has","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"attempts()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"— so the test is fine, but the real type contract is invisible). Minor, but worth tightening to","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"\\Illuminate\\Contracts\\Queue\\Job","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"What works well ✓","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"What works well ✓","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is caught by the middleware","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"before","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"it increments","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"maxExceptions","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":", so rate-limit releases don't consume the 3-exception budget for real failures.","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The middleware's jitter (","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"random_int(0, 5)","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":") prevents thundering herd on release.","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The log-sampling logic (","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"attempts <= 3 || attempts % 10 === 0","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":") keeps logs useful without flooding.","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ShouldBeUnique","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"+","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"uniqueFor()","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"correctly prevents duplicate jobs queuing up during the rate-limit wait window.","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"propagates cleanly through the DB transaction (rolled back), then through the job catch block (logged conditionally), then to the middleware which handles it.","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The pagination service re-throws","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"as-is and resets pagination state on the next job attempt — safe because offset restarts from 0.","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Message actions","depth":24,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Expand on 1 and 2. Let's consider there are several jobs running from ten workers. They are out fired instantly, and therefore there is no cache when they look for cache, but once they receive 429, they store cache. Can there be some potential issues with that flow?","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Thought for 91s","depth":23,"on_screen":false,"role_description":"disclosure triangle","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thought for 91s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Let me trace the exact race conditions step by step.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Wave 1 — The Initial Burst (t=0)","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Wave 1 — The Initial Burst (t=0)","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"All 10 workers execute simultaneously. Each does:","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"$cachedRetryAfter = Redis::get($cacheKey); // ALL TEN: null — cache miss","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Cache doesn't exist yet, so all 10 proceed and hammer HubSpot's search endpoint simultaneously. HubSpot's search limit is 5 RPS per portal — 10 concurrent requests guarantees 429s. All 10 get 429, all 10 run:","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Redis::setex($cacheKey, $retryAfter, (string) $retryAfter); // e.g. TTL=10, value=\"10\"\nthrow new RateLimitException('Hubspot returned 429', $retryAfter, $e);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"All 10 write the same key at nearly the same millisecond. Redis just accepts last-write-wins — no error, no coordination, 10 redundant writes. Then all 10 are caught by the middleware and released:","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"delay = max(1, min(10, 600)) + random_int(0, 5) → 10 to 15 seconds","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"So all 10 jobs land back in the queue, scheduled to fire between t=10 and t=15.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"The Core Problem — TTL Expires Before Released Jobs Fire","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"The Core Problem — TTL Expires Before Released Jobs Fire","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The Redis key was set with","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"TTL = retryAfter = 10","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":". The jobs were released with","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"delay = retryAfter + jitter = 10–15","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Redis key expires: t = 10\nJobs start firing: t = 10 to t = 15","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"These are nearly the same window. The very first job that fires at t=10 may race against the TTL expiry. Jobs firing at t=11–15","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"will find an empty cache","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"because the key already expired. So the circuit breaker provides","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"zero protection","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"for the second wave of the same jobs. They all miss the cache again and charge straight at HubSpot.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"This is the core mismatch: the release delay was derived from the same value as the TTL, so they expire together. The circuit breaker is only effective for","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"new, independent jobs","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"that arrive during the t=0–10 window, not for the 10 jobs that triggered the 429 in the first place.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 1 Compounded — Stale Delay Makes It Worse","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 1 Compounded — Stale Delay Makes It Worse","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Say a new job (job #11) arrives at t=5. It reads the cache and gets","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"\"10\"","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":". The middleware releases it for 10 seconds → fires at t=15. But the rate limit ended at t=10. It waited 5 extra seconds for nothing, and it fires exactly when the 10 original jobs are also firing (t=10–15). So instead of smoothing the load, the stale delay actually","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"bunches job #11 into the same second wave","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"With the absolute-timestamp fix:","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"t=5: job #11 reads cache, computes remaining =","depth":24,"on_screen":false,"role_description":"text"}]...
|
-3987219873014532913
|
6764940850431264870
|
idle
|
accessibility
|
NULL
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G) - 17 pending changes
17
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Testing
Claude Code
SOURCE CONTROL
SOURCE CONTROL
Changes Section
CHANGES
CHANGES
Commit Changes on "JY-20725-handle-HS-search-rate-limit"
Commit
More Actions...
Changes
17
.env.local
M
.env.nikilocal
U
.env.other
U
ids.txt
U
raw_sql_query.sql
U
WEBHOOK_FILTERING_IMPLEMENTATION.md
U
JiminnyDebugCommand.php
app/Console/Commands
M
SimulateWebhooksCommand.php
app/Console/Commands/Crm/Hubspot
U
CreateMockAskJiminnyReportResultCommand.php
app/Console/Commands/Reports
U
RateLimitException.php
app/Exceptions
M
MatchActivityCrmData.php
app/Jobs/Crm
M
Client.php
app/Services/Crm/Hubspot
9, M
logging.php
config
M
favicon.ico
public
U
HandleHubspotRateLimitTest.php
tests/Unit/Jobs/Middleware
M
CanAccessAiReportsTest.php
tests/Unit/Policies
U
ClientTest.php
tests/Unit/Services/Crm/Hubspot
M
Graph Section
GRAPH
GRAPH
JY-20725 add more tests
Lukas Kovalik
JY-20725-handle-HS-search-rate-limit
JY-20725 add tests
Lukas Kovalik
JY-20725 add HS rate limit handling on activities rematching
Lukas Kovalik
Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
origin/master
Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit
mihailmihaylovjiminny
JY-20819: Increase download transcription rate limit
mihail.mihaylov
JY-20818 move ask jiminny reports to its own datadog metric
Lukas Kovalik
Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge branch 'master' into JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge pull request #12047 from jiminny/JY-20662-remove-word-boost
ilian-jiminny
JY-20662 return void
ilian
JY-20662 Removed word_boost parameter from Assembly transcription request
ilian
JY-20817: Fix deleting old tracks
mihail.mihaylov
Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge branch 'master' into JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update
ilian-jiminny
JY-19938 Added consent information and recording error reason to Elastic Search mapping
ilian
JY-19938 reorder array map
ilian
JY-19938: Address PR review comments
ilian
JY-19938 lint fixes
ilian
HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1
ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1
Client.php (Working Tree) (Client.php), Editor Group 1
MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1
MatchActivityCrmData.php, Editor Group 1
RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1
Client.php, preview, Editor Group 1
<?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 {
<?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 {
Review rate limit handli…, Editor Group 2
remote
app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...
JY-20725-handle-HS-search-rate-limit*
app (Git) - Synchronize Changes
Errors: 6, Warnings: 11, Infos: 28
6
11
28
Notifications
key, PHP extension: Premium features not active.
8.3, Language Level: 8.3
Sign In
Sign In
PHP
Editor Language Status: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 4
Ln 320, Col 30
git-commit Lukas Kovalik (5 months ago)
Lukas Kovalik (5 months ago)
Found 1 symbol in /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Clear
Review rate limit handling for HubSpot job queue
Session history
New session
Message actions
RateLimitException.php#9-9
RateLimitException.php#9-9
Reveiw the diff. Mostly
@app/Jobs/Crm/MatchActivityCrmData.php
,
@app/Exceptions/RateLimitException.php
,
@app/Jobs/Middleware/HandleHubspotRateLimit.php
,
@app/Services/Crm/Hubspot/Client.php
and
@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php
. The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?
Thought for 5s
Thought for 5s
I'll review the implementation carefully. Let me first check how
RateLimitException
is used elsewhere and look at the test files and
PaginationState
.
Bash
Find all usages of RateLimitException outside the changed files
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/app --include="*.php" | grep -v "HandleHubspotRateLimit\|MatchActivityCrmData\|Client.php\|RateLimitException.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is
Read
PaginationState.php
PaginationState.php
Read
HandleHubspotRateLimitTest.php
HandleHubspotRateLimitTest.php
Bash
Check how RateLimitException is used in GenerateActionItemsJob
IN
grep -rn "RateLimitException\|LogicException\|RuntimeException" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
Bash
Find all catch blocks or instanceof checks for RateLimitException
IN
grep -rn "catch.*RateLimitException\|instanceof RateLimitException" /Users/lukas/jiminny/app/app --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {
Bash
Find all test references to RateLimitException
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/tests --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);
Bash
Check retry/failure handling in GenerateActionItemsJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30
Copy code to clipboard
OUT
13:use Jiminny\Exceptions\RateLimitException;
51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
56: $this->release(30);
Bash
Check retry/failure handling in GenerateAiCallScoringJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30
Copy code to clipboard
OUT
17:use Jiminny\Exceptions\RateLimitException;
68: RateLimitException |
75: $this->release(30);
Bash
Check retry config in MatchActivityCrmData
IN
grep -n "retryAfter\|RETRY_WINDOW\|retryUntil\|maxTries" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php
Copy code to clipboard
OUT
34: private const int RETRY_WINDOW_MINUTES = 30;
45: public function retryUntil(): \DateTimeInterface
47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);
77: return self::RETRY_WINDOW_MINUTES * 60 + 60;
Bash
Check the cache key format
IN
grep -n "getRateLimitCacheKey\|hubspot:ratelimit" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Copy code to clipboard
OUT
78: $cacheKey = $this->getRateLimitCacheKey();
110: private function getRateLimitCacheKey(): string
112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());
Bash
Find other jobs that might use the rate limit middleware
IN
grep -rn "class.*Middleware\|HandleHubspotRateLimit" /Users/lukas/jiminny/app/app/Jobs --include="*.php" | grep -v "HandleHubspotRateLimit.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];
Read
HubspotClientInterface.php
HubspotClientInterface.php
Thought for 73s
Thought for 73s
Now I have a complete picture. Here's my full analysis:
Review Summary
Review Summary
RuntimeException vs LogicException — Safe ✓
RuntimeException
vs
LogicException
— Safe ✓
The change is semantically correct and won't break anything.
LogicException
is for programmer errors (invalid arguments, contract violations). A rate limit from an external API is a runtime condition —
RuntimeException
is the right parent. All callers catch
RateLimitException
by name explicitly (union catches in
GenerateActionItemsJob
,
GenerateAiCallScoringJob
,
SummarizeTranscriptionJob
,
GenerateAiActivityTypeJob
) or check
instanceof RateLimitException
— none relies on the parent class type, so nothing breaks.
Issue 1 — Circuit breaker stores stale delay (most important bug)
Issue 1 — Circuit breaker stores stale delay (most important bug)
Client.php:94
Client.php:94
Copy code to clipboard
Redis::setex($cacheKey, $retryAfter, (string) $retryAfter);
The TTL is
$retryAfter
seconds, but the
stored value
is also the original
$retryAfter
. If a second job reads this cache 20 seconds later, it throws
RateLimitException(retryAfter=30)
and gets delayed 30 more seconds — even though only 10 remain. In a spike of 100 jobs, each subsequent job inflates its wait.
Fix:
store the absolute expiry timestamp, reconstruct the remaining delay when reading:
Copy code to clipboard
// on 429:
Redis::setex($cacheKey, $retryAfter, (string) (time() + $retryAfter));
// in the cached-read branch:
$remaining = max(1, (int) $cachedRetryAfter - time());
throw new RateLimitException('Hubspot rate limit (cached circuit-breaker)', $remaining);
Issue 2 — retryUntil() window may expire under sustained rate limiting
Issue 2 —
retryUntil()
window may expire under sustained rate limiting
MatchActivityCrmData.php:45-47
MatchActivityCrmData.php:45-47
retryUntil()
is calculated as
now() + 30 min
at
dispatch time
and stored in the job payload. With
MAX_RETRY_DELAY = 600s
, you only get ~3 rate-limit-triggered releases before the 30-minute window closes and the job is permanently failed. For a heavy 429 spike (daily limit, parsed as 600s), this can easily happen.
Consider increasing
RETRY_WINDOW_MINUTES
to 60 or more, or making it configurable. The
uniqueFor()
value should be updated to match.
Issue 3 — Only search() uses the circuit breaker, but other methods can still hit 429
Issue 3 — Only
search()
uses the circuit breaker, but other methods can still hit 429
Client.php:76-108
Client.php:76-108
executeRequest()
is only called from
search()
. Methods like
getOpportunityById()
,
batchReadObjects()
,
getContactByEmail()
bypass it entirely. If HubSpot's 429 is account-wide (not search-endpoint-specific), those methods will keep firing even while the circuit breaker is supposedly open. This is likely fine because the search endpoint has its own tighter quota (5 RPS), but it's worth being explicit in a comment.
Issue 4 — object $job type in middleware has no compile-time safety
Issue 4 —
object $job
type in middleware has no compile-time safety
HandleHubspotRateLimit.php:20
HandleHubspotRateLimit.php:20
Copy code to clipboard
public function handle(object $job, callable $next): void
Calling
$job->attempts()
and
$job->release()
on
object
will work at runtime since Laravel always passes jobs that use
InteractsWithQueue
, but PHPStan/psalm won't verify this and it could silently break in tests with bare mocks (as your test does use a
Job
mock that has
attempts()
— so the test is fine, but the real type contract is invisible). Minor, but worth tightening to
\Illuminate\Contracts\Queue\Job
.
What works well ✓
What works well ✓
RateLimitException
is caught by the middleware
before
it increme...
|
NULL
|
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/ /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php...
|
NULL
|
NULL
|
|
20438
|
885
|
0
|
2026-05-11T15:29:38.813599+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778513378813_m2.jpg...
|
Code
|
Client.php — app — 9 problems in this file • Modif Client.php — app — 9 problems in this file • Modified...
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G) - 17 pending changes
17
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Testing
Claude Code
SOURCE CONTROL
SOURCE CONTROL
Changes Section
CHANGES
CHANGES
Commit Changes on "JY-20725-handle-HS-search-rate-limit"
Commit
More Actions...
Changes
17
.env.local
M
.env.nikilocal
U
.env.other
U
ids.txt
U
raw_sql_query.sql
U
WEBHOOK_FILTERING_IMPLEMENTATION.md
U
JiminnyDebugCommand.php
app/Console/Commands
M
SimulateWebhooksCommand.php
app/Console/Commands/Crm/Hubspot
U
CreateMockAskJiminnyReportResultCommand.php
app/Console/Commands/Reports
U
RateLimitException.php
app/Exceptions
M
MatchActivityCrmData.php
app/Jobs/Crm
M
Client.php
app/Services/Crm/Hubspot
9, M
logging.php
config
M
favicon.ico
public
U
HandleHubspotRateLimitTest.php
tests/Unit/Jobs/Middleware
M
CanAccessAiReportsTest.php
tests/Unit/Policies
U
ClientTest.php
tests/Unit/Services/Crm/Hubspot
M
Graph Section
GRAPH
GRAPH
JY-20725 add more tests
Lukas Kovalik
JY-20725-handle-HS-search-rate-limit
JY-20725 add tests
Lukas Kovalik
JY-20725 add HS rate limit handling on activities rematching
Lukas Kovalik
Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
origin/master
Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit
mihailmihaylovjiminny
JY-20819: Increase download transcription rate limit
mihail.mihaylov
JY-20818 move ask jiminny reports to its own datadog metric
Lukas Kovalik
Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge branch 'master' into JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge pull request #12047 from jiminny/JY-20662-remove-word-boost
ilian-jiminny
JY-20662 return void
ilian
JY-20662 Removed word_boost parameter from Assembly transcription request
ilian
JY-20817: Fix deleting old tracks
mihail.mihaylov
Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge branch 'master' into JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update
ilian-jiminny
JY-19938 Added consent information and recording error reason to Elastic Search mapping
ilian
JY-19938 reorder array map
ilian
JY-19938: Address PR review comments
ilian
JY-19938 lint fixes
ilian
HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1
ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1
Client.php (Working Tree) (Client.php), Editor Group 1
MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1
MatchActivityCrmData.php, Editor Group 1
RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1
Client.php, preview, Editor Group 1
<?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 {
<?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 {
Review rate limit handli…, Editor Group 2
remote
app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...
JY-20725-handle-HS-search-rate-limit*
app (Git) - Synchronize Changes
Errors: 6, Warnings: 11, Infos: 28
6
11
28
Notifications
key, PHP extension: Premium features not active.
8.3, Language Level: 8.3
Sign In
Sign In
PHP
Editor Language Status: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 4
Ln 320, Col 30
git-commit Lukas Kovalik (5 months ago)
Lukas Kovalik (5 months ago)
Found 1 symbol in /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Clear
Review rate limit handling for HubSpot job queue
Session history
New session
Message actions
RateLimitException.php#9-9
RateLimitException.php#9-9
Reveiw the diff. Mostly
@app/Jobs/Crm/MatchActivityCrmData.php
,
@app/Exceptions/RateLimitException.php
,
@app/Jobs/Middleware/HandleHubspotRateLimit.php
,
@app/Services/Crm/Hubspot/Client.php
and
@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php
. The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?
Thought for 5s
Thought for 5s
I'll review the implementation carefully. Let me first check how
RateLimitException
is used elsewhere and look at the test files and
PaginationState
.
Bash
Find all usages of RateLimitException outside the changed files
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/app --include="*.php" | grep -v "HandleHubspotRateLimit\|MatchActivityCrmData\|Client.php\|RateLimitException.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is
Read
PaginationState.php
PaginationState.php
Read
HandleHubspotRateLimitTest.php
HandleHubspotRateLimitTest.php
Bash
Check how RateLimitException is used in GenerateActionItemsJob
IN
grep -rn "RateLimitException\|LogicException\|RuntimeException" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
Bash
Find all catch blocks or instanceof checks for RateLimitException
IN
grep -rn "catch.*RateLimitException\|instanceof RateLimitException" /Users/lukas/jiminny/app/app --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {
Bash
Find all test references to RateLimitException
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/tests --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);
Bash
Check retry/failure handling in GenerateActionItemsJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30
Copy code to clipboard
OUT
13:use Jiminny\Exceptions\RateLimitException;
51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
56: $this->release(30);
Bash
Check retry/failure handling in GenerateAiCallScoringJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30
Copy code to clipboard
OUT
17:use Jiminny\Exceptions\RateLimitException;
68: RateLimitException |
75: $this->release(30);
Bash
Check retry config in MatchActivityCrmData
IN
grep -n "retryAfter\|RETRY_WINDOW\|retryUntil\|maxTries" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php
Copy code to clipboard
OUT
34: private const int RETRY_WINDOW_MINUTES = 30;
45: public function retryUntil(): \DateTimeInterface
47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);
77: return self::RETRY_WINDOW_MINUTES * 60 + 60;
Bash
Check the cache key format
IN
grep -n "getRateLimitCacheKey\|hubspot:ratelimit" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Copy code to clipboard
OUT
78: $cacheKey = $this->getRateLimitCacheKey();
110: private function getRateLimitCacheKey(): string
112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());
Bash
Find other jobs that might use the rate limit middleware
IN
grep -rn "class.*Middleware\|HandleHubspotRateLimit" /Users/lukas/jiminny/app/app/Jobs --include="*.php" | grep -v "HandleHubspotRateLimit.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];
Read
HubspotClientInterface.php
HubspotClientInterface.php
Thought for 73s
Thought for 73s
Now I have a complete picture. Here's my full analysis:
Review Summary
Review Summary
RuntimeException vs LogicException — Safe ✓
RuntimeException
vs
LogicException
— Safe ✓
The change is semantically correct and won't break anything.
LogicException
is for programmer errors (invalid arguments, contract violations). A rate limit from an external API is a runtime condition —
RuntimeException
is the right parent. All callers catch
RateLimitException
by name explicitly (union catches in
GenerateActionItemsJob
,
GenerateAiCallScoringJob
,
SummarizeTranscriptionJob
,
GenerateAiActivityTypeJob
) or check
instanceof RateLimitException
— none relies on the parent class type, so nothing breaks.
Issue 1 — Circuit breaker stores stale delay (most important bug)
Issue 1 — Circuit breaker stores stale delay (most important bug)
Client.php:94
Client.php:94
Copy code to clipboard
Redis::setex($cacheKey, $retryAfter, (string) $retryAfter);
The TTL is
$retryAfter
seconds, but the
stored value
is also the original
$retryAfter
. If a second job reads this cache 20 seconds later, it throws
RateLimitException(retryAfter=30)
and gets delayed 30 more seconds — even though only 10 remain. In a spike of 100 jobs, each subsequent job inflates its wait.
Fix:
store the absolute expiry timestamp, reconstruct the remaining delay when reading:
Copy code to clipboard
// on 429:
Redis::setex($cacheKey, $retryAfter, (string) (time() + $retryAfter));
// in the cached-read branch:
$remaining = max(1, (int) $cachedRetryAfter - time());
throw new RateLimitException('Hubspot rate limit (cached circuit-breaker)', $remaining);
Issue 2 — retryUntil() window may expire under sustained rate limiting
Issue 2 —
retryUntil()
window may expire under sustained rate limiting
MatchActivityCrmData.php:45-47
MatchActivityCrmData.php:45-47
retryUntil()
is calculated as
now() + 30 min
at
dispatch time
and stored in the job payload. With
MAX_RETRY_DELAY = 600s
, you only get ~3 rate-limit-triggered releases before the 30-minute window closes and the job is permanently failed. For a heavy 429 spike (daily limit, parsed as 600s), this can easily happen.
Consider increasing
RETRY_WINDOW_MINUTES
to 60 or more, or making it configurable. The
uniqueFor()
value should be updated to match.
Issue 3 — Only search() uses the circuit breaker, but other methods can still hit 429
Issue 3 — Only
search()
uses the circuit breaker, but other methods can still hit 429
Client.php:76-108
Client.php:76-108
executeRequest()
is only called from
search()
. Methods like
getOpportunityById()
,
batchReadObjects()
,
getContactByEmail()
bypass it entirely. If HubSpot's 429 is account-wide (not search-endpoint-specific), those methods will keep firing even while the circuit breaker is supposedly open. This is likely fine because the search endpoint has its own tighter quota (5 RPS), but it's worth being explicit in a comment.
Issue 4 — object $job type in middleware has no compile-time safety
Issue 4 —
object $job
type in middleware has no compile-time safety
HandleHubspotRateLimit.php:20
HandleHubspotRateLimit.php:20
Copy code to clipboard
public function handle(object $job, callable $next): void
Calling
$job->attempts()
and
$job->release()
on
object
will work at runtime since Laravel always passes jobs that use
InteractsWithQueue
, but PHPStan/psalm won't verify this and it could silently break in tests with bare mocks (as your test does use a
Job
mock that has
attempts()
— so the test is fine, but the real type contract is invisible). Minor, but worth tightening to
\Illuminate\Contracts\Queue\Job
.
What works well ✓
What works well ✓
RateLimitException
is caught by the middleware
before
it increme...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Explorer (⇧⌘E)","depth":19,"bounds":{"left":0.0,"top":0.047885075,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.057462092,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Search (⇧⌘F)","depth":19,"bounds":{"left":0.0,"top":0.08619314,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.09577015,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Source Control (⌃⇧G) - 17 pending changes","depth":19,"bounds":{"left":0.0,"top":0.1245012,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":true},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.13407822,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"17","depth":22,"bounds":{"left":0.00831117,"top":0.1452514,"width":0.003656915,"height":0.008778931},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.008643617,"top":0.14604948,"width":0.0016622341,"height":0.007980846}},{"char_start":1,"char_count":1,"bounds":{"left":0.009973404,"top":0.14604948,"width":0.0019946808,"height":0.007980846}}],"role_description":"text"},{"role":"AXRadioButton","text":"Run and Debug (⇧⌘D)","depth":19,"bounds":{"left":0.0,"top":0.16280925,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.17238627,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Remote Explorer","depth":19,"bounds":{"left":0.0,"top":0.20111732,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.21069433,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Extensions (⇧⌘X) - 2 require update","depth":19,"bounds":{"left":0.0,"top":0.23942538,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.2490024,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"2","depth":22,"bounds":{"left":0.009640957,"top":0.2601756,"width":0.0019946808,"height":0.008778931},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Testing","depth":19,"bounds":{"left":0.0,"top":0.27773345,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.0039893617,"top":0.28731045,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Claude Code","depth":19,"bounds":{"left":0.0,"top":0.3160415,"width":0.015957447,"height":0.03830806},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"SOURCE CONTROL","depth":17,"bounds":{"left":0.022606382,"top":0.047885075,"width":0.034242023,"height":0.02793296},"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"SOURCE CONTROL","depth":18,"bounds":{"left":0.022606382,"top":0.056664005,"width":0.034242023,"height":0.0103751},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Changes Section","depth":21,"bounds":{"left":0.015957447,"top":0.07581804,"width":0.09940159,"height":0.017557861},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"","depth":23,"bounds":{"left":0.01662234,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"CHANGES","depth":22,"bounds":{"left":0.022606382,"top":0.07581804,"width":0.01861702,"height":0.017557861},"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"CHANGES","depth":23,"bounds":{"left":0.022606382,"top":0.079010375,"width":0.01861702,"height":0.0103751},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"","depth":28,"bounds":{"left":0.024601065,"top":0.09896249,"width":0.07247341,"height":0.015961692},"on_screen":true,"value":"","role_description":"editor","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":29,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Commit Changes on \"JY-20725-handle-HS-search-rate-limit\"","depth":26,"bounds":{"left":0.022273935,"top":0.1245012,"width":0.08045213,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.052526597,"top":0.12849163,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Commit","depth":27,"bounds":{"left":0.058843084,"top":0.1292897,"width":0.014960106,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.059175532,"top":0.1292897,"width":0.0029920214,"height":0.011173184}},{"char_start":1,"char_count":5,"bounds":{"left":0.061835106,"top":0.1292897,"width":0.011968086,"height":0.011173184}}],"role_description":"text"},{"role":"AXPopUpButton","text":"More Actions...","depth":26,"bounds":{"left":0.10305851,"top":0.1245012,"width":0.00831117,"height":0.0207502},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.1043883,"top":0.12849163,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.019614361,"top":0.15163608,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Changes","depth":27,"bounds":{"left":0.025930852,"top":0.15163608,"width":0.01761968,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.025930852,"top":0.15243416,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":6,"bounds":{"left":0.028922873,"top":0.15243416,"width":0.014960106,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"17","depth":27,"bounds":{"left":0.10571808,"top":0.15243416,"width":0.0039893617,"height":0.0103751},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.10605053,"top":0.15243416,"width":0.0016622341,"height":0.0103751}},{"char_start":1,"char_count":1,"bounds":{"left":0.10771277,"top":0.15243416,"width":0.0019946808,"height":0.0103751}}],"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.16759777,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.local","depth":28,"bounds":{"left":0.028590426,"top":0.16919394,"width":0.01861702,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.16999201,"width":0.0013297872,"height":0.011971269}},{"char_start":1,"char_count":9,"bounds":{"left":0.029920213,"top":0.16999201,"width":0.01761968,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.16999201,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.18515563,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.nikilocal","depth":28,"bounds":{"left":0.028590426,"top":0.1867518,"width":0.025598405,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.18754987,"width":0.0013297872,"height":0.011971269}},{"char_start":1,"char_count":13,"bounds":{"left":0.029920213,"top":0.18754987,"width":0.024268618,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.18754987,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.20271349,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.other","depth":28,"bounds":{"left":0.028590426,"top":0.20430966,"width":0.019946808,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.20510775,"width":0.0013297872,"height":0.011971269}},{"char_start":1,"char_count":9,"bounds":{"left":0.029920213,"top":0.20510775,"width":0.01861702,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.20510775,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.22027135,"width":0.0076462766,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ids.txt","depth":28,"bounds":{"left":0.028590426,"top":0.22186752,"width":0.012300532,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.22266561,"width":0.0009973404,"height":0.011971269}},{"char_start":1,"char_count":6,"bounds":{"left":0.029587766,"top":0.22266561,"width":0.011635638,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.22266561,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.23782921,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"raw_sql_query.sql","depth":28,"bounds":{"left":0.028590426,"top":0.23942538,"width":0.035904255,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.24022347,"width":0.0016622341,"height":0.011971269}},{"char_start":1,"char_count":16,"bounds":{"left":0.03025266,"top":0.24022347,"width":0.034242023,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.24022347,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.25538707,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"WEBHOOK_FILTERING_IMPLEMENTATION.md","depth":28,"bounds":{"left":0.028590426,"top":0.25698325,"width":0.076130316,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.25778133,"width":0.0043218085,"height":0.011971269}},{"char_start":1,"char_count":34,"bounds":{"left":0.032912236,"top":0.25778133,"width":0.08909574,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.25778133,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.27294493,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JiminnyDebugCommand.php","depth":28,"bounds":{"left":0.028590426,"top":0.2745411,"width":0.05817819,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.2753392,"width":0.0023271276,"height":0.011971269}},{"char_start":1,"char_count":22,"bounds":{"left":0.030917553,"top":0.2753392,"width":0.055851065,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands","depth":28,"bounds":{"left":0.08843085,"top":0.2745411,"width":0.01462766,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.0887633,"top":0.27613726,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":19,"bounds":{"left":0.09075798,"top":0.27613726,"width":0.043218084,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.2753392,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.2905028,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"SimulateWebhooksCommand.php","depth":28,"bounds":{"left":0.028590426,"top":0.29209897,"width":0.06781915,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.29289705,"width":0.0026595744,"height":0.011971269}},{"char_start":1,"char_count":26,"bounds":{"left":0.03125,"top":0.29289705,"width":0.065159574,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands/Crm/Hubspot","depth":28,"bounds":{"left":0.098071806,"top":0.29209897,"width":0.005984043,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.09840426,"top":0.29369512,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":31,"bounds":{"left":0.100398935,"top":0.29369512,"width":0.06881649,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.29289705,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.30806065,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CreateMockAskJiminnyReportResultCommand.php","depth":28,"bounds":{"left":0.028590426,"top":0.30965683,"width":0.07646277,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.3104549,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":42,"bounds":{"left":0.03158245,"top":0.3104549,"width":0.099734046,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Console/Commands/Reports","depth":28,"bounds":{"left":0.10538564,"top":0.3104549,"width":0.0003324468,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.13331117,"top":0.31125298,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":27,"bounds":{"left":0.13530585,"top":0.31125298,"width":0.05851064,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.3104549,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.3256185,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException.php","depth":28,"bounds":{"left":0.028590426,"top":0.3272147,"width":0.047539894,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.32801276,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":21,"bounds":{"left":0.03158245,"top":0.32801276,"width":0.04488032,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Exceptions","depth":28,"bounds":{"left":0.078125,"top":0.3272147,"width":0.024933511,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.078125,"top":0.32881084,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":13,"bounds":{"left":0.08045213,"top":0.32881084,"width":0.026263298,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.32801276,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.34317636,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"MatchActivityCrmData.php","depth":28,"bounds":{"left":0.028590426,"top":0.34477255,"width":0.054521278,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.34557062,"width":0.0039893617,"height":0.011971269}},{"char_start":1,"char_count":23,"bounds":{"left":0.032579787,"top":0.34557062,"width":0.050531916,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Jobs/Crm","depth":28,"bounds":{"left":0.084773935,"top":0.34477255,"width":0.020279255,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.084773935,"top":0.3463687,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":11,"bounds":{"left":0.087101065,"top":0.3463687,"width":0.023603724,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.34557062,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.36073422,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Client.php","depth":28,"bounds":{"left":0.028590426,"top":0.3623304,"width":0.020611702,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.36312848,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":9,"bounds":{"left":0.03158245,"top":0.36312848,"width":0.01761968,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"app/Services/Crm/Hubspot","depth":28,"bounds":{"left":0.05119681,"top":0.3623304,"width":0.028590426,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.05119681,"top":0.3639266,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":23,"bounds":{"left":0.053523935,"top":0.3639266,"width":0.047539894,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"9, M","depth":28,"bounds":{"left":0.10206117,"top":0.36312848,"width":0.00831117,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.10206117,"top":0.36312848,"width":0.0026595744,"height":0.011173184}},{"char_start":1,"char_count":3,"bounds":{"left":0.10472074,"top":0.36312848,"width":0.005984043,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.3782921,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"logging.php","depth":28,"bounds":{"left":0.028590426,"top":0.37988827,"width":0.023936171,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.38068634,"width":0.0009973404,"height":0.011971269}},{"char_start":1,"char_count":10,"bounds":{"left":0.029587766,"top":0.38068634,"width":0.022938829,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"config","depth":28,"bounds":{"left":0.05418883,"top":0.38068634,"width":0.011968086,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.054521278,"top":0.38148445,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":5,"bounds":{"left":0.05651596,"top":0.38148445,"width":0.009640957,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.38068634,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.39584997,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"favicon.ico","depth":28,"bounds":{"left":0.028590426,"top":0.39744613,"width":0.021609042,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.3982442,"width":0.0016622341,"height":0.011971269}},{"char_start":1,"char_count":10,"bounds":{"left":0.03025266,"top":0.3982442,"width":0.020279255,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"public","depth":28,"bounds":{"left":0.05219415,"top":0.3982442,"width":0.011303191,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.05219415,"top":0.3990423,"width":0.0026595744,"height":0.011173184}},{"char_start":1,"char_count":5,"bounds":{"left":0.054521278,"top":0.3990423,"width":0.008976064,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.3982442,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.41340783,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php","depth":28,"bounds":{"left":0.028590426,"top":0.415004,"width":0.06781915,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.41580206,"width":0.0033244682,"height":0.011971269}},{"char_start":1,"char_count":29,"bounds":{"left":0.031914894,"top":0.41580206,"width":0.064494684,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Jobs/Middleware","depth":28,"bounds":{"left":0.098071806,"top":0.415004,"width":0.004986702,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.098071806,"top":0.41660017,"width":0.0016622341,"height":0.011173184}},{"char_start":1,"char_count":25,"bounds":{"left":0.09940159,"top":0.41660017,"width":0.04886968,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.41580206,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.4309657,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CanAccessAiReportsTest.php","depth":28,"bounds":{"left":0.028590426,"top":0.43256184,"width":0.059175532,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.43335995,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":25,"bounds":{"left":0.03158245,"top":0.43335995,"width":0.05618351,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Policies","depth":28,"bounds":{"left":0.089428194,"top":0.43256184,"width":0.013962766,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.08976064,"top":0.43415803,"width":0.0013297872,"height":0.011173184}},{"char_start":1,"char_count":18,"bounds":{"left":0.091090426,"top":0.43415803,"width":0.031914894,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"U","depth":28,"bounds":{"left":0.107380316,"top":0.43335995,"width":0.0029920214,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.021276595,"top":0.44852355,"width":0.0063164895,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClientTest.php","depth":28,"bounds":{"left":0.028590426,"top":0.4501197,"width":0.028922873,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.028590426,"top":0.4509178,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":13,"bounds":{"left":0.03158245,"top":0.4509178,"width":0.026263298,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"tests/Unit/Services/Crm/Hubspot","depth":28,"bounds":{"left":0.059507977,"top":0.4501197,"width":0.043882977,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.059507977,"top":0.4517159,"width":0.0016622341,"height":0.011173184}},{"char_start":1,"char_count":30,"bounds":{"left":0.060837764,"top":0.4517159,"width":0.059507977,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"M","depth":28,"bounds":{"left":0.106715426,"top":0.4509178,"width":0.003656915,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Graph Section","depth":21,"bounds":{"left":0.015957447,"top":0.60015965,"width":0.09940159,"height":0.017557861},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"","depth":23,"bounds":{"left":0.01662234,"top":0.60255384,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXHeading","text":"GRAPH","depth":22,"bounds":{"left":0.022606382,"top":0.60015965,"width":0.013297873,"height":0.017557861},"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"GRAPH","depth":23,"bounds":{"left":0.022606382,"top":0.60415006,"width":0.013297873,"height":0.0103751},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add more tests","depth":28,"bounds":{"left":0.023271276,"top":0.6201117,"width":0.038231384,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.023271276,"top":0.6209098,"width":0.0026595744,"height":0.011971269}},{"char_start":1,"char_count":22,"bounds":{"left":0.025930852,"top":0.6209098,"width":0.05086436,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.061835106,"top":0.6209098,"width":0.0003324468,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.078457445,"top":0.6217079,"width":0.0026595744,"height":0.011173184}},{"char_start":1,"char_count":12,"bounds":{"left":0.080784574,"top":0.6217079,"width":0.023271276,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.06382979,"top":0.6201117,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit","depth":27,"bounds":{"left":0.06948138,"top":0.6209098,"width":0.032579787,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.06948138,"top":0.6209098,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":35,"bounds":{"left":0.07180851,"top":0.6209098,"width":0.071476065,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.10571808,"top":0.6201117,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add tests","depth":28,"bounds":{"left":0.023271276,"top":0.63766956,"width":0.03956117,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.023271276,"top":0.63846767,"width":0.0023271276,"height":0.011971269}},{"char_start":1,"char_count":17,"bounds":{"left":0.025598405,"top":0.63846767,"width":0.03723404,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.064494684,"top":0.63846767,"width":0.024933511,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.06482713,"top":0.6392658,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":12,"bounds":{"left":0.066821806,"top":0.6392658,"width":0.022938829,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"JY-20725 add HS rate limit handling on activities rematching","depth":28,"bounds":{"left":0.023271276,"top":0.6552275,"width":0.08577128,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.023271276,"top":0.6560255,"width":0.0023271276,"height":0.011971269}},{"char_start":1,"char_count":59,"bounds":{"left":0.025598405,"top":0.6560255,"width":0.11968085,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.109707445,"top":0.6560255,"width":0.0003324468,"height":0.011173184},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.14727394,"top":0.65682364,"width":0.0023271276,"height":0.011173184}},{"char_start":1,"char_count":12,"bounds":{"left":0.14926861,"top":0.65682364,"width":0.022938829,"height":0.011173184}}],"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric","depth":28,"bounds":{"left":0.026928192,"top":0.67278534,"width":0.049534574,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.0774601,"top":0.6735834,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.07945479,"top":0.67278534,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"origin/master","depth":27,"bounds":{"left":0.08510638,"top":0.6735834,"width":0.024933511,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric","depth":28,"bounds":{"left":0.030585106,"top":0.6903432,"width":0.078457445,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.109707445,"top":0.69114125,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit","depth":28,"bounds":{"left":0.030585106,"top":0.70790106,"width":0.078457445,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"bounds":{"left":0.109707445,"top":0.7086991,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20819: Increase download transcription rate limit","depth":28,"bounds":{"left":0.030585106,"top":0.7254589,"width":0.078457445,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihail.mihaylov","depth":28,"bounds":{"left":0.109707445,"top":0.72625697,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20818 move ask jiminny reports to its own datadog metric","depth":28,"bounds":{"left":0.030585106,"top":0.7430168,"width":0.07945479,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":28,"bounds":{"left":0.109707445,"top":0.7438148,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks","depth":28,"bounds":{"left":0.030585106,"top":0.76057464,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"bounds":{"left":0.109707445,"top":0.7613727,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-20817-fix-deleting-old-tracks","depth":28,"bounds":{"left":0.030585106,"top":0.7781325,"width":0.078125,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihailmihaylovjiminny","depth":28,"bounds":{"left":0.109707445,"top":0.77893054,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12047 from jiminny/JY-20662-remove-word-boost","depth":28,"bounds":{"left":0.030585106,"top":0.79569036,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"bounds":{"left":0.109707445,"top":0.7964884,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20662 return void","depth":28,"bounds":{"left":0.030585106,"top":0.8132482,"width":0.043218084,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.075465426,"top":0.81404626,"width":0.0076462766,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20662 Removed word_boost parameter from Assembly transcription request","depth":28,"bounds":{"left":0.030585106,"top":0.8308061,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.109707445,"top":0.8316041,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20817: Fix deleting old tracks","depth":28,"bounds":{"left":0.030585106,"top":0.84836394,"width":0.06582447,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"mihail.mihaylov","depth":28,"bounds":{"left":0.098071806,"top":0.84836394,"width":0.010305851,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es","depth":28,"bounds":{"left":0.030585106,"top":0.8659218,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"bounds":{"left":0.109707445,"top":0.8667199,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge branch 'master' into JY-19938-bot-status-in-csv-es","depth":28,"bounds":{"left":0.030585106,"top":0.88347965,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"bounds":{"left":0.109707445,"top":0.88427776,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update","depth":28,"bounds":{"left":0.030585106,"top":0.9010375,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian-jiminny","depth":28,"bounds":{"left":0.109707445,"top":0.9018356,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 Added consent information and recording error reason to Elastic Search mapping","depth":28,"bounds":{"left":0.030585106,"top":0.9185954,"width":0.0787899,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.109707445,"top":0.9193935,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 reorder array map","depth":28,"bounds":{"left":0.030585106,"top":0.93615323,"width":0.056848403,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.089428194,"top":0.93695134,"width":0.00731383,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938: Address PR review comments","depth":28,"bounds":{"left":0.030585106,"top":0.9537111,"width":0.07912234,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.109707445,"top":0.9545092,"width":0.0003324468,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-19938 lint fixes","depth":28,"bounds":{"left":0.030585106,"top":0.97126895,"width":0.03756649,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ilian","depth":28,"bounds":{"left":0.06981383,"top":0.97206706,"width":0.0076462766,"height":0.0103751},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1","depth":28,"on_screen":false,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1","depth":28,"bounds":{"left":0.11569149,"top":0.047885075,"width":0.022606382,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Client.php (Working Tree) (Client.php), Editor Group 1","depth":28,"bounds":{"left":0.13796543,"top":0.047885075,"width":0.08211436,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1","depth":28,"bounds":{"left":0.21974733,"top":0.047885075,"width":0.111369684,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"MatchActivityCrmData.php, Editor Group 1","depth":28,"bounds":{"left":0.3307846,"top":0.047885075,"width":0.080119684,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1","depth":28,"bounds":{"left":0.4105718,"top":0.047885075,"width":0.1043883,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Client.php, preview, Editor Group 1","depth":28,"bounds":{"left":0.5149601,"top":0.047885075,"width":0.05119681,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.12832446,"top":0.07821229,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.15093085,"top":0.07821229,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.16489361,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.1875,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.22074468,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.22606383,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.24401596,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":29,"bounds":{"left":0.24933511,"top":0.07821229,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"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 {","depth":28,"bounds":{"left":0.13996011,"top":0.53790903,"width":0.43783244,"height":0.014365523},"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 {","role_description":"editor","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","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 {","depth":29,"bounds":{"left":0.13996011,"top":0.53790903,"width":0.2762633,"height":0.014365523},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Review rate limit handli…, Editor Group 2","depth":28,"bounds":{"left":0.61768615,"top":0.047885075,"width":0.07014628,"height":0.02793296},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXButton","text":"remote","depth":16,"bounds":{"left":0.0006648936,"top":0.98244214,"width":0.010638298,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...","depth":16,"bounds":{"left":0.012965426,"top":0.98244214,"width":0.084773935,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":0.013962766,"top":0.9848364,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"JY-20725-handle-HS-search-rate-limit*","depth":17,"bounds":{"left":0.019281914,"top":0.9856345,"width":0.0774601,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"app (Git) - Synchronize Changes","depth":16,"bounds":{"left":0.09740692,"top":0.98244214,"width":0.0076462766,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Errors: 6, Warnings: 11, Infos: 28","depth":16,"bounds":{"left":0.107380316,"top":0.98244214,"width":0.03756649,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":0.109042555,"top":0.9848364,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"6","depth":17,"bounds":{"left":0.1143617,"top":0.9856345,"width":0.004986702,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":0.11934841,"top":0.9848364,"width":0.005319149,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":17,"bounds":{"left":0.124667555,"top":0.9856345,"width":0.00731383,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":0.13164894,"top":0.9848364,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"28","depth":17,"bounds":{"left":0.13696809,"top":0.9856345,"width":0.0063164895,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Notifications","depth":16,"bounds":{"left":0.9886968,"top":0.98244214,"width":0.010638298,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"key, PHP extension: Premium features not active.","depth":16,"bounds":{"left":0.9790558,"top":0.98244214,"width":0.008643617,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"8.3, Language Level: 8.3","depth":16,"bounds":{"left":0.9674202,"top":0.98244214,"width":0.009640957,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sign In","depth":16,"bounds":{"left":0.9428192,"top":0.98244214,"width":0.022938829,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":0.9444814,"top":0.9848364,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Sign In","depth":17,"bounds":{"left":0.94980055,"top":0.9856345,"width":0.014295213,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"PHP","depth":16,"bounds":{"left":0.93018615,"top":0.98244214,"width":0.010305851,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Editor Language Status: $(copilot) No inline suggestion available, Inline suggestions","depth":16,"bounds":{"left":0.92287236,"top":0.98244214,"width":0.0076462766,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"LF","depth":16,"bounds":{"left":0.9125665,"top":0.98244214,"width":0.007978723,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UTF-8","depth":16,"bounds":{"left":0.8949468,"top":0.98244214,"width":0.015625,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Spaces: 4","depth":16,"bounds":{"left":0.87101066,"top":0.98244214,"width":0.022273935,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Ln 320, Col 30","depth":16,"bounds":{"left":0.83776593,"top":0.98244214,"width":0.03158245,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"git-commit Lukas Kovalik (5 months ago)","depth":16,"bounds":{"left":0.7706117,"top":0.98244214,"width":0.06549202,"height":0.01755786},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":17,"bounds":{"left":0.77227396,"top":0.9848364,"width":0.0056515955,"height":0.012769354},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik (5 months ago)","depth":17,"bounds":{"left":0.7775931,"top":0.9856345,"width":0.056848403,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Found 1 symbol in /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php","depth":12,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Clear","depth":12,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Review rate limit handling for HubSpot job queue","depth":19,"bounds":{"left":0.6200133,"top":0.08060654,"width":0.099734046,"height":0.022346368},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Session history","depth":19,"bounds":{"left":0.9780585,"top":0.08060654,"width":0.00930851,"height":0.022346368},"on_screen":true,"help_text":"Session history","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New session","depth":19,"bounds":{"left":0.9886968,"top":0.08060654,"width":0.00930851,"height":0.022346368},"on_screen":true,"help_text":"New session","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Message actions","depth":24,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"RateLimitException.php#9-9","depth":23,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"RateLimitException.php#9-9","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Reveiw the diff. Mostly","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Jobs/Crm/MatchActivityCrmData.php","depth":26,"on_screen":false,"help_text":"Open app/Jobs/Crm/MatchActivityCrmData.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Exceptions/RateLimitException.php","depth":26,"on_screen":false,"help_text":"Open app/Exceptions/RateLimitException.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Jobs/Middleware/HandleHubspotRateLimit.php","depth":26,"on_screen":false,"help_text":"Open app/Jobs/Middleware/HandleHubspotRateLimit.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":",","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Services/Crm/Hubspot/Client.php","depth":26,"on_screen":false,"help_text":"Open app/Services/Crm/Hubspot/Client.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"and","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php","depth":26,"on_screen":false,"help_text":"Open app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":". The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Thought for 5s","depth":23,"on_screen":false,"role_description":"disclosure triangle","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thought for 5s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"I'll review the implementation carefully. Let me first check how","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is used elsewhere and look at the test files and","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PaginationState","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all usages of RateLimitException outside the changed files","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\" /Users/lukas/jiminny/app/app --include=\"*.php\" | grep -v \"HandleHubspotRateLimit\\|MatchActivityCrmData\\|Client.php\\|RateLimitException.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |\n/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit\n/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"PaginationState.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"PaginationState.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"HandleHubspotRateLimitTest.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"HandleHubspotRateLimitTest.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check how RateLimitException is used in GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\\|LogicException\\|RuntimeException\" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all catch blocks or instanceof checks for RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"catch.*RateLimitException\\|instanceof RateLimitException\" /Users/lukas/jiminny/app/app --include=\"*.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls\n/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {\n/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find all test references to RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"RateLimitException\" /Users/lukas/jiminny/app/tests --include=\"*.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);\n/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\\Exceptions\\RateLimitException;\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);\n/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry/failure handling in GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"RateLimitException\\|retryUntil\\|backoff\\|maxExceptions\\|maxTries\\|release\\|failed\" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"13:use Jiminny\\Exceptions\\RateLimitException;\n51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception\n56: $this->release(30);","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry/failure handling in GenerateAiCallScoringJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"RateLimitException\\|retryUntil\\|backoff\\|maxExceptions\\|maxTries\\|release\\|failed\" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"17:use Jiminny\\Exceptions\\RateLimitException;\n68: RateLimitException |\n75: $this->release(30);","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check retry config in MatchActivityCrmData","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"retryAfter\\|RETRY_WINDOW\\|retryUntil\\|maxTries\" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"34: private const int RETRY_WINDOW_MINUTES = 30;\n45: public function retryUntil(): \\DateTimeInterface\n47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n77: return self::RETRY_WINDOW_MINUTES * 60 + 60;","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Check the cache key format","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -n \"getRateLimitCacheKey\\|hubspot:ratelimit\" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"78: $cacheKey = $this->getRateLimitCacheKey();\n110: private function getRateLimitCacheKey(): string\n112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Bash","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Find other jobs that might use the rate limit middleware","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IN","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"grep -rn \"class.*Middleware\\|HandleHubspotRateLimit\" /Users/lukas/jiminny/app/app/Jobs --include=\"*.php\" | grep -v \"HandleHubspotRateLimit.php\"","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":24,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"OUT","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\\Jobs\\Middleware\\HandleHubspotRateLimit;\n/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Read","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"HubspotClientInterface.php","depth":24,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"HubspotClientInterface.php","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Thought for 73s","depth":23,"on_screen":false,"role_description":"disclosure triangle","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thought for 73s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Now I have a complete picture. Here's my full analysis:","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Review Summary","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Review Summary","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"RuntimeException vs LogicException — Safe ✓","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"RuntimeException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"vs","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LogicException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"— Safe ✓","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The change is semantically correct and won't break anything.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LogicException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is for programmer errors (invalid arguments, contract violations). A rate limit from an external API is a runtime condition —","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RuntimeException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is the right parent. All callers catch","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"by name explicitly (union catches in","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateActionItemsJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateAiCallScoringJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SummarizeTranscriptionJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateAiActivityTypeJob","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":") or check","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"instanceof RateLimitException","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"— none relies on the parent class type, so nothing breaks.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 1 — Circuit breaker stores stale delay (most important bug)","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 1 — Circuit breaker stores stale delay (most important bug)","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"Client.php:94","depth":23,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Client.php:94","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Redis::setex($cacheKey, $retryAfter, (string) $retryAfter);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The TTL is","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"$retryAfter","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"seconds, but the","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"stored value","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is also the original","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"$retryAfter","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":". If a second job reads this cache 20 seconds later, it throws","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException(retryAfter=30)","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"and gets delayed 30 more seconds — even though only 10 remain. In a spike of 100 jobs, each subsequent job inflates its wait.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Fix:","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"store the absolute expiry timestamp, reconstruct the remaining delay when reading:","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"// on 429:\nRedis::setex($cacheKey, $retryAfter, (string) (time() + $retryAfter));\n\n// in the cached-read branch:\n$remaining = max(1, (int) $cachedRetryAfter - time());\nthrow new RateLimitException('Hubspot rate limit (cached circuit-breaker)', $remaining);","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 2 — retryUntil() window may expire under sustained rate limiting","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 2 —","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"retryUntil()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"window may expire under sustained rate limiting","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"MatchActivityCrmData.php:45-47","depth":23,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"MatchActivityCrmData.php:45-47","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"retryUntil()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is calculated as","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"now() + 30 min","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"at","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"dispatch time","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"and stored in the job payload. With","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MAX_RETRY_DELAY = 600s","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":", you only get ~3 rate-limit-triggered releases before the 30-minute window closes and the job is permanently failed. For a heavy 429 spike (daily limit, parsed as 600s), this can easily happen.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Consider increasing","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RETRY_WINDOW_MINUTES","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"to 60 or more, or making it configurable. The","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"uniqueFor()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"value should be updated to match.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 3 — Only search() uses the circuit breaker, but other methods can still hit 429","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 3 — Only","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"search()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"uses the circuit breaker, but other methods can still hit 429","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"Client.php:76-108","depth":23,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Client.php:76-108","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"executeRequest()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is only called from","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"search()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":". Methods like","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getOpportunityById()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"batchReadObjects()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getContactByEmail()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"bypass it entirely. If HubSpot's 429 is account-wide (not search-endpoint-specific), those methods will keep firing even while the circuit breaker is supposedly open. This is likely fine because the search endpoint has its own tighter quota (5 RPS), but it's worth being explicit in a comment.","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Issue 4 — object $job type in middleware has no compile-time safety","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Issue 4 —","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"object $job","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"type in middleware has no compile-time safety","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"HandleHubspotRateLimit.php:20","depth":23,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"HandleHubspotRateLimit.php:20","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy code to clipboard","depth":23,"on_screen":false,"help_text":"Copy code","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"public function handle(object $job, callable $next): void","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Calling","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"$job->attempts()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"and","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"$job->release()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"on","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"object","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"will work at runtime since Laravel always passes jobs that use","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"InteractsWithQueue","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":", but PHPStan/psalm won't verify this and it could silently break in tests with bare mocks (as your test does use a","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Job","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"mock that has","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"attempts()","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"— so the test is fine, but the real type contract is invisible). Minor, but worth tightening to","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"\\Illuminate\\Contracts\\Queue\\Job","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"What works well ✓","depth":22,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"What works well ✓","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is caught by the middleware","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"before","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"it increments","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"maxExceptions","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":", so rate-limit releases don't consume the 3-exception budget for real failures.","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The middleware's jitter (","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"random_int(0, 5)","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":") prevents thundering herd on release.","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The log-sampling logic (","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"attempts <= 3 || attempts % 10 === 0","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":") keeps logs useful without flooding.","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ShouldBeUnique","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"+","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"uniqueFor()","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"correctly prevents duplicate jobs queuing up during the rate-limit wait window.","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"propagates cleanly through the DB transaction (rolled back), then through the job catch block (logged conditionally), then to the middleware which handles it.","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The pagination service re-throws","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimitException","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"as-is and resets pagination state on the next job attempt — safe because offset restarts from 0.","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Message actions","depth":24,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-3987219873014532913
|
6764940850431264870
|
idle
|
accessibility
|
NULL
|
Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧ Explorer (⇧⌘E)
Search (⇧⌘F)
Source Control (⌃⇧G) - 17 pending changes
17
Run and Debug (⇧⌘D)
Remote Explorer
Extensions (⇧⌘X) - 2 require update
2
Testing
Claude Code
SOURCE CONTROL
SOURCE CONTROL
Changes Section
CHANGES
CHANGES
Commit Changes on "JY-20725-handle-HS-search-rate-limit"
Commit
More Actions...
Changes
17
.env.local
M
.env.nikilocal
U
.env.other
U
ids.txt
U
raw_sql_query.sql
U
WEBHOOK_FILTERING_IMPLEMENTATION.md
U
JiminnyDebugCommand.php
app/Console/Commands
M
SimulateWebhooksCommand.php
app/Console/Commands/Crm/Hubspot
U
CreateMockAskJiminnyReportResultCommand.php
app/Console/Commands/Reports
U
RateLimitException.php
app/Exceptions
M
MatchActivityCrmData.php
app/Jobs/Crm
M
Client.php
app/Services/Crm/Hubspot
9, M
logging.php
config
M
favicon.ico
public
U
HandleHubspotRateLimitTest.php
tests/Unit/Jobs/Middleware
M
CanAccessAiReportsTest.php
tests/Unit/Policies
U
ClientTest.php
tests/Unit/Services/Crm/Hubspot
M
Graph Section
GRAPH
GRAPH
JY-20725 add more tests
Lukas Kovalik
JY-20725-handle-HS-search-rate-limit
JY-20725 add tests
Lukas Kovalik
JY-20725 add HS rate limit handling on activities rematching
Lukas Kovalik
Merge pull request #12056 from jiminny/JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
origin/master
Merge branch 'master' into JY-20818-move-AJ-reports-to-separated-datadog-metric
Lukas Kovalik
Merge pull request #12057 from jiminny/JY-20819-increase-download-transctip-rate-limit
mihailmihaylovjiminny
JY-20819: Increase download transcription rate limit
mihail.mihaylov
JY-20818 move ask jiminny reports to its own datadog metric
Lukas Kovalik
Merge pull request #12052 from jiminny/JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge branch 'master' into JY-20817-fix-deleting-old-tracks
mihailmihaylovjiminny
Merge pull request #12047 from jiminny/JY-20662-remove-word-boost
ilian-jiminny
JY-20662 return void
ilian
JY-20662 Removed word_boost parameter from Assembly transcription request
ilian
JY-20817: Fix deleting old tracks
mihail.mihaylov
Merge pull request #12043 from jiminny/JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge branch 'master' into JY-19938-bot-status-in-csv-es
ilian-jiminny
Merge pull request #12044 from jiminny/JY-19938-ES-mapping-update
ilian-jiminny
JY-19938 Added consent information and recording error reason to Elastic Search mapping
ilian
JY-19938 reorder array map
ilian
JY-19938: Address PR review comments
ilian
JY-19938 lint fixes
ilian
HandleHubspotRateLimitTest.php (Working Tree) (HandleHubspotRateLimitTest.php), Editor Group 1
ClientTest.php (Working Tree) (ClientTest.php), Editor Group 1
Client.php (Working Tree) (Client.php), Editor Group 1
MatchActivityCrmData.php (Working Tree) (MatchActivityCrmData.php), Editor Group 1
MatchActivityCrmData.php, Editor Group 1
RateLimitException.php (Working Tree) (RateLimitException.php), Editor Group 1
Client.php, preview, Editor Group 1
<?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 {
<?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 {
Review rate limit handli…, Editor Group 2
remote
app (Git) - JY-20725-handle-HS-search-rate-limit*, Checkout Branch/Tag...
JY-20725-handle-HS-search-rate-limit*
app (Git) - Synchronize Changes
Errors: 6, Warnings: 11, Infos: 28
6
11
28
Notifications
key, PHP extension: Premium features not active.
8.3, Language Level: 8.3
Sign In
Sign In
PHP
Editor Language Status: $(copilot) No inline suggestion available, Inline suggestions
LF
UTF-8
Spaces: 4
Ln 320, Col 30
git-commit Lukas Kovalik (5 months ago)
Lukas Kovalik (5 months ago)
Found 1 symbol in /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Clear
Review rate limit handling for HubSpot job queue
Session history
New session
Message actions
RateLimitException.php#9-9
RateLimitException.php#9-9
Reveiw the diff. Mostly
@app/Jobs/Crm/MatchActivityCrmData.php
,
@app/Exceptions/RateLimitException.php
,
@app/Jobs/Middleware/HandleHubspotRateLimit.php
,
@app/Services/Crm/Hubspot/Client.php
and
@app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php
. The issue is I am trying to make sure that instant spike of MatchActivityCrmData jobs willl handle 429 exceptions from hubspot. Revewi ti carefully and try to see potential issues and bottlenecks. Also RateLimitException was changerd from LogicException to RuntimeException. Is it ok. This exception is used by other paerts of code. Will ti break anything?
Thought for 5s
Thought for 5s
I'll review the implementation carefully. Let me first check how
RateLimitException
is used elsewhere and look at the test files and
PaginationState
.
Bash
Find all usages of RateLimitException outside the changed files
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/app --include="*.php" | grep -v "HandleHubspotRateLimit\|MatchActivityCrmData\|Client.php\|RateLimitException.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:17:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php:68: RateLimitException |
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:15:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/TranscriptionSummary/Jobs/SummarizeTranscriptionJob.php:58: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:19:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/AiActivityType/Jobs/GenerateAiActivityTypeJob.php:76: RateLimitException |
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/HubspotClientInterface.php:50: * @throws RateLimitException When rate limit is hit
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:195: // RateLimitException and other exceptions are re-thrown as-is
Read
PaginationState.php
PaginationState.php
Read
HandleHubspotRateLimitTest.php
HandleHubspotRateLimitTest.php
Bash
Check how RateLimitException is used in GenerateActionItemsJob
IN
grep -rn "RateLimitException\|LogicException\|RuntimeException" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -20
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:13:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php:51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
Bash
Find all catch blocks or instanceof checks for RateLimitException
IN
grep -rn "catch.*RateLimitException\|instanceof RateLimitException" /Users/lukas/jiminny/app/app --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:11: * Job middleware that catches RateLimitException from HubSpot API calls
/Users/lukas/jiminny/app/app/Jobs/Middleware/HandleHubspotRateLimit.php:24: } catch (RateLimitException $e) {
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:149: if (! $e instanceof RateLimitException) {
Bash
Find all test references to RateLimitException
IN
grep -rn "RateLimitException" /Users/lukas/jiminny/app/tests --include="*.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:8:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:13:#[CoversClass(RateLimitException::class)]
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:14:class RateLimitExceptionTest extends TestCase
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:18: $exception = new RateLimitException();
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:28: $exception = new RateLimitException('Hubspot rate limit', 30, $previous);
/Users/lukas/jiminny/app/tests/Unit/Exceptions/RateLimitExceptionTest.php:52: $exception = new RateLimitException('test', $input);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:10:use Jiminny\Exceptions\RateLimitException;
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:49: public function testPropagatesNonRateLimitExceptions(): void
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:103: throw new RateLimitException('rate limited', $retryAfter);
/Users/lukas/jiminny/app/tests/Unit/Jobs/Middleware/HandleHubspotRateLimitTest.php:150: throw new RateLimitException('rate limited', 1);
Bash
Check retry/failure handling in GenerateActionItemsJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/ActionItems/Jobs/GenerateActionItemsJob.php | head -30
Copy code to clipboard
OUT
13:use Jiminny\Exceptions\RateLimitException;
51: GuzzleException|HttpBadRequestException|JsonException|RateLimitException|UnexpectedValueException $exception
56: $this->release(30);
Bash
Check retry/failure handling in GenerateAiCallScoringJob
IN
grep -n "RateLimitException\|retryUntil\|backoff\|maxExceptions\|maxTries\|release\|failed" /Users/lukas/jiminny/app/app/Component/AiCallScoring/Jobs/GenerateAiCallScoringJob.php | head -30
Copy code to clipboard
OUT
17:use Jiminny\Exceptions\RateLimitException;
68: RateLimitException |
75: $this->release(30);
Bash
Check retry config in MatchActivityCrmData
IN
grep -n "retryAfter\|RETRY_WINDOW\|retryUntil\|maxTries" /Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php
Copy code to clipboard
OUT
34: private const int RETRY_WINDOW_MINUTES = 30;
45: public function retryUntil(): \DateTimeInterface
47: return now()->addMinutes(self::RETRY_WINDOW_MINUTES);
77: return self::RETRY_WINDOW_MINUTES * 60 + 60;
Bash
Check the cache key format
IN
grep -n "getRateLimitCacheKey\|hubspot:ratelimit" /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php
Copy code to clipboard
OUT
78: $cacheKey = $this->getRateLimitCacheKey();
110: private function getRateLimitCacheKey(): string
112: return sprintf('hubspot:ratelimit:portal:%d', $this->config->getId());
Bash
Find other jobs that might use the rate limit middleware
IN
grep -rn "class.*Middleware\|HandleHubspotRateLimit" /Users/lukas/jiminny/app/app/Jobs --include="*.php" | grep -v "HandleHubspotRateLimit.php"
Copy code to clipboard
OUT
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:18:use Jiminny\Jobs\Middleware\HandleHubspotRateLimit;
/Users/lukas/jiminny/app/app/Jobs/Crm/MatchActivityCrmData.php:42: return [new HandleHubspotRateLimit()];
Read
HubspotClientInterface.php
HubspotClientInterface.php
Thought for 73s
Thought for 73s
Now I have a complete picture. Here's my full analysis:
Review Summary
Review Summary
RuntimeException vs LogicException — Safe ✓
RuntimeException
vs
LogicException
— Safe ✓
The change is semantically correct and won't break anything.
LogicException
is for programmer errors (invalid arguments, contract violations). A rate limit from an external API is a runtime condition —
RuntimeException
is the right parent. All callers catch
RateLimitException
by name explicitly (union catches in
GenerateActionItemsJob
,
GenerateAiCallScoringJob
,
SummarizeTranscriptionJob
,
GenerateAiActivityTypeJob
) or check
instanceof RateLimitException
— none relies on the parent class type, so nothing breaks.
Issue 1 — Circuit breaker stores stale delay (most important bug)
Issue 1 — Circuit breaker stores stale delay (most important bug)
Client.php:94
Client.php:94
Copy code to clipboard
Redis::setex($cacheKey, $retryAfter, (string) $retryAfter);
The TTL is
$retryAfter
seconds, but the
stored value
is also the original
$retryAfter
. If a second job reads this cache 20 seconds later, it throws
RateLimitException(retryAfter=30)
and gets delayed 30 more seconds — even though only 10 remain. In a spike of 100 jobs, each subsequent job inflates its wait.
Fix:
store the absolute expiry timestamp, reconstruct the remaining delay when reading:
Copy code to clipboard
// on 429:
Redis::setex($cacheKey, $retryAfter, (string) (time() + $retryAfter));
// in the cached-read branch:
$remaining = max(1, (int) $cachedRetryAfter - time());
throw new RateLimitException('Hubspot rate limit (cached circuit-breaker)', $remaining);
Issue 2 — retryUntil() window may expire under sustained rate limiting
Issue 2 —
retryUntil()
window may expire under sustained rate limiting
MatchActivityCrmData.php:45-47
MatchActivityCrmData.php:45-47
retryUntil()
is calculated as
now() + 30 min
at
dispatch time
and stored in the job payload. With
MAX_RETRY_DELAY = 600s
, you only get ~3 rate-limit-triggered releases before the 30-minute window closes and the job is permanently failed. For a heavy 429 spike (daily limit, parsed as 600s), this can easily happen.
Consider increasing
RETRY_WINDOW_MINUTES
to 60 or more, or making it configurable. The
uniqueFor()
value should be updated to match.
Issue 3 — Only search() uses the circuit breaker, but other methods can still hit 429
Issue 3 — Only
search()
uses the circuit breaker, but other methods can still hit 429
Client.php:76-108
Client.php:76-108
executeRequest()
is only called from
search()
. Methods like
getOpportunityById()
,
batchReadObjects()
,
getContactByEmail()
bypass it entirely. If HubSpot's 429 is account-wide (not search-endpoint-specific), those methods will keep firing even while the circuit breaker is supposedly open. This is likely fine because the search endpoint has its own tighter quota (5 RPS), but it's worth being explicit in a comment.
Issue 4 — object $job type in middleware has no compile-time safety
Issue 4 —
object $job
type in middleware has no compile-time safety
HandleHubspotRateLimit.php:20
HandleHubspotRateLimit.php:20
Copy code to clipboard
public function handle(object $job, callable $next): void
Calling
$job->attempts()
and
$job->release()
on
object
will work at runtime since Laravel always passes jobs that use
InteractsWithQueue
, but PHPStan/psalm won't verify this and it could silently break in tests with bare mocks (as your test does use a
Job
mock that has
attempts()
— so the test is fine, but the real type contract is invisible). Minor, but worth tightening to
\Illuminate\Contracts\Queue\Job
.
What works well ✓
What works well ✓
RateLimitException
is caught by the middleware
before
it increme...
|
20436
|
/Users/lukas/jiminny/app/app/Services/Crm/Hubspot/ /Users/lukas/jiminny/app/app/Services/Crm/Hubspot/Client.php...
|
NULL
|
NULL
|
|
20470
|
NULL
|
0
|
2026-05-11T15:34:13.730591+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778513653730_m2.jpg...
|
iTerm2
|
APP (docker)
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Last login: Thu May 7 09:44:56 on ttys007
Poetry Last login: Thu May 7 09:44:56 on ttys007
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status
On branch master
Your branch is up to date with 'origin/master'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: .env.local
modified: app/Console/Commands/JiminnyDebugCommand.php
modified: app/Jobs/Team/SyncToIntercom.php
modified: app/Services/PlaybackService.php
modified: config/logging.php
modified: resources/views/partials/crm/push-summary/html-assembly.blade.php
Untracked files:
(use "git add <file>..." to include in what will be committed)
.env.nikilocal
.env.other
WEBHOOK_FILTERING_IMPLEMENTATION.md
app/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php
app/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php
ids.txt
public/favicon.ico
raw_sql_query.sql
tests/Unit/Policies/CanAccessAiReportsTest.php
no changes added to commit (use "git add" and/or "git commit -a")
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 1482, done.
remote: Counting objects: 100% (481/481), done.
remote: Compressing objects: 100% (191/191), done.
remote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)
Receiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.
Resolving deltas: 100% (877/877), completed with 96 local objects.
From github.com:jiminny/app
83b628967a..ad2ce76737 master -> origin/master
1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests
b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
* [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost
* [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc
* [new branch] make-claude-great-again -> origin/make-claude-great-again
* [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507
* [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
Fast-forward
.cursor/rules/frontend-conventions.mdc | 23 ++
.env.production-eu | 2 +-
.env.staging | 2 +-
Makefile | 10 +
app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-
app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-
app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--
app/Component/AskAnything/AskAnythingPromptService.php | 3 +
app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-
app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-
app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-
app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---
app/Component/Twilio/TwilioRepository.php | 27 ++
app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----
app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--
app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++
app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----
app/Console/Commands/Users/SyncToIntercom.php | 4 +-
app/Console/Kernel.php | 3 +-
app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -
app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -
app/Contracts/Repositories/TeamRepository.php | 3 +-
app/Events/Activities/ActivityUpdated.php | 10 +-
app/Events/Activities/Audio/RecordingEvent.php | 6 +-
app/Events/Activities/Softphone/Ended.php | 8 +-
app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-
app/Events/Activities/Softphone/Started.php | 8 +-
app/Http/Controllers/API/ActivityController.php | 17 +-
app/Http/Controllers/API/SoftphoneController.php | 9 +-
app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-
app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-
app/Http/Controllers/Auth/SocialController.php | 6 +-
app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-
app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-
app/Http/Controllers/Kiosk/PartnersController.php | 46 +++
app/Http/Controllers/Kiosk/SearchController.php | 8 +
app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-
app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-
app/Http/Controllers/TeamSetupController.php | 4 +-
app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-
app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-
app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +
app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +
app/Http/Transformers/ActivityTransformer.php | 4 +-
app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-
app/Http/Transformers/PartnerTransformer.php | 1 +
app/Http/Transformers/StageTransformer.php | 6 +-
app/Http/Transformers/UserTransformer.php | 11 +-
app/Interactions/Settings/Teams/CreateTeam.php | 3 +
app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-
app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++
app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++
app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-
app/Jobs/Crm/UpdateStage.php | 3 +
app/Jobs/Team/SyncToIntercom.php | 7 +-
app/Listeners/Teams/SyncIntercomCompany.php | 5 +-
app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-
app/Listeners/Users/SyncIntercom.php | 5 +-
app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++
app/Mail/Reports/ReportNotGenerated.php | 41 +++
app/Models/Activity.php | 25 +-
app/Models/Activity/Question.php | 14 +-
app/Models/Activity/Search.php | 7 +
app/Models/AskAnything/AskAnythingPrompt.php | 6 +
app/Models/AutomatedReport.php | 10 +
app/Models/CoachingFeedback.php | 44 ++-
app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----
app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----
app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --
app/Models/Partner.php | 13 +
app/Models/Playlist/Activity.php | 14 +-
app/Notifications/OwnerInvitedToTrial.php | 14 +-
app/Policies/UserPolicy.php | 16 +-
app/Queue/Worker/Worker.php | 3 +-
app/Repositories/ActivityRepository.php | 13 +-
app/Repositories/AutomatedReportsRepository.php | 42 ++-
app/Repositories/TeamRepository.php | 21 +-
app/Repositories/UserRepository.php | 2 +-
app/Services/Activity/MeetingBotService.php | 8 +-
app/Services/ActivityService.php | 111 ++-----
app/Services/Crm/Hubspot/Service.php | 36 +-
app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-
app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--
app/Services/Kiosk/KioskService.php | 7 +-
app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-
app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++
app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++
app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++
app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----
composer.json | 1 -
composer.lock | 95 +-----
config/secure-headers.php | 5 +-
database/mappings/mapping_activities.json | 16 +
database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++
database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++
database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++
database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++
front-end/package.json | 5 +-
front-end/src/__mocks__/jiminny.js | 4 +-
front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +
front-end/src/__mocks__/setup.js | 1 +
front-end/src/apps/ai-reports-promo.js | 22 ++
front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++
front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++
front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++
front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++
front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++
.../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++
front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-
front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++
front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++
.../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++
front-end/src/components/AiReports/constants.js | 7 +
front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +
front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++
front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +
front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++
front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-
front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-
front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-
front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-
front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++
front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-
front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++
front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++
front-end/src/main.js | 1 +
front-end/src/store/modules/TeamInsights/util.js | 1 +
front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++
front-end/src/store/modules/platform/getters.js | 3 +
front-end/src/utils/index.js | 11 +
front-end/yarn.lock | 21 +-
phpstan-baseline.neon | 60 ----
public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes
public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes
public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes
public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes
public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes
public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes
public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes
public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes
resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++
resources/views/emails/reports/report-not-generated.blade.php | 24 ++
resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-
routes/api.php | 6 +
routes/web.php | 4 +
tests/Feature/Policies/UserPolicyTest.php | 90 ++++-
tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++
tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++
tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++
tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++
tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--
tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-
tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++
tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++
tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++
tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++
tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++
tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-
tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++
tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++
tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-
tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++
tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++
tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-
tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +
tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++
tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-
tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++
tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++
tests/Unit/Models/PartnerTest.php | 28 ++
tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++
tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-
tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++
tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--
tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++
tests/Unit/Services/KioskServiceTest.php | 8 +
tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-
tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++
tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++
tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----
186 files changed, 8538 insertions(+), 1233 deletions(-)
create mode 100644 app/Component/Twilio/TwilioRepository.php
delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php
create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php
delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php
create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php
create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php
create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php
create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php
create mode 100644 app/Mail/Reports/ReportNotGenerated.php
delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php
create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php
create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php
create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php
create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php
create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php
create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php
create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php
create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js
create mode 100644 front-end/src/apps/ai-reports-promo.js
create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html
create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js
create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js
create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js
create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/com/loss-report.pdf
create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf
create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf
create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf
create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php
create mode 100644 resources/views/emails/reports/report-not-generated.blade.php
create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php
create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php
create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php
create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php
create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php
create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php
create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php
create mode 100644 tests/Unit/Models/PartnerTest.php
create mode 100644 tests/Unit/Services/ActivityServiceTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 628, done.
remote: Counting objects: 100% (331/331), done.
remote: Compressing objects: 100% (63/63), done.
remote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)
Receiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.
Resolving deltas: 100% (391/391), completed with 57 local objects.
From github.com:jiminny/app
ad2ce76737..12295204cf master -> origin/master
14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests
f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech
+ ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)
* [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication
* [new branch] desktop-app -> origin/desktop-app
4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again
* [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas
Updating ad2ce76737..12295204cf
Fast-forward
app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-
app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------
app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------
tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------
tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -
11 files changed, 49 insertions(+), 90 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric
Switched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 15, done.
remote: Counting objects: 100% (15/15), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)
Unpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.
From github.com:jiminny/app
c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit
Already up to date.
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 180, done.
remote: Counting objects: 100% (179/179), done.
remote: Compressing objects: 100% (55/55), done.
remote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)
Receiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.
Resolving deltas: 100% (127/127), completed with 47 local objects.
From github.com:jiminny/app
12295204cf..35f036ace6 master -> origin/master
* [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5
5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories
* [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration
+ ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)
8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas
Updating 12295204cf..35f036ace6
Fast-forward
app/Component/Datadog/Constants.php | 1 +
app/Providers/RouteServiceProvider.php | 2 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------
4 files changed, 62 insertions(+), 8 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr
* master
JY-20818-move-AJ-reports-to-separated-datadog-metric
JY-20773-fix-automated-reports-user-pilot-tracking
JY-20157-AJ-report-not-send-notification
JY-20508-notify-before-AJ-report-expiration
JY-20372-ai-reports-promotion-pages
JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
JY-20738-debug-AJ-tracking-UP
a
JY-18909-automated-reports-ask-jiminny
JY-20692-fix-integration-app-[API_KEY]
JY-20553-debug-crm-sync-delays
JY-20698-fix-SF-activity-types-on-new-playbook
JY-20543-AJ-report-tracking
JY-20384-handle-auto-sync-with-no-access-to-event-type
JY-20458-ask-jiminny-user-definitions
JY-19666-fix-import-contacts-account-association
JY-19666-HS-import-contacts-and-accounts-batch-job
JY-20458-Ask-Jiminny-Reports
JY-20200-batch-update-CRM-objects-Salesforce
JY-19666-HS-webhooks-add-contact-and-company
JY-20348-trigger-setup-DI-layout-on-team-creation
JY-20326-refactor-info-message-in-command
JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled
JY-20312-remove-on-update-change-last-synced-at-crm-configurations
JY-20306-SF-skip-auto-sync-for-task-based-playbook
JY-20192-remove-deleted-team-from-saved-search-filters
JY-20197-import-opportunity-batch-job
JY-20293-enable-status-field-for-pipedrive-deals
JY-20191-remove-commands-interactive-prompts
JY-20118-change-default-sync-strategy
JY-20183-add-cache-on-auto-log-delay
JY-20197-add-import-opportunity-batch-job
20118-hs-opportunity-make-webhook-strategy-default
JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based
JY-20196-handle-opportunity-without-note
JY-20118-improve-opportunity-import
JY-20189-handle-activity-search-on-deleted-groups
JY-20160
JY-20145-filter-out-converted-leads-when-matching
JY-20150-skip-push-summary-on-summary-ready-if-autolog
JY-20132-fix-note-encoding
JY-19792-clean-logs
JY-20117-fix-sync-profile-fields-on-empty-fields
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit
Switched to a new branch 'JY-20725-handle-HS-search-rate-limit'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd
docker exec -it docker_lamp_1 bash -c "mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini"
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 supervisorctl restart all
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped
jiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped
jiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped
jiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped
jiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped
worker-analytics:worker-analytics_00: stopped
worker-crm-update:worker-crm-update_00: stopped
worker-download:worker-download_00: stopped
worker-nudges:worker-nudges_00: stopped
artisan-schedule:artisan-schedule_00: stopped
worker-emails:worker-emails_00: stopped
worker:worker_00: stopped
worker-calendar:worker-calendar_00: stopped
jiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped
worker-crm-sync:worker-crm-sync_00: stopped
worker-audio:worker-audio_00: stopped
worker-conferences:worker-conferences_00: stopped
worker-es-update:worker-es-update_00: stopped
artisan-schedule:artisan-schedule_00: started
jiminny-worker-processing-1:jiminny-worker-processing-1_00: started
jiminny-worker-processing-2:jiminny-worker-processing-2_00: started
jiminny-worker-processing-3:jiminny-worker-processing-3_00: started
jiminny-worker-processing-4:jiminny-worker-processing-4_00: started
jiminny-worker-processing-5:jiminny-worker-processing-5_00: started
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started
worker:worker_00: started
worker-analytics:worker-analytics_00: started
worker-audio:worker-audio_00: started
worker-calendar:worker-calendar_00: started
worker-conferences:worker-conferences_00: started
worker-crm-sync:worker-crm-sync_00: started
worker-crm-update:worker-crm-update_00: started
worker-download:worker-download_00: started
worker-emails:worker-emails_00: started
worker-es-update:worker-es-update_00: started
worker-nudges:worker-nudges_00: started
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 php -v
PHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)
Copyright (c) The PHP Group
Zend En...
|
[{"role":"AXTextArea","text [{"role":"AXTextArea","text":"Last login: Thu May 7 09:44:56 on ttys007\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Console/Commands/JiminnyDebugCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Services/PlaybackService.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: config/logging.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: resources/views/partials/crm/push-summary/html-assembly.blade.php\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 1482, done.\nremote: Counting objects: 100% (481/481), done.\nremote: Compressing objects: 100% (191/191), done.\nremote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)\nReceiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.\nResolving deltas: 100% (877/877), completed with 96 local objects.\nFrom github.com:jiminny/app\n 83b628967a..ad2ce76737 master -> origin/master\n 1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n 5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests\n b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n * [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost\n * [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc\n * [new branch] make-claude-great-again -> origin/make-claude-great-again\n * [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507\n * [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nFast-forward\n .cursor/rules/frontend-conventions.mdc | 23 ++\n .env.production-eu | 2 +-\n .env.staging | 2 +-\n Makefile | 10 +\n app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-\n app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-\n app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--\n app/Component/AskAnything/AskAnythingPromptService.php | 3 +\n app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-\n app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-\n app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-\n app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---\n app/Component/Twilio/TwilioRepository.php | 27 ++\n app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----\n app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--\n app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++\n app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----\n app/Console/Commands/Users/SyncToIntercom.php | 4 +-\n app/Console/Kernel.php | 3 +-\n app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -\n app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -\n app/Contracts/Repositories/TeamRepository.php | 3 +-\n app/Events/Activities/ActivityUpdated.php | 10 +-\n app/Events/Activities/Audio/RecordingEvent.php | 6 +-\n app/Events/Activities/Softphone/Ended.php | 8 +-\n app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-\n app/Events/Activities/Softphone/Started.php | 8 +-\n app/Http/Controllers/API/ActivityController.php | 17 +-\n app/Http/Controllers/API/SoftphoneController.php | 9 +-\n app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-\n app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-\n app/Http/Controllers/Auth/SocialController.php | 6 +-\n app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-\n app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-\n app/Http/Controllers/Kiosk/PartnersController.php | 46 +++\n app/Http/Controllers/Kiosk/SearchController.php | 8 +\n app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-\n app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-\n app/Http/Controllers/TeamSetupController.php | 4 +-\n app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-\n app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-\n app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +\n app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +\n app/Http/Transformers/ActivityTransformer.php | 4 +-\n app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-\n app/Http/Transformers/PartnerTransformer.php | 1 +\n app/Http/Transformers/StageTransformer.php | 6 +-\n app/Http/Transformers/UserTransformer.php | 11 +-\n app/Interactions/Settings/Teams/CreateTeam.php | 3 +\n app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-\n app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++\n app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++\n app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-\n app/Jobs/Crm/UpdateStage.php | 3 +\n app/Jobs/Team/SyncToIntercom.php | 7 +-\n app/Listeners/Teams/SyncIntercomCompany.php | 5 +-\n app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-\n app/Listeners/Users/SyncIntercom.php | 5 +-\n app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++\n app/Mail/Reports/ReportNotGenerated.php | 41 +++\n app/Models/Activity.php | 25 +-\n app/Models/Activity/Question.php | 14 +-\n app/Models/Activity/Search.php | 7 +\n app/Models/AskAnything/AskAnythingPrompt.php | 6 +\n app/Models/AutomatedReport.php | 10 +\n app/Models/CoachingFeedback.php | 44 ++-\n app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----\n app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----\n app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --\n app/Models/Partner.php | 13 +\n app/Models/Playlist/Activity.php | 14 +-\n app/Notifications/OwnerInvitedToTrial.php | 14 +-\n app/Policies/UserPolicy.php | 16 +-\n app/Queue/Worker/Worker.php | 3 +-\n app/Repositories/ActivityRepository.php | 13 +-\n app/Repositories/AutomatedReportsRepository.php | 42 ++-\n app/Repositories/TeamRepository.php | 21 +-\n app/Repositories/UserRepository.php | 2 +-\n app/Services/Activity/MeetingBotService.php | 8 +-\n app/Services/ActivityService.php | 111 ++-----\n app/Services/Crm/Hubspot/Service.php | 36 +-\n app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--\n app/Services/Kiosk/KioskService.php | 7 +-\n app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-\n app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++\n app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++\n app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++\n app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----\n composer.json | 1 -\n composer.lock | 95 +-----\n config/secure-headers.php | 5 +-\n database/mappings/mapping_activities.json | 16 +\n database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++\n database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++\n database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++\n database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++\n front-end/package.json | 5 +-\n front-end/src/__mocks__/jiminny.js | 4 +-\n front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +\n front-end/src/__mocks__/setup.js | 1 +\n front-end/src/apps/ai-reports-promo.js | 22 ++\n front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++\n front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++\n .../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++\n front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-\n front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++\n front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++\n .../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++\n front-end/src/components/AiReports/constants.js | 7 +\n front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +\n front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++\n front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +\n front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++\n front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-\n front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-\n front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-\n front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-\n front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++\n front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-\n front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++\n front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++\n front-end/src/main.js | 1 +\n front-end/src/store/modules/TeamInsights/util.js | 1 +\n front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++\n front-end/src/store/modules/platform/getters.js | 3 +\n front-end/src/utils/index.js | 11 +\n front-end/yarn.lock | 21 +-\n phpstan-baseline.neon | 60 ----\n public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes\n public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes\n public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes\n public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes\n public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes\n public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes\n public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes\n public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes\n resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++\n resources/views/emails/reports/report-not-generated.blade.php | 24 ++\n resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-\n routes/api.php | 6 +\n routes/web.php | 4 +\n tests/Feature/Policies/UserPolicyTest.php | 90 ++++-\n tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++\n tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++\n tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++\n tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++\n tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--\n tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-\n tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++\n tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++\n tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++\n tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++\n tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++\n tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-\n tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++\n tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++\n tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-\n tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++\n tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++\n tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-\n tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +\n tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++\n tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-\n tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++\n tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++\n tests/Unit/Models/PartnerTest.php | 28 ++\n tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++\n tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-\n tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++\n tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--\n tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++\n tests/Unit/Services/KioskServiceTest.php | 8 +\n tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-\n tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++\n tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++\n tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----\n 186 files changed, 8538 insertions(+), 1233 deletions(-)\n create mode 100644 app/Component/Twilio/TwilioRepository.php\n delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php\n create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php\n delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php\n create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php\n create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php\n create mode 100644 app/Mail/Reports/ReportNotGenerated.php\n delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php\n create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php\n create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php\n create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php\n create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php\n create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php\n create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php\n create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php\n create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js\n create mode 100644 front-end/src/apps/ai-reports-promo.js\n create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html\n create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js\n create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js\n create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js\n create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/com/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf\n create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf\n create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php\n create mode 100644 resources/views/emails/reports/report-not-generated.blade.php\n create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php\n create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php\n create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php\n create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php\n create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php\n create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php\n create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php\n create mode 100644 tests/Unit/Models/PartnerTest.php\n create mode 100644 tests/Unit/Services/ActivityServiceTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 628, done.\nremote: Counting objects: 100% (331/331), done.\nremote: Compressing objects: 100% (63/63), done.\nremote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)\nReceiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.\nResolving deltas: 100% (391/391), completed with 57 local objects.\nFrom github.com:jiminny/app\n ad2ce76737..12295204cf master -> origin/master\n 14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests\n f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n 1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech\n + ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)\n * [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication\n * [new branch] desktop-app -> origin/desktop-app\n 4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again\n * [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating ad2ce76737..12295204cf\nFast-forward\n app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-\n app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------\n app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------\n tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------\n tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -\n 11 files changed, 49 insertions(+), 90 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric\nSwitched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-calendar:worker-calendar_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker:worker_00: stopped\nworker-emails:worker-emails_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 15, done.\nremote: Counting objects: 100% (15/15), done.\nremote: Compressing objects: 100% (2/2), done.\nremote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)\nUnpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.\nFrom github.com:jiminny/app\n c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit\nAlready up to date.\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 180, done.\nremote: Counting objects: 100% (179/179), done.\nremote: Compressing objects: 100% (55/55), done.\nremote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)\nReceiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.\nResolving deltas: 100% (127/127), completed with 47 local objects.\nFrom github.com:jiminny/app\n 12295204cf..35f036ace6 master -> origin/master\n * [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5\n 5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories\n * [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration\n + ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)\n 8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating 12295204cf..35f036ace6\nFast-forward\n app/Component/Datadog/Constants.php | 1 +\n app/Providers/RouteServiceProvider.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------\n 4 files changed, 62 insertions(+), 8 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr \n* master\n JY-20818-move-AJ-reports-to-separated-datadog-metric\n JY-20773-fix-automated-reports-user-pilot-tracking\n JY-20157-AJ-report-not-send-notification\n JY-20508-notify-before-AJ-report-expiration\n JY-20372-ai-reports-promotion-pages\n JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n JY-20738-debug-AJ-tracking-UP\n a\n JY-18909-automated-reports-ask-jiminny\n JY-20692-fix-integration-app-token-auth-response-change\n JY-20553-debug-crm-sync-delays\n JY-20698-fix-SF-activity-types-on-new-playbook\n JY-20543-AJ-report-tracking\n JY-20384-handle-auto-sync-with-no-access-to-event-type\n JY-20458-ask-jiminny-user-definitions\n JY-19666-fix-import-contacts-account-association\n JY-19666-HS-import-contacts-and-accounts-batch-job\n JY-20458-Ask-Jiminny-Reports\n JY-20200-batch-update-CRM-objects-Salesforce\n JY-19666-HS-webhooks-add-contact-and-company\n JY-20348-trigger-setup-DI-layout-on-team-creation\n JY-20326-refactor-info-message-in-command\n JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled\n JY-20312-remove-on-update-change-last-synced-at-crm-configurations\n JY-20306-SF-skip-auto-sync-for-task-based-playbook\n JY-20192-remove-deleted-team-from-saved-search-filters\n JY-20197-import-opportunity-batch-job\n JY-20293-enable-status-field-for-pipedrive-deals\n JY-20191-remove-commands-interactive-prompts\n JY-20118-change-default-sync-strategy\n JY-20183-add-cache-on-auto-log-delay\n JY-20197-add-import-opportunity-batch-job\n 20118-hs-opportunity-make-webhook-strategy-default\n JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based\n JY-20196-handle-opportunity-without-note\n JY-20118-improve-opportunity-import\n JY-20189-handle-activity-search-on-deleted-groups\n JY-20160\n JY-20145-filter-out-converted-leads-when-matching\n JY-20150-skip-push-summary-on-summary-ready-if-autolog\n JY-20132-fix-note-encoding\n JY-19792-clean-logs\n JY-20117-fix-sync-profile-fields-on-empty-fields\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit \nSwitched to a new branch 'JY-20725-handle-HS-search-rate-limit'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-emails:worker-emails_00: stopped\nworker:worker_00: stopped\nworker-calendar:worker-calendar_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n 1) app/Jobs/Crm/MatchActivityCrmData.php (ordered_class_elements)\n ---------- begin diff ----------\n--- /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n+++ /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n@@ -29,24 +29,14 @@\n use InteractsWithQueue;\n use SerializesModels;\n \n+ private const int RETRY_WINDOW_MINUTES = 120;\n+\n public int $maxExceptions = 3;\n \n- private const int RETRY_WINDOW_MINUTES = 120;\n-\n private int $activityId;\n private ?Configuration $fromConfiguration;\n private bool $remoteSearch;\n \n- public function middleware(): array\n- {\n- return [new HandleHubspotRateLimit()];\n- }\n-\n- public function retryUntil(): \\DateTimeInterface\n- {\n- return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n- }\n-\n public function __construct(\n int $activityId,\n ?Configuration $fromConfiguration = null,\n@@ -57,6 +47,16 @@\n $this->remoteSearch = $remoteSearch;\n \n $this->onQueue(Constants::QUEUE_ANALYTICS_LOW);\n+ }\n+\n+ public function middleware(): array\n+ {\n+ return [new HandleHubspotRateLimit()];\n+ }\n+\n+ public function retryUntil(): \\DateTimeInterface\n+ {\n+ return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n }\n \n public function uniqueId(): string\n\n ----------- end diff -----------\n\n 2) app/Services/Crm/Hubspot/HubspotClientInterface.php (phpdoc_order, phpdoc_separation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n@@ -46,9 +46,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 3) app/Services/Crm/Hubspot/Client.php (phpdoc_order, phpdoc_separation, blank_line_before_statement)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/Client.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/Client.php\n@@ -69,10 +69,12 @@\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- * @return T The result of the API call\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@@ -81,6 +83,7 @@\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@@ -232,9 +235,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 4) app/Console/Commands/JiminnyDebugCommand.php (statement_indentation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n+++ /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n@@ -359,11 +359,11 @@\n $crmService = $crmResolver->prepareCrmService();\n \n for ($i = 0 ; $i < 3; $i++) {\n-// if ($i % 25 === 0) {\n-// $this->info(\"Syncing opportunity {$i}\");\n- $this->info(\"Matching contact {$i}\");\n-// }\n-// $crmService->syncOpportunity('374720564');\n+ // if ($i % 25 === 0) {\n+ // $this->info(\"Syncing opportunity {$i}\");\n+ $this->info(\"Matching contact {$i}\");\n+ // }\n+ // $crmService->syncOpportunity('374720564');\n $crmService->matchByName('Robot');\n }\n }\n\n ----------- end diff -----------\n\n\nFixed 4 of 5666 files in 146.870 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix \ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5666 files in 106.192 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 4761/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░] 84%","depth":4,"on_screen":true,"value":"Last login: Thu May 7 09:44:56 on ttys007\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Console/Commands/JiminnyDebugCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Services/PlaybackService.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: config/logging.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: resources/views/partials/crm/push-summary/html-assembly.blade.php\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 1482, done.\nremote: Counting objects: 100% (481/481), done.\nremote: Compressing objects: 100% (191/191), done.\nremote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)\nReceiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.\nResolving deltas: 100% (877/877), completed with 96 local objects.\nFrom github.com:jiminny/app\n 83b628967a..ad2ce76737 master -> origin/master\n 1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n 5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests\n b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n * [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost\n * [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc\n * [new branch] make-claude-great-again -> origin/make-claude-great-again\n * [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507\n * [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nFast-forward\n .cursor/rules/frontend-conventions.mdc | 23 ++\n .env.production-eu | 2 +-\n .env.staging | 2 +-\n Makefile | 10 +\n app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-\n app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-\n app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--\n app/Component/AskAnything/AskAnythingPromptService.php | 3 +\n app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-\n app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-\n app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-\n app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---\n app/Component/Twilio/TwilioRepository.php | 27 ++\n app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----\n app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--\n app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++\n app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----\n app/Console/Commands/Users/SyncToIntercom.php | 4 +-\n app/Console/Kernel.php | 3 +-\n app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -\n app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -\n app/Contracts/Repositories/TeamRepository.php | 3 +-\n app/Events/Activities/ActivityUpdated.php | 10 +-\n app/Events/Activities/Audio/RecordingEvent.php | 6 +-\n app/Events/Activities/Softphone/Ended.php | 8 +-\n app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-\n app/Events/Activities/Softphone/Started.php | 8 +-\n app/Http/Controllers/API/ActivityController.php | 17 +-\n app/Http/Controllers/API/SoftphoneController.php | 9 +-\n app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-\n app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-\n app/Http/Controllers/Auth/SocialController.php | 6 +-\n app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-\n app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-\n app/Http/Controllers/Kiosk/PartnersController.php | 46 +++\n app/Http/Controllers/Kiosk/SearchController.php | 8 +\n app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-\n app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-\n app/Http/Controllers/TeamSetupController.php | 4 +-\n app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-\n app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-\n app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +\n app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +\n app/Http/Transformers/ActivityTransformer.php | 4 +-\n app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-\n app/Http/Transformers/PartnerTransformer.php | 1 +\n app/Http/Transformers/StageTransformer.php | 6 +-\n app/Http/Transformers/UserTransformer.php | 11 +-\n app/Interactions/Settings/Teams/CreateTeam.php | 3 +\n app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-\n app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++\n app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++\n app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-\n app/Jobs/Crm/UpdateStage.php | 3 +\n app/Jobs/Team/SyncToIntercom.php | 7 +-\n app/Listeners/Teams/SyncIntercomCompany.php | 5 +-\n app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-\n app/Listeners/Users/SyncIntercom.php | 5 +-\n app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++\n app/Mail/Reports/ReportNotGenerated.php | 41 +++\n app/Models/Activity.php | 25 +-\n app/Models/Activity/Question.php | 14 +-\n app/Models/Activity/Search.php | 7 +\n app/Models/AskAnything/AskAnythingPrompt.php | 6 +\n app/Models/AutomatedReport.php | 10 +\n app/Models/CoachingFeedback.php | 44 ++-\n app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----\n app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----\n app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --\n app/Models/Partner.php | 13 +\n app/Models/Playlist/Activity.php | 14 +-\n app/Notifications/OwnerInvitedToTrial.php | 14 +-\n app/Policies/UserPolicy.php | 16 +-\n app/Queue/Worker/Worker.php | 3 +-\n app/Repositories/ActivityRepository.php | 13 +-\n app/Repositories/AutomatedReportsRepository.php | 42 ++-\n app/Repositories/TeamRepository.php | 21 +-\n app/Repositories/UserRepository.php | 2 +-\n app/Services/Activity/MeetingBotService.php | 8 +-\n app/Services/ActivityService.php | 111 ++-----\n app/Services/Crm/Hubspot/Service.php | 36 +-\n app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--\n app/Services/Kiosk/KioskService.php | 7 +-\n app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-\n app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++\n app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++\n app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++\n app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----\n composer.json | 1 -\n composer.lock | 95 +-----\n config/secure-headers.php | 5 +-\n database/mappings/mapping_activities.json | 16 +\n database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++\n database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++\n database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++\n database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++\n front-end/package.json | 5 +-\n front-end/src/__mocks__/jiminny.js | 4 +-\n front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +\n front-end/src/__mocks__/setup.js | 1 +\n front-end/src/apps/ai-reports-promo.js | 22 ++\n front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++\n front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++\n .../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++\n front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-\n front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++\n front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++\n .../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++\n front-end/src/components/AiReports/constants.js | 7 +\n front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +\n front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++\n front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +\n front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++\n front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-\n front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-\n front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-\n front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-\n front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++\n front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-\n front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++\n front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++\n front-end/src/main.js | 1 +\n front-end/src/store/modules/TeamInsights/util.js | 1 +\n front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++\n front-end/src/store/modules/platform/getters.js | 3 +\n front-end/src/utils/index.js | 11 +\n front-end/yarn.lock | 21 +-\n phpstan-baseline.neon | 60 ----\n public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes\n public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes\n public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes\n public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes\n public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes\n public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes\n public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes\n public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes\n resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++\n resources/views/emails/reports/report-not-generated.blade.php | 24 ++\n resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-\n routes/api.php | 6 +\n routes/web.php | 4 +\n tests/Feature/Policies/UserPolicyTest.php | 90 ++++-\n tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++\n tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++\n tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++\n tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++\n tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--\n tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-\n tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++\n tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++\n tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++\n tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++\n tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++\n tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-\n tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++\n tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++\n tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-\n tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++\n tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++\n tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-\n tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +\n tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++\n tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-\n tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++\n tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++\n tests/Unit/Models/PartnerTest.php | 28 ++\n tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++\n tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-\n tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++\n tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--\n tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++\n tests/Unit/Services/KioskServiceTest.php | 8 +\n tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-\n tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++\n tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++\n tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----\n 186 files changed, 8538 insertions(+), 1233 deletions(-)\n create mode 100644 app/Component/Twilio/TwilioRepository.php\n delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php\n create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php\n delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php\n create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php\n create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php\n create mode 100644 app/Mail/Reports/ReportNotGenerated.php\n delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php\n create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php\n create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php\n create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php\n create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php\n create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php\n create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php\n create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php\n create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js\n create mode 100644 front-end/src/apps/ai-reports-promo.js\n create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html\n create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js\n create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js\n create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js\n create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/com/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf\n create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf\n create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php\n create mode 100644 resources/views/emails/reports/report-not-generated.blade.php\n create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php\n create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php\n create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php\n create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php\n create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php\n create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php\n create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php\n create mode 100644 tests/Unit/Models/PartnerTest.php\n create mode 100644 tests/Unit/Services/ActivityServiceTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 628, done.\nremote: Counting objects: 100% (331/331), done.\nremote: Compressing objects: 100% (63/63), done.\nremote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)\nReceiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.\nResolving deltas: 100% (391/391), completed with 57 local objects.\nFrom github.com:jiminny/app\n ad2ce76737..12295204cf master -> origin/master\n 14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests\n f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n 1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech\n + ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)\n * [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication\n * [new branch] desktop-app -> origin/desktop-app\n 4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again\n * [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating ad2ce76737..12295204cf\nFast-forward\n app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-\n app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------\n app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------\n tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------\n tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -\n 11 files changed, 49 insertions(+), 90 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric\nSwitched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-calendar:worker-calendar_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker:worker_00: stopped\nworker-emails:worker-emails_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 15, done.\nremote: Counting objects: 100% (15/15), done.\nremote: Compressing objects: 100% (2/2), done.\nremote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)\nUnpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.\nFrom github.com:jiminny/app\n c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit\nAlready up to date.\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 180, done.\nremote: Counting objects: 100% (179/179), done.\nremote: Compressing objects: 100% (55/55), done.\nremote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)\nReceiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.\nResolving deltas: 100% (127/127), completed with 47 local objects.\nFrom github.com:jiminny/app\n 12295204cf..35f036ace6 master -> origin/master\n * [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5\n 5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories\n * [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration\n + ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)\n 8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating 12295204cf..35f036ace6\nFast-forward\n app/Component/Datadog/Constants.php | 1 +\n app/Providers/RouteServiceProvider.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------\n 4 files changed, 62 insertions(+), 8 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr \n* master\n JY-20818-move-AJ-reports-to-separated-datadog-metric\n JY-20773-fix-automated-reports-user-pilot-tracking\n JY-20157-AJ-report-not-send-notification\n JY-20508-notify-before-AJ-report-expiration\n JY-20372-ai-reports-promotion-pages\n JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n JY-20738-debug-AJ-tracking-UP\n a\n JY-18909-automated-reports-ask-jiminny\n JY-20692-fix-integration-app-token-auth-response-change\n JY-20553-debug-crm-sync-delays\n JY-20698-fix-SF-activity-types-on-new-playbook\n JY-20543-AJ-report-tracking\n JY-20384-handle-auto-sync-with-no-access-to-event-type\n JY-20458-ask-jiminny-user-definitions\n JY-19666-fix-import-contacts-account-association\n JY-19666-HS-import-contacts-and-accounts-batch-job\n JY-20458-Ask-Jiminny-Reports\n JY-20200-batch-update-CRM-objects-Salesforce\n JY-19666-HS-webhooks-add-contact-and-company\n JY-20348-trigger-setup-DI-layout-on-team-creation\n JY-20326-refactor-info-message-in-command\n JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled\n JY-20312-remove-on-update-change-last-synced-at-crm-configurations\n JY-20306-SF-skip-auto-sync-for-task-based-playbook\n JY-20192-remove-deleted-team-from-saved-search-filters\n JY-20197-import-opportunity-batch-job\n JY-20293-enable-status-field-for-pipedrive-deals\n JY-20191-remove-commands-interactive-prompts\n JY-20118-change-default-sync-strategy\n JY-20183-add-cache-on-auto-log-delay\n JY-20197-add-import-opportunity-batch-job\n 20118-hs-opportunity-make-webhook-strategy-default\n JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based\n JY-20196-handle-opportunity-without-note\n JY-20118-improve-opportunity-import\n JY-20189-handle-activity-search-on-deleted-groups\n JY-20160\n JY-20145-filter-out-converted-leads-when-matching\n JY-20150-skip-push-summary-on-summary-ready-if-autolog\n JY-20132-fix-note-encoding\n JY-19792-clean-logs\n JY-20117-fix-sync-profile-fields-on-empty-fields\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit \nSwitched to a new branch 'JY-20725-handle-HS-search-rate-limit'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-emails:worker-emails_00: stopped\nworker:worker_00: stopped\nworker-calendar:worker-calendar_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n 1) app/Jobs/Crm/MatchActivityCrmData.php (ordered_class_elements)\n ---------- begin diff ----------\n--- /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n+++ /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n@@ -29,24 +29,14 @@\n use InteractsWithQueue;\n use SerializesModels;\n \n+ private const int RETRY_WINDOW_MINUTES = 120;\n+\n public int $maxExceptions = 3;\n \n- private const int RETRY_WINDOW_MINUTES = 120;\n-\n private int $activityId;\n private ?Configuration $fromConfiguration;\n private bool $remoteSearch;\n \n- public function middleware(): array\n- {\n- return [new HandleHubspotRateLimit()];\n- }\n-\n- public function retryUntil(): \\DateTimeInterface\n- {\n- return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n- }\n-\n public function __construct(\n int $activityId,\n ?Configuration $fromConfiguration = null,\n@@ -57,6 +47,16 @@\n $this->remoteSearch = $remoteSearch;\n \n $this->onQueue(Constants::QUEUE_ANALYTICS_LOW);\n+ }\n+\n+ public function middleware(): array\n+ {\n+ return [new HandleHubspotRateLimit()];\n+ }\n+\n+ public function retryUntil(): \\DateTimeInterface\n+ {\n+ return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n }\n \n public function uniqueId(): string\n\n ----------- end diff -----------\n\n 2) app/Services/Crm/Hubspot/HubspotClientInterface.php (phpdoc_order, phpdoc_separation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n@@ -46,9 +46,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 3) app/Services/Crm/Hubspot/Client.php (phpdoc_order, phpdoc_separation, blank_line_before_statement)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/Client.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/Client.php\n@@ -69,10 +69,12 @@\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- * @return T The result of the API call\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@@ -81,6 +83,7 @@\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@@ -232,9 +235,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 4) app/Console/Commands/JiminnyDebugCommand.php (statement_indentation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n+++ /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n@@ -359,11 +359,11 @@\n $crmService = $crmResolver->prepareCrmService();\n \n for ($i = 0 ; $i < 3; $i++) {\n-// if ($i % 25 === 0) {\n-// $this->info(\"Syncing opportunity {$i}\");\n- $this->info(\"Matching contact {$i}\");\n-// }\n-// $crmService->syncOpportunity('374720564');\n+ // if ($i % 25 === 0) {\n+ // $this->info(\"Syncing opportunity {$i}\");\n+ $this->info(\"Matching contact {$i}\");\n+ // }\n+ // $crmService->syncOpportunity('374720564');\n $crmService->matchByName('Robot');\n }\n }\n\n ----------- end diff -----------\n\n\nFixed 4 of 5666 files in 146.870 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix \ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5666 files in 106.192 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 4761/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░] 84%","is_focused":true},{"role":"AXRadioButton","text":"DOCKER","depth":2,"bounds":{"left":0.27027926,"top":1.0,"width":0.06732048,"height":-0.042298436},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.27227393,"top":1.0,"width":0.005319149,"height":-0.04549086},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"DEV (docker)","depth":2,"bounds":{"left":0.33759972,"top":1.0,"width":0.06732048,"height":-0.042298436},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.33959442,"top":1.0,"width":0.005319149,"height":-0.04549086},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"APP (docker)","depth":2,"bounds":{"left":0.40492022,"top":1.0,"width":0.06732048,"height":-0.042298436},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.4069149,"top":1.0,"width":0.005319149,"height":-0.04549086},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"-zsh","depth":2,"bounds":{"left":0.4722407,"top":1.0,"width":0.06732048,"height":-0.042298436},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.4742354,"top":1.0,"width":0.005319149,"height":-0.04549086},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"-zsh","depth":2,"bounds":{"left":0.53956115,"top":1.0,"width":0.06715426,"height":-0.042298436},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.5415558,"top":1.0,"width":0.005319149,"height":-0.04549086},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"screenpipe\"","depth":2,"bounds":{"left":0.60671544,"top":1.0,"width":0.06715426,"height":-0.042298436},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.6087101,"top":1.0,"width":0.005319149,"height":-0.04549086},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"-zsh","depth":2,"bounds":{"left":0.67386967,"top":1.0,"width":0.06715426,"height":-0.042298436},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.67586434,"top":1.0,"width":0.005319149,"height":-0.04549086},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"⌥⌘1","depth":1,"bounds":{"left":0.7273936,"top":1.0,"width":0.01861702,"height":-0.023144484},"on_screen":true,"automation_id":"_NS:8","role_description":"text"},{"role":"AXStaticText","text":"APP (docker)","depth":1,"bounds":{"left":0.49468085,"top":1.0,"width":0.029920213,"height":-0.02394259},"on_screen":true,"role_description":"text"}]...
|
-5826347064466025141
|
-2292063823433160584
|
idle
|
accessibility
|
NULL
|
Last login: Thu May 7 09:44:56 on ttys007
Poetry Last login: Thu May 7 09:44:56 on ttys007
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status
On branch master
Your branch is up to date with 'origin/master'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: .env.local
modified: app/Console/Commands/JiminnyDebugCommand.php
modified: app/Jobs/Team/SyncToIntercom.php
modified: app/Services/PlaybackService.php
modified: config/logging.php
modified: resources/views/partials/crm/push-summary/html-assembly.blade.php
Untracked files:
(use "git add <file>..." to include in what will be committed)
.env.nikilocal
.env.other
WEBHOOK_FILTERING_IMPLEMENTATION.md
app/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php
app/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php
ids.txt
public/favicon.ico
raw_sql_query.sql
tests/Unit/Policies/CanAccessAiReportsTest.php
no changes added to commit (use "git add" and/or "git commit -a")
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 1482, done.
remote: Counting objects: 100% (481/481), done.
remote: Compressing objects: 100% (191/191), done.
remote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)
Receiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.
Resolving deltas: 100% (877/877), completed with 96 local objects.
From github.com:jiminny/app
83b628967a..ad2ce76737 master -> origin/master
1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests
b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
* [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost
* [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc
* [new branch] make-claude-great-again -> origin/make-claude-great-again
* [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507
* [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
Fast-forward
.cursor/rules/frontend-conventions.mdc | 23 ++
.env.production-eu | 2 +-
.env.staging | 2 +-
Makefile | 10 +
app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-
app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-
app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--
app/Component/AskAnything/AskAnythingPromptService.php | 3 +
app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-
app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-
app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-
app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---
app/Component/Twilio/TwilioRepository.php | 27 ++
app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----
app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--
app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++
app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----
app/Console/Commands/Users/SyncToIntercom.php | 4 +-
app/Console/Kernel.php | 3 +-
app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -
app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -
app/Contracts/Repositories/TeamRepository.php | 3 +-
app/Events/Activities/ActivityUpdated.php | 10 +-
app/Events/Activities/Audio/RecordingEvent.php | 6 +-
app/Events/Activities/Softphone/Ended.php | 8 +-
app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-
app/Events/Activities/Softphone/Started.php | 8 +-
app/Http/Controllers/API/ActivityController.php | 17 +-
app/Http/Controllers/API/SoftphoneController.php | 9 +-
app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-
app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-
app/Http/Controllers/Auth/SocialController.php | 6 +-
app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-
app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-
app/Http/Controllers/Kiosk/PartnersController.php | 46 +++
app/Http/Controllers/Kiosk/SearchController.php | 8 +
app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-
app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-
app/Http/Controllers/TeamSetupController.php | 4 +-
app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-
app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-
app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +
app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +
app/Http/Transformers/ActivityTransformer.php | 4 +-
app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-
app/Http/Transformers/PartnerTransformer.php | 1 +
app/Http/Transformers/StageTransformer.php | 6 +-
app/Http/Transformers/UserTransformer.php | 11 +-
app/Interactions/Settings/Teams/CreateTeam.php | 3 +
app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-
app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++
app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++
app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-
app/Jobs/Crm/UpdateStage.php | 3 +
app/Jobs/Team/SyncToIntercom.php | 7 +-
app/Listeners/Teams/SyncIntercomCompany.php | 5 +-
app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-
app/Listeners/Users/SyncIntercom.php | 5 +-
app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++
app/Mail/Reports/ReportNotGenerated.php | 41 +++
app/Models/Activity.php | 25 +-
app/Models/Activity/Question.php | 14 +-
app/Models/Activity/Search.php | 7 +
app/Models/AskAnything/AskAnythingPrompt.php | 6 +
app/Models/AutomatedReport.php | 10 +
app/Models/CoachingFeedback.php | 44 ++-
app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----
app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----
app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --
app/Models/Partner.php | 13 +
app/Models/Playlist/Activity.php | 14 +-
app/Notifications/OwnerInvitedToTrial.php | 14 +-
app/Policies/UserPolicy.php | 16 +-
app/Queue/Worker/Worker.php | 3 +-
app/Repositories/ActivityRepository.php | 13 +-
app/Repositories/AutomatedReportsRepository.php | 42 ++-
app/Repositories/TeamRepository.php | 21 +-
app/Repositories/UserRepository.php | 2 +-
app/Services/Activity/MeetingBotService.php | 8 +-
app/Services/ActivityService.php | 111 ++-----
app/Services/Crm/Hubspot/Service.php | 36 +-
app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-
app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--
app/Services/Kiosk/KioskService.php | 7 +-
app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-
app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++
app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++
app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++
app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----
composer.json | 1 -
composer.lock | 95 +-----
config/secure-headers.php | 5 +-
database/mappings/mapping_activities.json | 16 +
database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++
database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++
database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++
database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++
front-end/package.json | 5 +-
front-end/src/__mocks__/jiminny.js | 4 +-
front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +
front-end/src/__mocks__/setup.js | 1 +
front-end/src/apps/ai-reports-promo.js | 22 ++
front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++
front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++
front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++
front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++
front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++
.../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++
front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-
front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++
front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++
.../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++
front-end/src/components/AiReports/constants.js | 7 +
front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +
front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++
front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +
front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++
front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-
front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-
front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-
front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-
front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++
front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-
front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++
front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++
front-end/src/main.js | 1 +
front-end/src/store/modules/TeamInsights/util.js | 1 +
front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++
front-end/src/store/modules/platform/getters.js | 3 +
front-end/src/utils/index.js | 11 +
front-end/yarn.lock | 21 +-
phpstan-baseline.neon | 60 ----
public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes
public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes
public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes
public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes
public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes
public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes
public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes
public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes
resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++
resources/views/emails/reports/report-not-generated.blade.php | 24 ++
resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-
routes/api.php | 6 +
routes/web.php | 4 +
tests/Feature/Policies/UserPolicyTest.php | 90 ++++-
tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++
tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++
tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++
tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++
tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--
tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-
tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++
tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++
tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++
tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++
tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++
tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-
tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++
tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++
tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-
tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++
tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++
tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-
tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +
tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++
tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-
tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++
tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++
tests/Unit/Models/PartnerTest.php | 28 ++
tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++
tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-
tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++
tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--
tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++
tests/Unit/Services/KioskServiceTest.php | 8 +
tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-
tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++
tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++
tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----
186 files changed, 8538 insertions(+), 1233 deletions(-)
create mode 100644 app/Component/Twilio/TwilioRepository.php
delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php
create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php
delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php
create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php
create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php
create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php
create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php
create mode 100644 app/Mail/Reports/ReportNotGenerated.php
delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php
create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php
create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php
create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php
create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php
create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php
create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php
create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php
create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js
create mode 100644 front-end/src/apps/ai-reports-promo.js
create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html
create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js
create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js
create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js
create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/com/loss-report.pdf
create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf
create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf
create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf
create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php
create mode 100644 resources/views/emails/reports/report-not-generated.blade.php
create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php
create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php
create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php
create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php
create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php
create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php
create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php
create mode 100644 tests/Unit/Models/PartnerTest.php
create mode 100644 tests/Unit/Services/ActivityServiceTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 628, done.
remote: Counting objects: 100% (331/331), done.
remote: Compressing objects: 100% (63/63), done.
remote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)
Receiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.
Resolving deltas: 100% (391/391), completed with 57 local objects.
From github.com:jiminny/app
ad2ce76737..12295204cf master -> origin/master
14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests
f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech
+ ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)
* [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication
* [new branch] desktop-app -> origin/desktop-app
4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again
* [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas
Updating ad2ce76737..12295204cf
Fast-forward
app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-
app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------
app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------
tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------
tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -
11 files changed, 49 insertions(+), 90 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric
Switched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 15, done.
remote: Counting objects: 100% (15/15), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)
Unpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.
From github.com:jiminny/app
c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit
Already up to date.
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 180, done.
remote: Counting objects: 100% (179/179), done.
remote: Compressing objects: 100% (55/55), done.
remote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)
Receiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.
Resolving deltas: 100% (127/127), completed with 47 local objects.
From github.com:jiminny/app
12295204cf..35f036ace6 master -> origin/master
* [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5
5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories
* [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration
+ ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)
8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas
Updating 12295204cf..35f036ace6
Fast-forward
app/Component/Datadog/Constants.php | 1 +
app/Providers/RouteServiceProvider.php | 2 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------
4 files changed, 62 insertions(+), 8 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr
* master
JY-20818-move-AJ-reports-to-separated-datadog-metric
JY-20773-fix-automated-reports-user-pilot-tracking
JY-20157-AJ-report-not-send-notification
JY-20508-notify-before-AJ-report-expiration
JY-20372-ai-reports-promotion-pages
JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
JY-20738-debug-AJ-tracking-UP
a
JY-18909-automated-reports-ask-jiminny
JY-20692-fix-integration-app-[API_KEY]
JY-20553-debug-crm-sync-delays
JY-20698-fix-SF-activity-types-on-new-playbook
JY-20543-AJ-report-tracking
JY-20384-handle-auto-sync-with-no-access-to-event-type
JY-20458-ask-jiminny-user-definitions
JY-19666-fix-import-contacts-account-association
JY-19666-HS-import-contacts-and-accounts-batch-job
JY-20458-Ask-Jiminny-Reports
JY-20200-batch-update-CRM-objects-Salesforce
JY-19666-HS-webhooks-add-contact-and-company
JY-20348-trigger-setup-DI-layout-on-team-creation
JY-20326-refactor-info-message-in-command
JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled
JY-20312-remove-on-update-change-last-synced-at-crm-configurations
JY-20306-SF-skip-auto-sync-for-task-based-playbook
JY-20192-remove-deleted-team-from-saved-search-filters
JY-20197-import-opportunity-batch-job
JY-20293-enable-status-field-for-pipedrive-deals
JY-20191-remove-commands-interactive-prompts
JY-20118-change-default-sync-strategy
JY-20183-add-cache-on-auto-log-delay
JY-20197-add-import-opportunity-batch-job
20118-hs-opportunity-make-webhook-strategy-default
JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based
JY-20196-handle-opportunity-without-note
JY-20118-improve-opportunity-import
JY-20189-handle-activity-search-on-deleted-groups
JY-20160
JY-20145-filter-out-converted-leads-when-matching
JY-20150-skip-push-summary-on-summary-ready-if-autolog
JY-20132-fix-note-encoding
JY-19792-clean-logs
JY-20117-fix-sync-profile-fields-on-empty-fields
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit
Switched to a new branch 'JY-20725-handle-HS-search-rate-limit'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd
docker exec -it docker_lamp_1 bash -c "mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini"
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 supervisorctl restart all
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped
jiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped
jiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped
jiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped
jiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped
worker-analytics:worker-analytics_00: stopped
worker-crm-update:worker-crm-update_00: stopped
worker-download:worker-download_00: stopped
worker-nudges:worker-nudges_00: stopped
artisan-schedule:artisan-schedule_00: stopped
worker-emails:worker-emails_00: stopped
worker:worker_00: stopped
worker-calendar:worker-calendar_00: stopped
jiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped
worker-crm-sync:worker-crm-sync_00: stopped
worker-audio:worker-audio_00: stopped
worker-conferences:worker-conferences_00: stopped
worker-es-update:worker-es-update_00: stopped
artisan-schedule:artisan-schedule_00: started
jiminny-worker-processing-1:jiminny-worker-processing-1_00: started
jiminny-worker-processing-2:jiminny-worker-processing-2_00: started
jiminny-worker-processing-3:jiminny-worker-processing-3_00: started
jiminny-worker-processing-4:jiminny-worker-processing-4_00: started
jiminny-worker-processing-5:jiminny-worker-processing-5_00: started
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started
worker:worker_00: started
worker-analytics:worker-analytics_00: started
worker-audio:worker-audio_00: started
worker-calendar:worker-calendar_00: started
worker-conferences:worker-conferences_00: started
worker-crm-sync:worker-crm-sync_00: started
worker-crm-update:worker-crm-update_00: started
worker-download:worker-download_00: started
worker-emails:worker-emails_00: started
worker-es-update:worker-es-update_00: started
worker-nudges:worker-nudges_00: started
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 php -v
PHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)
Copyright (c) The PHP Group
Zend En...
|
20463
|
NULL
|
NULL
|
NULL
|
|
20471
|
NULL
|
0
|
2026-05-11T15:34:17.858029+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778513657858_m1.jpg...
|
iTerm2
|
APP (docker)
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Last login: Thu May 7 09:44:56 on ttys007
Poetry Last login: Thu May 7 09:44:56 on ttys007
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status
On branch master
Your branch is up to date with 'origin/master'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: .env.local
modified: app/Console/Commands/JiminnyDebugCommand.php
modified: app/Jobs/Team/SyncToIntercom.php
modified: app/Services/PlaybackService.php
modified: config/logging.php
modified: resources/views/partials/crm/push-summary/html-assembly.blade.php
Untracked files:
(use "git add <file>..." to include in what will be committed)
.env.nikilocal
.env.other
WEBHOOK_FILTERING_IMPLEMENTATION.md
app/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php
app/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php
ids.txt
public/favicon.ico
raw_sql_query.sql
tests/Unit/Policies/CanAccessAiReportsTest.php
no changes added to commit (use "git add" and/or "git commit -a")
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 1482, done.
remote: Counting objects: 100% (481/481), done.
remote: Compressing objects: 100% (191/191), done.
remote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)
Receiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.
Resolving deltas: 100% (877/877), completed with 96 local objects.
From github.com:jiminny/app
83b628967a..ad2ce76737 master -> origin/master
1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests
b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
* [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost
* [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc
* [new branch] make-claude-great-again -> origin/make-claude-great-again
* [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507
* [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
Fast-forward
.cursor/rules/frontend-conventions.mdc | 23 ++
.env.production-eu | 2 +-
.env.staging | 2 +-
Makefile | 10 +
app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-
app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-
app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--
app/Component/AskAnything/AskAnythingPromptService.php | 3 +
app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-
app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-
app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-
app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---
app/Component/Twilio/TwilioRepository.php | 27 ++
app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----
app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--
app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++
app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----
app/Console/Commands/Users/SyncToIntercom.php | 4 +-
app/Console/Kernel.php | 3 +-
app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -
app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -
app/Contracts/Repositories/TeamRepository.php | 3 +-
app/Events/Activities/ActivityUpdated.php | 10 +-
app/Events/Activities/Audio/RecordingEvent.php | 6 +-
app/Events/Activities/Softphone/Ended.php | 8 +-
app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-
app/Events/Activities/Softphone/Started.php | 8 +-
app/Http/Controllers/API/ActivityController.php | 17 +-
app/Http/Controllers/API/SoftphoneController.php | 9 +-
app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-
app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-
app/Http/Controllers/Auth/SocialController.php | 6 +-
app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-
app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-
app/Http/Controllers/Kiosk/PartnersController.php | 46 +++
app/Http/Controllers/Kiosk/SearchController.php | 8 +
app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-
app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-
app/Http/Controllers/TeamSetupController.php | 4 +-
app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-
app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-
app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +
app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +
app/Http/Transformers/ActivityTransformer.php | 4 +-
app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-
app/Http/Transformers/PartnerTransformer.php | 1 +
app/Http/Transformers/StageTransformer.php | 6 +-
app/Http/Transformers/UserTransformer.php | 11 +-
app/Interactions/Settings/Teams/CreateTeam.php | 3 +
app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-
app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++
app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++
app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-
app/Jobs/Crm/UpdateStage.php | 3 +
app/Jobs/Team/SyncToIntercom.php | 7 +-
app/Listeners/Teams/SyncIntercomCompany.php | 5 +-
app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-
app/Listeners/Users/SyncIntercom.php | 5 +-
app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++
app/Mail/Reports/ReportNotGenerated.php | 41 +++
app/Models/Activity.php | 25 +-
app/Models/Activity/Question.php | 14 +-
app/Models/Activity/Search.php | 7 +
app/Models/AskAnything/AskAnythingPrompt.php | 6 +
app/Models/AutomatedReport.php | 10 +
app/Models/CoachingFeedback.php | 44 ++-
app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----
app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----
app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --
app/Models/Partner.php | 13 +
app/Models/Playlist/Activity.php | 14 +-
app/Notifications/OwnerInvitedToTrial.php | 14 +-
app/Policies/UserPolicy.php | 16 +-
app/Queue/Worker/Worker.php | 3 +-
app/Repositories/ActivityRepository.php | 13 +-
app/Repositories/AutomatedReportsRepository.php | 42 ++-
app/Repositories/TeamRepository.php | 21 +-
app/Repositories/UserRepository.php | 2 +-
app/Services/Activity/MeetingBotService.php | 8 +-
app/Services/ActivityService.php | 111 ++-----
app/Services/Crm/Hubspot/Service.php | 36 +-
app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-
app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--
app/Services/Kiosk/KioskService.php | 7 +-
app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-
app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++
app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++
app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++
app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----
composer.json | 1 -
composer.lock | 95 +-----
config/secure-headers.php | 5 +-
database/mappings/mapping_activities.json | 16 +
database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++
database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++
database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++
database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++
front-end/package.json | 5 +-
front-end/src/__mocks__/jiminny.js | 4 +-
front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +
front-end/src/__mocks__/setup.js | 1 +
front-end/src/apps/ai-reports-promo.js | 22 ++
front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++
front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++
front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++
front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++
front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++
.../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++
front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-
front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++
front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++
.../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++
front-end/src/components/AiReports/constants.js | 7 +
front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +
front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++
front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +
front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++
front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-
front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-
front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-
front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-
front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++
front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-
front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++
front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++
front-end/src/main.js | 1 +
front-end/src/store/modules/TeamInsights/util.js | 1 +
front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++
front-end/src/store/modules/platform/getters.js | 3 +
front-end/src/utils/index.js | 11 +
front-end/yarn.lock | 21 +-
phpstan-baseline.neon | 60 ----
public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes
public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes
public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes
public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes
public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes
public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes
public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes
public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes
resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++
resources/views/emails/reports/report-not-generated.blade.php | 24 ++
resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-
routes/api.php | 6 +
routes/web.php | 4 +
tests/Feature/Policies/UserPolicyTest.php | 90 ++++-
tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++
tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++
tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++
tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++
tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--
tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-
tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++
tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++
tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++
tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++
tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++
tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-
tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++
tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++
tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-
tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++
tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++
tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-
tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +
tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++
tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-
tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++
tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++
tests/Unit/Models/PartnerTest.php | 28 ++
tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++
tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-
tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++
tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--
tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++
tests/Unit/Services/KioskServiceTest.php | 8 +
tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-
tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++
tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++
tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----
186 files changed, 8538 insertions(+), 1233 deletions(-)
create mode 100644 app/Component/Twilio/TwilioRepository.php
delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php
create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php
delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php
create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php
create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php
create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php
create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php
create mode 100644 app/Mail/Reports/ReportNotGenerated.php
delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php
create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php
create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php
create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php
create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php
create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php
create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php
create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php
create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js
create mode 100644 front-end/src/apps/ai-reports-promo.js
create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html
create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js
create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js
create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js
create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/com/loss-report.pdf
create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf
create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf
create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf
create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php
create mode 100644 resources/views/emails/reports/report-not-generated.blade.php
create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php
create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php
create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php
create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php
create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php
create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php
create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php
create mode 100644 tests/Unit/Models/PartnerTest.php
create mode 100644 tests/Unit/Services/ActivityServiceTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 628, done.
remote: Counting objects: 100% (331/331), done.
remote: Compressing objects: 100% (63/63), done.
remote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)
Receiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.
Resolving deltas: 100% (391/391), completed with 57 local objects.
From github.com:jiminny/app
ad2ce76737..12295204cf master -> origin/master
14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests
f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech
+ ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)
* [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication
* [new branch] desktop-app -> origin/desktop-app
4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again
* [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas
Updating ad2ce76737..12295204cf
Fast-forward
app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-
app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------
app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------
tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------
tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -
11 files changed, 49 insertions(+), 90 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric
Switched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 15, done.
remote: Counting objects: 100% (15/15), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)
Unpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.
From github.com:jiminny/app
c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit
Already up to date.
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 180, done.
remote: Counting objects: 100% (179/179), done.
remote: Compressing objects: 100% (55/55), done.
remote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)
Receiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.
Resolving deltas: 100% (127/127), completed with 47 local objects.
From github.com:jiminny/app
12295204cf..35f036ace6 master -> origin/master
* [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5
5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories
* [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration
+ ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)
8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas
Updating 12295204cf..35f036ace6
Fast-forward
app/Component/Datadog/Constants.php | 1 +
app/Providers/RouteServiceProvider.php | 2 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------
4 files changed, 62 insertions(+), 8 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr
* master
JY-20818-move-AJ-reports-to-separated-datadog-metric
JY-20773-fix-automated-reports-user-pilot-tracking
JY-20157-AJ-report-not-send-notification
JY-20508-notify-before-AJ-report-expiration
JY-20372-ai-reports-promotion-pages
JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
JY-20738-debug-AJ-tracking-UP
a
JY-18909-automated-reports-ask-jiminny
JY-20692-fix-integration-app-[API_KEY]
JY-20553-debug-crm-sync-delays
JY-20698-fix-SF-activity-types-on-new-playbook
JY-20543-AJ-report-tracking
JY-20384-handle-auto-sync-with-no-access-to-event-type
JY-20458-ask-jiminny-user-definitions
JY-19666-fix-import-contacts-account-association
JY-19666-HS-import-contacts-and-accounts-batch-job
JY-20458-Ask-Jiminny-Reports
JY-20200-batch-update-CRM-objects-Salesforce
JY-19666-HS-webhooks-add-contact-and-company
JY-20348-trigger-setup-DI-layout-on-team-creation
JY-20326-refactor-info-message-in-command
JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled
JY-20312-remove-on-update-change-last-synced-at-crm-configurations
JY-20306-SF-skip-auto-sync-for-task-based-playbook
JY-20192-remove-deleted-team-from-saved-search-filters
JY-20197-import-opportunity-batch-job
JY-20293-enable-status-field-for-pipedrive-deals
JY-20191-remove-commands-interactive-prompts
JY-20118-change-default-sync-strategy
JY-20183-add-cache-on-auto-log-delay
JY-20197-add-import-opportunity-batch-job
20118-hs-opportunity-make-webhook-strategy-default
JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based
JY-20196-handle-opportunity-without-note
JY-20118-improve-opportunity-import
JY-20189-handle-activity-search-on-deleted-groups
JY-20160
JY-20145-filter-out-converted-leads-when-matching
JY-20150-skip-push-summary-on-summary-ready-if-autolog
JY-20132-fix-note-encoding
JY-19792-clean-logs
JY-20117-fix-sync-profile-fields-on-empty-fields
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit
Switched to a new branch 'JY-20725-handle-HS-search-rate-limit'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd
docker exec -it docker_lamp_1 bash -c "mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini"
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 supervisorctl restart all
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped
jiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped
jiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped
jiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped
jiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped
worker-analytics:worker-analytics_00: stopped
worker-crm-update:worker-crm-update_00: stopped
worker-download:worker-download_00: stopped
worker-nudges:worker-nudges_00: stopped
artisan-schedule:artisan-schedule_00: stopped
worker-emails:worker-emails_00: stopped
worker:worker_00: stopped
worker-calendar:worker-calendar_00: stopped
jiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped
worker-crm-sync:worker-crm-sync_00: stopped
worker-audio:worker-audio_00: stopped
worker-conferences:worker-conferences_00: stopped
worker-es-update:worker-es-update_00: stopped
artisan-schedule:artisan-schedule_00: started
jiminny-worker-processing-1:jiminny-worker-processing-1_00: started
jiminny-worker-processing-2:jiminny-worker-processing-2_00: started
jiminny-worker-processing-3:jiminny-worker-processing-3_00: started
jiminny-worker-processing-4:jiminny-worker-processing-4_00: started
jiminny-worker-processing-5:jiminny-worker-processing-5_00: started
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started
worker:worker_00: started
worker-analytics:worker-analytics_00: started
worker-audio:worker-audio_00: started
worker-calendar:worker-calendar_00: started
worker-conferences:worker-conferences_00: started
worker-crm-sync:worker-crm-sync_00: started
worker-crm-update:worker-crm-update_00: started
worker-download:worker-download_00: started
worker-emails:worker-emails_00: started
worker-es-update:worker-es-update_00: started
worker-nudges:worker-nudges_00: started
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 php -v
PHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)
Copyright (c) The PHP Group
Zend En...
|
[{"role":"AXTextArea","text [{"role":"AXTextArea","text":"Last login: Thu May 7 09:44:56 on ttys007\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Console/Commands/JiminnyDebugCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Services/PlaybackService.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: config/logging.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: resources/views/partials/crm/push-summary/html-assembly.blade.php\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 1482, done.\nremote: Counting objects: 100% (481/481), done.\nremote: Compressing objects: 100% (191/191), done.\nremote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)\nReceiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.\nResolving deltas: 100% (877/877), completed with 96 local objects.\nFrom github.com:jiminny/app\n 83b628967a..ad2ce76737 master -> origin/master\n 1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n 5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests\n b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n * [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost\n * [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc\n * [new branch] make-claude-great-again -> origin/make-claude-great-again\n * [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507\n * [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nFast-forward\n .cursor/rules/frontend-conventions.mdc | 23 ++\n .env.production-eu | 2 +-\n .env.staging | 2 +-\n Makefile | 10 +\n app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-\n app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-\n app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--\n app/Component/AskAnything/AskAnythingPromptService.php | 3 +\n app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-\n app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-\n app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-\n app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---\n app/Component/Twilio/TwilioRepository.php | 27 ++\n app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----\n app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--\n app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++\n app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----\n app/Console/Commands/Users/SyncToIntercom.php | 4 +-\n app/Console/Kernel.php | 3 +-\n app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -\n app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -\n app/Contracts/Repositories/TeamRepository.php | 3 +-\n app/Events/Activities/ActivityUpdated.php | 10 +-\n app/Events/Activities/Audio/RecordingEvent.php | 6 +-\n app/Events/Activities/Softphone/Ended.php | 8 +-\n app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-\n app/Events/Activities/Softphone/Started.php | 8 +-\n app/Http/Controllers/API/ActivityController.php | 17 +-\n app/Http/Controllers/API/SoftphoneController.php | 9 +-\n app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-\n app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-\n app/Http/Controllers/Auth/SocialController.php | 6 +-\n app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-\n app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-\n app/Http/Controllers/Kiosk/PartnersController.php | 46 +++\n app/Http/Controllers/Kiosk/SearchController.php | 8 +\n app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-\n app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-\n app/Http/Controllers/TeamSetupController.php | 4 +-\n app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-\n app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-\n app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +\n app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +\n app/Http/Transformers/ActivityTransformer.php | 4 +-\n app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-\n app/Http/Transformers/PartnerTransformer.php | 1 +\n app/Http/Transformers/StageTransformer.php | 6 +-\n app/Http/Transformers/UserTransformer.php | 11 +-\n app/Interactions/Settings/Teams/CreateTeam.php | 3 +\n app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-\n app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++\n app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++\n app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-\n app/Jobs/Crm/UpdateStage.php | 3 +\n app/Jobs/Team/SyncToIntercom.php | 7 +-\n app/Listeners/Teams/SyncIntercomCompany.php | 5 +-\n app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-\n app/Listeners/Users/SyncIntercom.php | 5 +-\n app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++\n app/Mail/Reports/ReportNotGenerated.php | 41 +++\n app/Models/Activity.php | 25 +-\n app/Models/Activity/Question.php | 14 +-\n app/Models/Activity/Search.php | 7 +\n app/Models/AskAnything/AskAnythingPrompt.php | 6 +\n app/Models/AutomatedReport.php | 10 +\n app/Models/CoachingFeedback.php | 44 ++-\n app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----\n app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----\n app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --\n app/Models/Partner.php | 13 +\n app/Models/Playlist/Activity.php | 14 +-\n app/Notifications/OwnerInvitedToTrial.php | 14 +-\n app/Policies/UserPolicy.php | 16 +-\n app/Queue/Worker/Worker.php | 3 +-\n app/Repositories/ActivityRepository.php | 13 +-\n app/Repositories/AutomatedReportsRepository.php | 42 ++-\n app/Repositories/TeamRepository.php | 21 +-\n app/Repositories/UserRepository.php | 2 +-\n app/Services/Activity/MeetingBotService.php | 8 +-\n app/Services/ActivityService.php | 111 ++-----\n app/Services/Crm/Hubspot/Service.php | 36 +-\n app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--\n app/Services/Kiosk/KioskService.php | 7 +-\n app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-\n app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++\n app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++\n app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++\n app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----\n composer.json | 1 -\n composer.lock | 95 +-----\n config/secure-headers.php | 5 +-\n database/mappings/mapping_activities.json | 16 +\n database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++\n database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++\n database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++\n database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++\n front-end/package.json | 5 +-\n front-end/src/__mocks__/jiminny.js | 4 +-\n front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +\n front-end/src/__mocks__/setup.js | 1 +\n front-end/src/apps/ai-reports-promo.js | 22 ++\n front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++\n front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++\n .../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++\n front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-\n front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++\n front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++\n .../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++\n front-end/src/components/AiReports/constants.js | 7 +\n front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +\n front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++\n front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +\n front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++\n front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-\n front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-\n front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-\n front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-\n front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++\n front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-\n front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++\n front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++\n front-end/src/main.js | 1 +\n front-end/src/store/modules/TeamInsights/util.js | 1 +\n front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++\n front-end/src/store/modules/platform/getters.js | 3 +\n front-end/src/utils/index.js | 11 +\n front-end/yarn.lock | 21 +-\n phpstan-baseline.neon | 60 ----\n public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes\n public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes\n public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes\n public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes\n public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes\n public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes\n public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes\n public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes\n resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++\n resources/views/emails/reports/report-not-generated.blade.php | 24 ++\n resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-\n routes/api.php | 6 +\n routes/web.php | 4 +\n tests/Feature/Policies/UserPolicyTest.php | 90 ++++-\n tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++\n tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++\n tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++\n tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++\n tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--\n tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-\n tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++\n tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++\n tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++\n tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++\n tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++\n tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-\n tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++\n tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++\n tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-\n tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++\n tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++\n tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-\n tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +\n tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++\n tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-\n tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++\n tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++\n tests/Unit/Models/PartnerTest.php | 28 ++\n tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++\n tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-\n tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++\n tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--\n tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++\n tests/Unit/Services/KioskServiceTest.php | 8 +\n tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-\n tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++\n tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++\n tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----\n 186 files changed, 8538 insertions(+), 1233 deletions(-)\n create mode 100644 app/Component/Twilio/TwilioRepository.php\n delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php\n create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php\n delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php\n create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php\n create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php\n create mode 100644 app/Mail/Reports/ReportNotGenerated.php\n delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php\n create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php\n create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php\n create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php\n create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php\n create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php\n create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php\n create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php\n create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js\n create mode 100644 front-end/src/apps/ai-reports-promo.js\n create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html\n create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js\n create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js\n create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js\n create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/com/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf\n create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf\n create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php\n create mode 100644 resources/views/emails/reports/report-not-generated.blade.php\n create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php\n create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php\n create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php\n create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php\n create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php\n create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php\n create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php\n create mode 100644 tests/Unit/Models/PartnerTest.php\n create mode 100644 tests/Unit/Services/ActivityServiceTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 628, done.\nremote: Counting objects: 100% (331/331), done.\nremote: Compressing objects: 100% (63/63), done.\nremote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)\nReceiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.\nResolving deltas: 100% (391/391), completed with 57 local objects.\nFrom github.com:jiminny/app\n ad2ce76737..12295204cf master -> origin/master\n 14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests\n f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n 1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech\n + ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)\n * [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication\n * [new branch] desktop-app -> origin/desktop-app\n 4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again\n * [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating ad2ce76737..12295204cf\nFast-forward\n app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-\n app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------\n app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------\n tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------\n tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -\n 11 files changed, 49 insertions(+), 90 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric\nSwitched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-calendar:worker-calendar_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker:worker_00: stopped\nworker-emails:worker-emails_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 15, done.\nremote: Counting objects: 100% (15/15), done.\nremote: Compressing objects: 100% (2/2), done.\nremote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)\nUnpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.\nFrom github.com:jiminny/app\n c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit\nAlready up to date.\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 180, done.\nremote: Counting objects: 100% (179/179), done.\nremote: Compressing objects: 100% (55/55), done.\nremote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)\nReceiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.\nResolving deltas: 100% (127/127), completed with 47 local objects.\nFrom github.com:jiminny/app\n 12295204cf..35f036ace6 master -> origin/master\n * [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5\n 5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories\n * [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration\n + ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)\n 8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating 12295204cf..35f036ace6\nFast-forward\n app/Component/Datadog/Constants.php | 1 +\n app/Providers/RouteServiceProvider.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------\n 4 files changed, 62 insertions(+), 8 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr \n* master\n JY-20818-move-AJ-reports-to-separated-datadog-metric\n JY-20773-fix-automated-reports-user-pilot-tracking\n JY-20157-AJ-report-not-send-notification\n JY-20508-notify-before-AJ-report-expiration\n JY-20372-ai-reports-promotion-pages\n JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n JY-20738-debug-AJ-tracking-UP\n a\n JY-18909-automated-reports-ask-jiminny\n JY-20692-fix-integration-app-token-auth-response-change\n JY-20553-debug-crm-sync-delays\n JY-20698-fix-SF-activity-types-on-new-playbook\n JY-20543-AJ-report-tracking\n JY-20384-handle-auto-sync-with-no-access-to-event-type\n JY-20458-ask-jiminny-user-definitions\n JY-19666-fix-import-contacts-account-association\n JY-19666-HS-import-contacts-and-accounts-batch-job\n JY-20458-Ask-Jiminny-Reports\n JY-20200-batch-update-CRM-objects-Salesforce\n JY-19666-HS-webhooks-add-contact-and-company\n JY-20348-trigger-setup-DI-layout-on-team-creation\n JY-20326-refactor-info-message-in-command\n JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled\n JY-20312-remove-on-update-change-last-synced-at-crm-configurations\n JY-20306-SF-skip-auto-sync-for-task-based-playbook\n JY-20192-remove-deleted-team-from-saved-search-filters\n JY-20197-import-opportunity-batch-job\n JY-20293-enable-status-field-for-pipedrive-deals\n JY-20191-remove-commands-interactive-prompts\n JY-20118-change-default-sync-strategy\n JY-20183-add-cache-on-auto-log-delay\n JY-20197-add-import-opportunity-batch-job\n 20118-hs-opportunity-make-webhook-strategy-default\n JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based\n JY-20196-handle-opportunity-without-note\n JY-20118-improve-opportunity-import\n JY-20189-handle-activity-search-on-deleted-groups\n JY-20160\n JY-20145-filter-out-converted-leads-when-matching\n JY-20150-skip-push-summary-on-summary-ready-if-autolog\n JY-20132-fix-note-encoding\n JY-19792-clean-logs\n JY-20117-fix-sync-profile-fields-on-empty-fields\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit \nSwitched to a new branch 'JY-20725-handle-HS-search-rate-limit'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-emails:worker-emails_00: stopped\nworker:worker_00: stopped\nworker-calendar:worker-calendar_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n 1) app/Jobs/Crm/MatchActivityCrmData.php (ordered_class_elements)\n ---------- begin diff ----------\n--- /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n+++ /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n@@ -29,24 +29,14 @@\n use InteractsWithQueue;\n use SerializesModels;\n \n+ private const int RETRY_WINDOW_MINUTES = 120;\n+\n public int $maxExceptions = 3;\n \n- private const int RETRY_WINDOW_MINUTES = 120;\n-\n private int $activityId;\n private ?Configuration $fromConfiguration;\n private bool $remoteSearch;\n \n- public function middleware(): array\n- {\n- return [new HandleHubspotRateLimit()];\n- }\n-\n- public function retryUntil(): \\DateTimeInterface\n- {\n- return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n- }\n-\n public function __construct(\n int $activityId,\n ?Configuration $fromConfiguration = null,\n@@ -57,6 +47,16 @@\n $this->remoteSearch = $remoteSearch;\n \n $this->onQueue(Constants::QUEUE_ANALYTICS_LOW);\n+ }\n+\n+ public function middleware(): array\n+ {\n+ return [new HandleHubspotRateLimit()];\n+ }\n+\n+ public function retryUntil(): \\DateTimeInterface\n+ {\n+ return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n }\n \n public function uniqueId(): string\n\n ----------- end diff -----------\n\n 2) app/Services/Crm/Hubspot/HubspotClientInterface.php (phpdoc_order, phpdoc_separation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n@@ -46,9 +46,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 3) app/Services/Crm/Hubspot/Client.php (phpdoc_order, phpdoc_separation, blank_line_before_statement)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/Client.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/Client.php\n@@ -69,10 +69,12 @@\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- * @return T The result of the API call\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@@ -81,6 +83,7 @@\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@@ -232,9 +235,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 4) app/Console/Commands/JiminnyDebugCommand.php (statement_indentation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n+++ /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n@@ -359,11 +359,11 @@\n $crmService = $crmResolver->prepareCrmService();\n \n for ($i = 0 ; $i < 3; $i++) {\n-// if ($i % 25 === 0) {\n-// $this->info(\"Syncing opportunity {$i}\");\n- $this->info(\"Matching contact {$i}\");\n-// }\n-// $crmService->syncOpportunity('374720564');\n+ // if ($i % 25 === 0) {\n+ // $this->info(\"Syncing opportunity {$i}\");\n+ $this->info(\"Matching contact {$i}\");\n+ // }\n+ // $crmService->syncOpportunity('374720564');\n $crmService->matchByName('Robot');\n }\n }\n\n ----------- end diff -----------\n\n\nFixed 4 of 5666 files in 146.870 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix \ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5666 files in 106.192 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5041/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░] 88%","depth":4,"on_screen":true,"value":"Last login: Thu May 7 09:44:56 on ttys007\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Console/Commands/JiminnyDebugCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Services/PlaybackService.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: config/logging.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: resources/views/partials/crm/push-summary/html-assembly.blade.php\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 1482, done.\nremote: Counting objects: 100% (481/481), done.\nremote: Compressing objects: 100% (191/191), done.\nremote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)\nReceiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.\nResolving deltas: 100% (877/877), completed with 96 local objects.\nFrom github.com:jiminny/app\n 83b628967a..ad2ce76737 master -> origin/master\n 1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n 5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests\n b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n * [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost\n * [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc\n * [new branch] make-claude-great-again -> origin/make-claude-great-again\n * [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507\n * [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nFast-forward\n .cursor/rules/frontend-conventions.mdc | 23 ++\n .env.production-eu | 2 +-\n .env.staging | 2 +-\n Makefile | 10 +\n app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-\n app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-\n app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--\n app/Component/AskAnything/AskAnythingPromptService.php | 3 +\n app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-\n app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-\n app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-\n app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---\n app/Component/Twilio/TwilioRepository.php | 27 ++\n app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----\n app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--\n app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++\n app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----\n app/Console/Commands/Users/SyncToIntercom.php | 4 +-\n app/Console/Kernel.php | 3 +-\n app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -\n app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -\n app/Contracts/Repositories/TeamRepository.php | 3 +-\n app/Events/Activities/ActivityUpdated.php | 10 +-\n app/Events/Activities/Audio/RecordingEvent.php | 6 +-\n app/Events/Activities/Softphone/Ended.php | 8 +-\n app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-\n app/Events/Activities/Softphone/Started.php | 8 +-\n app/Http/Controllers/API/ActivityController.php | 17 +-\n app/Http/Controllers/API/SoftphoneController.php | 9 +-\n app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-\n app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-\n app/Http/Controllers/Auth/SocialController.php | 6 +-\n app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-\n app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-\n app/Http/Controllers/Kiosk/PartnersController.php | 46 +++\n app/Http/Controllers/Kiosk/SearchController.php | 8 +\n app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-\n app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-\n app/Http/Controllers/TeamSetupController.php | 4 +-\n app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-\n app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-\n app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +\n app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +\n app/Http/Transformers/ActivityTransformer.php | 4 +-\n app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-\n app/Http/Transformers/PartnerTransformer.php | 1 +\n app/Http/Transformers/StageTransformer.php | 6 +-\n app/Http/Transformers/UserTransformer.php | 11 +-\n app/Interactions/Settings/Teams/CreateTeam.php | 3 +\n app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-\n app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++\n app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++\n app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-\n app/Jobs/Crm/UpdateStage.php | 3 +\n app/Jobs/Team/SyncToIntercom.php | 7 +-\n app/Listeners/Teams/SyncIntercomCompany.php | 5 +-\n app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-\n app/Listeners/Users/SyncIntercom.php | 5 +-\n app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++\n app/Mail/Reports/ReportNotGenerated.php | 41 +++\n app/Models/Activity.php | 25 +-\n app/Models/Activity/Question.php | 14 +-\n app/Models/Activity/Search.php | 7 +\n app/Models/AskAnything/AskAnythingPrompt.php | 6 +\n app/Models/AutomatedReport.php | 10 +\n app/Models/CoachingFeedback.php | 44 ++-\n app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----\n app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----\n app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --\n app/Models/Partner.php | 13 +\n app/Models/Playlist/Activity.php | 14 +-\n app/Notifications/OwnerInvitedToTrial.php | 14 +-\n app/Policies/UserPolicy.php | 16 +-\n app/Queue/Worker/Worker.php | 3 +-\n app/Repositories/ActivityRepository.php | 13 +-\n app/Repositories/AutomatedReportsRepository.php | 42 ++-\n app/Repositories/TeamRepository.php | 21 +-\n app/Repositories/UserRepository.php | 2 +-\n app/Services/Activity/MeetingBotService.php | 8 +-\n app/Services/ActivityService.php | 111 ++-----\n app/Services/Crm/Hubspot/Service.php | 36 +-\n app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--\n app/Services/Kiosk/KioskService.php | 7 +-\n app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-\n app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++\n app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++\n app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++\n app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----\n composer.json | 1 -\n composer.lock | 95 +-----\n config/secure-headers.php | 5 +-\n database/mappings/mapping_activities.json | 16 +\n database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++\n database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++\n database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++\n database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++\n front-end/package.json | 5 +-\n front-end/src/__mocks__/jiminny.js | 4 +-\n front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +\n front-end/src/__mocks__/setup.js | 1 +\n front-end/src/apps/ai-reports-promo.js | 22 ++\n front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++\n front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++\n .../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++\n front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-\n front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++\n front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++\n .../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++\n front-end/src/components/AiReports/constants.js | 7 +\n front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +\n front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++\n front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +\n front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++\n front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-\n front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-\n front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-\n front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-\n front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++\n front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-\n front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++\n front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++\n front-end/src/main.js | 1 +\n front-end/src/store/modules/TeamInsights/util.js | 1 +\n front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++\n front-end/src/store/modules/platform/getters.js | 3 +\n front-end/src/utils/index.js | 11 +\n front-end/yarn.lock | 21 +-\n phpstan-baseline.neon | 60 ----\n public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes\n public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes\n public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes\n public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes\n public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes\n public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes\n public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes\n public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes\n resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++\n resources/views/emails/reports/report-not-generated.blade.php | 24 ++\n resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-\n routes/api.php | 6 +\n routes/web.php | 4 +\n tests/Feature/Policies/UserPolicyTest.php | 90 ++++-\n tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++\n tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++\n tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++\n tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++\n tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--\n tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-\n tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++\n tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++\n tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++\n tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++\n tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++\n tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-\n tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++\n tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++\n tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-\n tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++\n tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++\n tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-\n tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +\n tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++\n tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-\n tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++\n tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++\n tests/Unit/Models/PartnerTest.php | 28 ++\n tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++\n tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-\n tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++\n tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--\n tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++\n tests/Unit/Services/KioskServiceTest.php | 8 +\n tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-\n tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++\n tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++\n tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----\n 186 files changed, 8538 insertions(+), 1233 deletions(-)\n create mode 100644 app/Component/Twilio/TwilioRepository.php\n delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php\n create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php\n delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php\n create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php\n create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php\n create mode 100644 app/Mail/Reports/ReportNotGenerated.php\n delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php\n create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php\n create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php\n create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php\n create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php\n create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php\n create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php\n create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php\n create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js\n create mode 100644 front-end/src/apps/ai-reports-promo.js\n create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html\n create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js\n create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js\n create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js\n create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/com/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf\n create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf\n create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php\n create mode 100644 resources/views/emails/reports/report-not-generated.blade.php\n create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php\n create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php\n create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php\n create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php\n create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php\n create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php\n create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php\n create mode 100644 tests/Unit/Models/PartnerTest.php\n create mode 100644 tests/Unit/Services/ActivityServiceTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 628, done.\nremote: Counting objects: 100% (331/331), done.\nremote: Compressing objects: 100% (63/63), done.\nremote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)\nReceiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.\nResolving deltas: 100% (391/391), completed with 57 local objects.\nFrom github.com:jiminny/app\n ad2ce76737..12295204cf master -> origin/master\n 14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests\n f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n 1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech\n + ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)\n * [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication\n * [new branch] desktop-app -> origin/desktop-app\n 4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again\n * [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating ad2ce76737..12295204cf\nFast-forward\n app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-\n app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------\n app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------\n tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------\n tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -\n 11 files changed, 49 insertions(+), 90 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric\nSwitched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-calendar:worker-calendar_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker:worker_00: stopped\nworker-emails:worker-emails_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 15, done.\nremote: Counting objects: 100% (15/15), done.\nremote: Compressing objects: 100% (2/2), done.\nremote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)\nUnpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.\nFrom github.com:jiminny/app\n c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit\nAlready up to date.\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 180, done.\nremote: Counting objects: 100% (179/179), done.\nremote: Compressing objects: 100% (55/55), done.\nremote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)\nReceiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.\nResolving deltas: 100% (127/127), completed with 47 local objects.\nFrom github.com:jiminny/app\n 12295204cf..35f036ace6 master -> origin/master\n * [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5\n 5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories\n * [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration\n + ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)\n 8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating 12295204cf..35f036ace6\nFast-forward\n app/Component/Datadog/Constants.php | 1 +\n app/Providers/RouteServiceProvider.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------\n 4 files changed, 62 insertions(+), 8 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr \n* master\n JY-20818-move-AJ-reports-to-separated-datadog-metric\n JY-20773-fix-automated-reports-user-pilot-tracking\n JY-20157-AJ-report-not-send-notification\n JY-20508-notify-before-AJ-report-expiration\n JY-20372-ai-reports-promotion-pages\n JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n JY-20738-debug-AJ-tracking-UP\n a\n JY-18909-automated-reports-ask-jiminny\n JY-20692-fix-integration-app-token-auth-response-change\n JY-20553-debug-crm-sync-delays\n JY-20698-fix-SF-activity-types-on-new-playbook\n JY-20543-AJ-report-tracking\n JY-20384-handle-auto-sync-with-no-access-to-event-type\n JY-20458-ask-jiminny-user-definitions\n JY-19666-fix-import-contacts-account-association\n JY-19666-HS-import-contacts-and-accounts-batch-job\n JY-20458-Ask-Jiminny-Reports\n JY-20200-batch-update-CRM-objects-Salesforce\n JY-19666-HS-webhooks-add-contact-and-company\n JY-20348-trigger-setup-DI-layout-on-team-creation\n JY-20326-refactor-info-message-in-command\n JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled\n JY-20312-remove-on-update-change-last-synced-at-crm-configurations\n JY-20306-SF-skip-auto-sync-for-task-based-playbook\n JY-20192-remove-deleted-team-from-saved-search-filters\n JY-20197-import-opportunity-batch-job\n JY-20293-enable-status-field-for-pipedrive-deals\n JY-20191-remove-commands-interactive-prompts\n JY-20118-change-default-sync-strategy\n JY-20183-add-cache-on-auto-log-delay\n JY-20197-add-import-opportunity-batch-job\n 20118-hs-opportunity-make-webhook-strategy-default\n JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based\n JY-20196-handle-opportunity-without-note\n JY-20118-improve-opportunity-import\n JY-20189-handle-activity-search-on-deleted-groups\n JY-20160\n JY-20145-filter-out-converted-leads-when-matching\n JY-20150-skip-push-summary-on-summary-ready-if-autolog\n JY-20132-fix-note-encoding\n JY-19792-clean-logs\n JY-20117-fix-sync-profile-fields-on-empty-fields\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit \nSwitched to a new branch 'JY-20725-handle-HS-search-rate-limit'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-emails:worker-emails_00: stopped\nworker:worker_00: stopped\nworker-calendar:worker-calendar_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n 1) app/Jobs/Crm/MatchActivityCrmData.php (ordered_class_elements)\n ---------- begin diff ----------\n--- /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n+++ /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n@@ -29,24 +29,14 @@\n use InteractsWithQueue;\n use SerializesModels;\n \n+ private const int RETRY_WINDOW_MINUTES = 120;\n+\n public int $maxExceptions = 3;\n \n- private const int RETRY_WINDOW_MINUTES = 120;\n-\n private int $activityId;\n private ?Configuration $fromConfiguration;\n private bool $remoteSearch;\n \n- public function middleware(): array\n- {\n- return [new HandleHubspotRateLimit()];\n- }\n-\n- public function retryUntil(): \\DateTimeInterface\n- {\n- return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n- }\n-\n public function __construct(\n int $activityId,\n ?Configuration $fromConfiguration = null,\n@@ -57,6 +47,16 @@\n $this->remoteSearch = $remoteSearch;\n \n $this->onQueue(Constants::QUEUE_ANALYTICS_LOW);\n+ }\n+\n+ public function middleware(): array\n+ {\n+ return [new HandleHubspotRateLimit()];\n+ }\n+\n+ public function retryUntil(): \\DateTimeInterface\n+ {\n+ return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n }\n \n public function uniqueId(): string\n\n ----------- end diff -----------\n\n 2) app/Services/Crm/Hubspot/HubspotClientInterface.php (phpdoc_order, phpdoc_separation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n@@ -46,9 +46,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 3) app/Services/Crm/Hubspot/Client.php (phpdoc_order, phpdoc_separation, blank_line_before_statement)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/Client.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/Client.php\n@@ -69,10 +69,12 @@\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- * @return T The result of the API call\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@@ -81,6 +83,7 @@\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@@ -232,9 +235,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 4) app/Console/Commands/JiminnyDebugCommand.php (statement_indentation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n+++ /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n@@ -359,11 +359,11 @@\n $crmService = $crmResolver->prepareCrmService();\n \n for ($i = 0 ; $i < 3; $i++) {\n-// if ($i % 25 === 0) {\n-// $this->info(\"Syncing opportunity {$i}\");\n- $this->info(\"Matching contact {$i}\");\n-// }\n-// $crmService->syncOpportunity('374720564');\n+ // if ($i % 25 === 0) {\n+ // $this->info(\"Syncing opportunity {$i}\");\n+ $this->info(\"Matching contact {$i}\");\n+ // }\n+ // $crmService->syncOpportunity('374720564');\n $crmService->matchByName('Robot');\n }\n }\n\n ----------- end diff -----------\n\n\nFixed 4 of 5666 files in 146.870 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix \ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5666 files in 106.192 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5041/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░] 88%","is_focused":true},{"role":"AXRadioButton","text":"DOCKER","depth":2,"bounds":{"left":0.0,"top":0.05888889,"width":0.140625,"height":0.026666667},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.004166667,"top":0.06333333,"width":0.011111111,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"DEV (docker)","depth":2,"bounds":{"left":0.140625,"top":0.05888889,"width":0.140625,"height":0.026666667},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.14479166,"top":0.06333333,"width":0.011111111,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"APP (docker)","depth":2,"bounds":{"left":0.28125,"top":0.05888889,"width":0.140625,"height":0.026666667},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.28541666,"top":0.06333333,"width":0.011111111,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"-zsh","depth":2,"bounds":{"left":0.421875,"top":0.05888889,"width":0.140625,"height":0.026666667},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.42604166,"top":0.06333333,"width":0.011111111,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"-zsh","depth":2,"bounds":{"left":0.5625,"top":0.05888889,"width":0.14027777,"height":0.026666667},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.56666666,"top":0.06333333,"width":0.011111111,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"ffmpeg","depth":2,"bounds":{"left":0.7027778,"top":0.05888889,"width":0.14027777,"height":0.026666667},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.70694447,"top":0.06333333,"width":0.011111111,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"-zsh","depth":2,"bounds":{"left":0.84305555,"top":0.05888889,"width":0.14027777,"height":0.026666667},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.8472222,"top":0.06333333,"width":0.011111111,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"⌥⌘1","depth":1,"bounds":{"left":0.9548611,"top":0.032222223,"width":0.03888889,"height":0.018888889},"on_screen":true,"automation_id":"_NS:8","role_description":"text"},{"role":"AXStaticText","text":"APP (docker)","depth":1,"bounds":{"left":0.46875,"top":0.033333335,"width":0.0625,"height":0.017777778},"on_screen":true,"role_description":"text"}]...
|
-5826347064466025141
|
-2292063823433160584
|
idle
|
accessibility
|
NULL
|
Last login: Thu May 7 09:44:56 on ttys007
Poetry Last login: Thu May 7 09:44:56 on ttys007
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status
On branch master
Your branch is up to date with 'origin/master'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: .env.local
modified: app/Console/Commands/JiminnyDebugCommand.php
modified: app/Jobs/Team/SyncToIntercom.php
modified: app/Services/PlaybackService.php
modified: config/logging.php
modified: resources/views/partials/crm/push-summary/html-assembly.blade.php
Untracked files:
(use "git add <file>..." to include in what will be committed)
.env.nikilocal
.env.other
WEBHOOK_FILTERING_IMPLEMENTATION.md
app/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php
app/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php
ids.txt
public/favicon.ico
raw_sql_query.sql
tests/Unit/Policies/CanAccessAiReportsTest.php
no changes added to commit (use "git add" and/or "git commit -a")
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 1482, done.
remote: Counting objects: 100% (481/481), done.
remote: Compressing objects: 100% (191/191), done.
remote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)
Receiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.
Resolving deltas: 100% (877/877), completed with 96 local objects.
From github.com:jiminny/app
83b628967a..ad2ce76737 master -> origin/master
1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests
b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
* [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost
* [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc
* [new branch] make-claude-great-again -> origin/make-claude-great-again
* [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507
* [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
Fast-forward
.cursor/rules/frontend-conventions.mdc | 23 ++
.env.production-eu | 2 +-
.env.staging | 2 +-
Makefile | 10 +
app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-
app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-
app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--
app/Component/AskAnything/AskAnythingPromptService.php | 3 +
app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-
app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-
app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-
app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---
app/Component/Twilio/TwilioRepository.php | 27 ++
app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----
app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--
app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++
app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----
app/Console/Commands/Users/SyncToIntercom.php | 4 +-
app/Console/Kernel.php | 3 +-
app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -
app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -
app/Contracts/Repositories/TeamRepository.php | 3 +-
app/Events/Activities/ActivityUpdated.php | 10 +-
app/Events/Activities/Audio/RecordingEvent.php | 6 +-
app/Events/Activities/Softphone/Ended.php | 8 +-
app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-
app/Events/Activities/Softphone/Started.php | 8 +-
app/Http/Controllers/API/ActivityController.php | 17 +-
app/Http/Controllers/API/SoftphoneController.php | 9 +-
app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-
app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-
app/Http/Controllers/Auth/SocialController.php | 6 +-
app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-
app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-
app/Http/Controllers/Kiosk/PartnersController.php | 46 +++
app/Http/Controllers/Kiosk/SearchController.php | 8 +
app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-
app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-
app/Http/Controllers/TeamSetupController.php | 4 +-
app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-
app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-
app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +
app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +
app/Http/Transformers/ActivityTransformer.php | 4 +-
app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-
app/Http/Transformers/PartnerTransformer.php | 1 +
app/Http/Transformers/StageTransformer.php | 6 +-
app/Http/Transformers/UserTransformer.php | 11 +-
app/Interactions/Settings/Teams/CreateTeam.php | 3 +
app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-
app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++
app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++
app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-
app/Jobs/Crm/UpdateStage.php | 3 +
app/Jobs/Team/SyncToIntercom.php | 7 +-
app/Listeners/Teams/SyncIntercomCompany.php | 5 +-
app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-
app/Listeners/Users/SyncIntercom.php | 5 +-
app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++
app/Mail/Reports/ReportNotGenerated.php | 41 +++
app/Models/Activity.php | 25 +-
app/Models/Activity/Question.php | 14 +-
app/Models/Activity/Search.php | 7 +
app/Models/AskAnything/AskAnythingPrompt.php | 6 +
app/Models/AutomatedReport.php | 10 +
app/Models/CoachingFeedback.php | 44 ++-
app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----
app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----
app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --
app/Models/Partner.php | 13 +
app/Models/Playlist/Activity.php | 14 +-
app/Notifications/OwnerInvitedToTrial.php | 14 +-
app/Policies/UserPolicy.php | 16 +-
app/Queue/Worker/Worker.php | 3 +-
app/Repositories/ActivityRepository.php | 13 +-
app/Repositories/AutomatedReportsRepository.php | 42 ++-
app/Repositories/TeamRepository.php | 21 +-
app/Repositories/UserRepository.php | 2 +-
app/Services/Activity/MeetingBotService.php | 8 +-
app/Services/ActivityService.php | 111 ++-----
app/Services/Crm/Hubspot/Service.php | 36 +-
app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-
app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--
app/Services/Kiosk/KioskService.php | 7 +-
app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-
app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++
app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++
app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++
app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----
composer.json | 1 -
composer.lock | 95 +-----
config/secure-headers.php | 5 +-
database/mappings/mapping_activities.json | 16 +
database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++
database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++
database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++
database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++
front-end/package.json | 5 +-
front-end/src/__mocks__/jiminny.js | 4 +-
front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +
front-end/src/__mocks__/setup.js | 1 +
front-end/src/apps/ai-reports-promo.js | 22 ++
front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++
front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++
front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++
front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++
front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++
.../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++
front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-
front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++
front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++
.../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++
front-end/src/components/AiReports/constants.js | 7 +
front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +
front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++
front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +
front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++
front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-
front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-
front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-
front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-
front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++
front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-
front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++
front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++
front-end/src/main.js | 1 +
front-end/src/store/modules/TeamInsights/util.js | 1 +
front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++
front-end/src/store/modules/platform/getters.js | 3 +
front-end/src/utils/index.js | 11 +
front-end/yarn.lock | 21 +-
phpstan-baseline.neon | 60 ----
public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes
public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes
public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes
public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes
public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes
public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes
public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes
public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes
resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++
resources/views/emails/reports/report-not-generated.blade.php | 24 ++
resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-
routes/api.php | 6 +
routes/web.php | 4 +
tests/Feature/Policies/UserPolicyTest.php | 90 ++++-
tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++
tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++
tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++
tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++
tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--
tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-
tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++
tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++
tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++
tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++
tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++
tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-
tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++
tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++
tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-
tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++
tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++
tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-
tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +
tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++
tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-
tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++
tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++
tests/Unit/Models/PartnerTest.php | 28 ++
tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++
tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-
tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++
tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--
tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++
tests/Unit/Services/KioskServiceTest.php | 8 +
tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-
tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++
tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++
tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----
186 files changed, 8538 insertions(+), 1233 deletions(-)
create mode 100644 app/Component/Twilio/TwilioRepository.php
delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php
create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php
delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php
create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php
create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php
create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php
create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php
create mode 100644 app/Mail/Reports/ReportNotGenerated.php
delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php
create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php
create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php
create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php
create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php
create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php
create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php
create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php
create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js
create mode 100644 front-end/src/apps/ai-reports-promo.js
create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html
create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js
create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js
create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js
create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/com/loss-report.pdf
create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf
create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf
create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf
create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php
create mode 100644 resources/views/emails/reports/report-not-generated.blade.php
create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php
create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php
create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php
create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php
create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php
create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php
create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php
create mode 100644 tests/Unit/Models/PartnerTest.php
create mode 100644 tests/Unit/Services/ActivityServiceTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 628, done.
remote: Counting objects: 100% (331/331), done.
remote: Compressing objects: 100% (63/63), done.
remote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)
Receiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.
Resolving deltas: 100% (391/391), completed with 57 local objects.
From github.com:jiminny/app
ad2ce76737..12295204cf master -> origin/master
14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests
f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech
+ ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)
* [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication
* [new branch] desktop-app -> origin/desktop-app
4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again
* [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas
Updating ad2ce76737..12295204cf
Fast-forward
app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-
app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------
app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------
tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------
tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -
11 files changed, 49 insertions(+), 90 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric
Switched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 15, done.
remote: Counting objects: 100% (15/15), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)
Unpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.
From github.com:jiminny/app
c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit
Already up to date.
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 180, done.
remote: Counting objects: 100% (179/179), done.
remote: Compressing objects: 100% (55/55), done.
remote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)
Receiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.
Resolving deltas: 100% (127/127), completed with 47 local objects.
From github.com:jiminny/app
12295204cf..35f036ace6 master -> origin/master
* [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5
5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories
* [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration
+ ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)
8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas
Updating 12295204cf..35f036ace6
Fast-forward
app/Component/Datadog/Constants.php | 1 +
app/Providers/RouteServiceProvider.php | 2 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------
4 files changed, 62 insertions(+), 8 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr
* master
JY-20818-move-AJ-reports-to-separated-datadog-metric
JY-20773-fix-automated-reports-user-pilot-tracking
JY-20157-AJ-report-not-send-notification
JY-20508-notify-before-AJ-report-expiration
JY-20372-ai-reports-promotion-pages
JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
JY-20738-debug-AJ-tracking-UP
a
JY-18909-automated-reports-ask-jiminny
JY-20692-fix-integration-app-[API_KEY]
JY-20553-debug-crm-sync-delays
JY-20698-fix-SF-activity-types-on-new-playbook
JY-20543-AJ-report-tracking
JY-20384-handle-auto-sync-with-no-access-to-event-type
JY-20458-ask-jiminny-user-definitions
JY-19666-fix-import-contacts-account-association
JY-19666-HS-import-contacts-and-accounts-batch-job
JY-20458-Ask-Jiminny-Reports
JY-20200-batch-update-CRM-objects-Salesforce
JY-19666-HS-webhooks-add-contact-and-company
JY-20348-trigger-setup-DI-layout-on-team-creation
JY-20326-refactor-info-message-in-command
JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled
JY-20312-remove-on-update-change-last-synced-at-crm-configurations
JY-20306-SF-skip-auto-sync-for-task-based-playbook
JY-20192-remove-deleted-team-from-saved-search-filters
JY-20197-import-opportunity-batch-job
JY-20293-enable-status-field-for-pipedrive-deals
JY-20191-remove-commands-interactive-prompts
JY-20118-change-default-sync-strategy
JY-20183-add-cache-on-auto-log-delay
JY-20197-add-import-opportunity-batch-job
20118-hs-opportunity-make-webhook-strategy-default
JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based
JY-20196-handle-opportunity-without-note
JY-20118-improve-opportunity-import
JY-20189-handle-activity-search-on-deleted-groups
JY-20160
JY-20145-filter-out-converted-leads-when-matching
JY-20150-skip-push-summary-on-summary-ready-if-autolog
JY-20132-fix-note-encoding
JY-19792-clean-logs
JY-20117-fix-sync-profile-fields-on-empty-fields
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit
Switched to a new branch 'JY-20725-handle-HS-search-rate-limit'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd
docker exec -it docker_lamp_1 bash -c "mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini"
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 supervisorctl restart all
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped
jiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped
jiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped
jiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped
jiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped
worker-analytics:worker-analytics_00: stopped
worker-crm-update:worker-crm-update_00: stopped
worker-download:worker-download_00: stopped
worker-nudges:worker-nudges_00: stopped
artisan-schedule:artisan-schedule_00: stopped
worker-emails:worker-emails_00: stopped
worker:worker_00: stopped
worker-calendar:worker-calendar_00: stopped
jiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped
worker-crm-sync:worker-crm-sync_00: stopped
worker-audio:worker-audio_00: stopped
worker-conferences:worker-conferences_00: stopped
worker-es-update:worker-es-update_00: stopped
artisan-schedule:artisan-schedule_00: started
jiminny-worker-processing-1:jiminny-worker-processing-1_00: started
jiminny-worker-processing-2:jiminny-worker-processing-2_00: started
jiminny-worker-processing-3:jiminny-worker-processing-3_00: started
jiminny-worker-processing-4:jiminny-worker-processing-4_00: started
jiminny-worker-processing-5:jiminny-worker-processing-5_00: started
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started
worker:worker_00: started
worker-analytics:worker-analytics_00: started
worker-audio:worker-audio_00: started
worker-calendar:worker-calendar_00: started
worker-conferences:worker-conferences_00: started
worker-crm-sync:worker-crm-sync_00: started
worker-crm-update:worker-crm-update_00: started
worker-download:worker-download_00: started
worker-emails:worker-emails_00: started
worker-es-update:worker-es-update_00: started
worker-nudges:worker-nudges_00: started
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 php -v
PHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)
Copyright (c) The PHP Group
Zend En...
|
20462
|
NULL
|
NULL
|
NULL
|
|
20472
|
887
|
0
|
2026-05-11T15:34:44.060782+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778513684060_m2.jpg...
|
iTerm2
|
APP (-zsh)
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Last login: Thu May 7 09:44:56 on ttys007
Poetry Last login: Thu May 7 09:44:56 on ttys007
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status
On branch master
Your branch is up to date with 'origin/master'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: .env.local
modified: app/Console/Commands/JiminnyDebugCommand.php
modified: app/Jobs/Team/SyncToIntercom.php
modified: app/Services/PlaybackService.php
modified: config/logging.php
modified: resources/views/partials/crm/push-summary/html-assembly.blade.php
Untracked files:
(use "git add <file>..." to include in what will be committed)
.env.nikilocal
.env.other
WEBHOOK_FILTERING_IMPLEMENTATION.md
app/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php
app/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php
ids.txt
public/favicon.ico
raw_sql_query.sql
tests/Unit/Policies/CanAccessAiReportsTest.php
no changes added to commit (use "git add" and/or "git commit -a")
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 1482, done.
remote: Counting objects: 100% (481/481), done.
remote: Compressing objects: 100% (191/191), done.
remote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)
Receiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.
Resolving deltas: 100% (877/877), completed with 96 local objects.
From github.com:jiminny/app
83b628967a..ad2ce76737 master -> origin/master
1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests
b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
* [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost
* [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc
* [new branch] make-claude-great-again -> origin/make-claude-great-again
* [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507
* [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
Fast-forward
.cursor/rules/frontend-conventions.mdc | 23 ++
.env.production-eu | 2 +-
.env.staging | 2 +-
Makefile | 10 +
app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-
app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-
app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--
app/Component/AskAnything/AskAnythingPromptService.php | 3 +
app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-
app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-
app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-
app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---
app/Component/Twilio/TwilioRepository.php | 27 ++
app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----
app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--
app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++
app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----
app/Console/Commands/Users/SyncToIntercom.php | 4 +-
app/Console/Kernel.php | 3 +-
app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -
app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -
app/Contracts/Repositories/TeamRepository.php | 3 +-
app/Events/Activities/ActivityUpdated.php | 10 +-
app/Events/Activities/Audio/RecordingEvent.php | 6 +-
app/Events/Activities/Softphone/Ended.php | 8 +-
app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-
app/Events/Activities/Softphone/Started.php | 8 +-
app/Http/Controllers/API/ActivityController.php | 17 +-
app/Http/Controllers/API/SoftphoneController.php | 9 +-
app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-
app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-
app/Http/Controllers/Auth/SocialController.php | 6 +-
app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-
app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-
app/Http/Controllers/Kiosk/PartnersController.php | 46 +++
app/Http/Controllers/Kiosk/SearchController.php | 8 +
app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-
app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-
app/Http/Controllers/TeamSetupController.php | 4 +-
app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-
app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-
app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +
app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +
app/Http/Transformers/ActivityTransformer.php | 4 +-
app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-
app/Http/Transformers/PartnerTransformer.php | 1 +
app/Http/Transformers/StageTransformer.php | 6 +-
app/Http/Transformers/UserTransformer.php | 11 +-
app/Interactions/Settings/Teams/CreateTeam.php | 3 +
app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-
app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++
app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++
app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-
app/Jobs/Crm/UpdateStage.php | 3 +
app/Jobs/Team/SyncToIntercom.php | 7 +-
app/Listeners/Teams/SyncIntercomCompany.php | 5 +-
app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-
app/Listeners/Users/SyncIntercom.php | 5 +-
app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++
app/Mail/Reports/ReportNotGenerated.php | 41 +++
app/Models/Activity.php | 25 +-
app/Models/Activity/Question.php | 14 +-
app/Models/Activity/Search.php | 7 +
app/Models/AskAnything/AskAnythingPrompt.php | 6 +
app/Models/AutomatedReport.php | 10 +
app/Models/CoachingFeedback.php | 44 ++-
app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----
app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----
app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --
app/Models/Partner.php | 13 +
app/Models/Playlist/Activity.php | 14 +-
app/Notifications/OwnerInvitedToTrial.php | 14 +-
app/Policies/UserPolicy.php | 16 +-
app/Queue/Worker/Worker.php | 3 +-
app/Repositories/ActivityRepository.php | 13 +-
app/Repositories/AutomatedReportsRepository.php | 42 ++-
app/Repositories/TeamRepository.php | 21 +-
app/Repositories/UserRepository.php | 2 +-
app/Services/Activity/MeetingBotService.php | 8 +-
app/Services/ActivityService.php | 111 ++-----
app/Services/Crm/Hubspot/Service.php | 36 +-
app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-
app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--
app/Services/Kiosk/KioskService.php | 7 +-
app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-
app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++
app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++
app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++
app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----
composer.json | 1 -
composer.lock | 95 +-----
config/secure-headers.php | 5 +-
database/mappings/mapping_activities.json | 16 +
database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++
database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++
database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++
database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++
front-end/package.json | 5 +-
front-end/src/__mocks__/jiminny.js | 4 +-
front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +
front-end/src/__mocks__/setup.js | 1 +
front-end/src/apps/ai-reports-promo.js | 22 ++
front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++
front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++
front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++
front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++
front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++
.../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++
front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-
front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++
front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++
.../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++
front-end/src/components/AiReports/constants.js | 7 +
front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +
front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++
front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +
front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++
front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-
front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-
front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-
front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-
front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++
front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-
front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++
front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++
front-end/src/main.js | 1 +
front-end/src/store/modules/TeamInsights/util.js | 1 +
front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++
front-end/src/store/modules/platform/getters.js | 3 +
front-end/src/utils/index.js | 11 +
front-end/yarn.lock | 21 +-
phpstan-baseline.neon | 60 ----
public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes
public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes
public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes
public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes
public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes
public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes
public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes
public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes
resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++
resources/views/emails/reports/report-not-generated.blade.php | 24 ++
resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-
routes/api.php | 6 +
routes/web.php | 4 +
tests/Feature/Policies/UserPolicyTest.php | 90 ++++-
tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++
tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++
tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++
tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++
tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--
tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-
tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++
tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++
tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++
tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++
tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++
tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-
tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++
tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++
tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-
tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++
tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++
tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-
tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +
tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++
tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-
tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++
tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++
tests/Unit/Models/PartnerTest.php | 28 ++
tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++
tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-
tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++
tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--
tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++
tests/Unit/Services/KioskServiceTest.php | 8 +
tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-
tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++
tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++
tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----
186 files changed, 8538 insertions(+), 1233 deletions(-)
create mode 100644 app/Component/Twilio/TwilioRepository.php
delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php
create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php
delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php
create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php
create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php
create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php
create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php
create mode 100644 app/Mail/Reports/ReportNotGenerated.php
delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php
create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php
create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php
create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php
create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php
create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php
create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php
create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php
create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js
create mode 100644 front-end/src/apps/ai-reports-promo.js
create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html
create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js
create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js
create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js
create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/com/loss-report.pdf
create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf
create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf
create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf
create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php
create mode 100644 resources/views/emails/reports/report-not-generated.blade.php
create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php
create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php
create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php
create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php
create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php
create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php
create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php
create mode 100644 tests/Unit/Models/PartnerTest.php
create mode 100644 tests/Unit/Services/ActivityServiceTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 628, done.
remote: Counting objects: 100% (331/331), done.
remote: Compressing objects: 100% (63/63), done.
remote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)
Receiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.
Resolving deltas: 100% (391/391), completed with 57 local objects.
From github.com:jiminny/app
ad2ce76737..12295204cf master -> origin/master
14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests
f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech
+ ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)
* [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication
* [new branch] desktop-app -> origin/desktop-app
4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again
* [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas
Updating ad2ce76737..12295204cf
Fast-forward
app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-
app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------
app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------
tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------
tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -
11 files changed, 49 insertions(+), 90 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric
Switched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 15, done.
remote: Counting objects: 100% (15/15), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)
Unpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.
From github.com:jiminny/app
c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit
Already up to date.
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 180, done.
remote: Counting objects: 100% (179/179), done.
remote: Compressing objects: 100% (55/55), done.
remote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)
Receiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.
Resolving deltas: 100% (127/127), completed with 47 local objects.
From github.com:jiminny/app
12295204cf..35f036ace6 master -> origin/master
* [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5
5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories
* [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration
+ ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)
8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas
Updating 12295204cf..35f036ace6
Fast-forward
app/Component/Datadog/Constants.php | 1 +
app/Providers/RouteServiceProvider.php | 2 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------
4 files changed, 62 insertions(+), 8 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr
* master
JY-20818-move-AJ-reports-to-separated-datadog-metric
JY-20773-fix-automated-reports-user-pilot-tracking
JY-20157-AJ-report-not-send-notification
JY-20508-notify-before-AJ-report-expiration
JY-20372-ai-reports-promotion-pages
JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
JY-20738-debug-AJ-tracking-UP
a
JY-18909-automated-reports-ask-jiminny
JY-20692-fix-integration-app-[API_KEY]
JY-20553-debug-crm-sync-delays
JY-20698-fix-SF-activity-types-on-new-playbook
JY-20543-AJ-report-tracking
JY-20384-handle-auto-sync-with-no-access-to-event-type
JY-20458-ask-jiminny-user-definitions
JY-19666-fix-import-contacts-account-association
JY-19666-HS-import-contacts-and-accounts-batch-job
JY-20458-Ask-Jiminny-Reports
JY-20200-batch-update-CRM-objects-Salesforce
JY-19666-HS-webhooks-add-contact-and-company
JY-20348-trigger-setup-DI-layout-on-team-creation
JY-20326-refactor-info-message-in-command
JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled
JY-20312-remove-on-update-change-last-synced-at-crm-configurations
JY-20306-SF-skip-auto-sync-for-task-based-playbook
JY-20192-remove-deleted-team-from-saved-search-filters
JY-20197-import-opportunity-batch-job
JY-20293-enable-status-field-for-pipedrive-deals
JY-20191-remove-commands-interactive-prompts
JY-20118-change-default-sync-strategy
JY-20183-add-cache-on-auto-log-delay
JY-20197-add-import-opportunity-batch-job
20118-hs-opportunity-make-webhook-strategy-default
JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based
JY-20196-handle-opportunity-without-note
JY-20118-improve-opportunity-import
JY-20189-handle-activity-search-on-deleted-groups
JY-20160
JY-20145-filter-out-converted-leads-when-matching
JY-20150-skip-push-summary-on-summary-ready-if-autolog
JY-20132-fix-note-encoding
JY-19792-clean-logs
JY-20117-fix-sync-profile-fields-on-empty-fields
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit
Switched to a new branch 'JY-20725-handle-HS-search-rate-limit'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd
docker exec -it docker_lamp_1 bash -c "mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini"
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 supervisorctl restart all
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped
jiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped
jiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped
jiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped
jiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped
worker-analytics:worker-analytics_00: stopped
worker-crm-update:worker-crm-update_00: stopped
worker-download:worker-download_00: stopped
worker-nudges:worker-nudges_00: stopped
artisan-schedule:artisan-schedule_00: stopped
worker-emails:worker-emails_00: stopped
worker:worker_00: stopped
worker-calendar:worker-calendar_00: stopped
jiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped
worker-crm-sync:worker-crm-sync_00: stopped
worker-audio:worker-audio_00: stopped
worker-conferences:worker-conferences_00: stopped
worker-es-update:worker-es-update_00: stopped
artisan-schedule:artisan-schedule_00: started
jiminny-worker-processing-1:jiminny-worker-processing-1_00: started
jiminny-worker-processing-2:jiminny-worker-processing-2_00: started
jiminny-worker-processing-3:jiminny-worker-processing-3_00: started
jiminny-worker-processing-4:jiminny-worker-processing-4_00: started
jiminny-worker-processing-5:jiminny-worker-processing-5_00: started
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started
worker:worker_00: started
worker-analytics:worker-analytics_00: started
worker-audio:worker-audio_00: started
worker-calendar:worker-calendar_00: started
worker-conferences:worker-conferences_00: started
worker-crm-sync:worker-crm-sync_00: started
worker-crm-update:worker-crm-update_00: started
worker-download:worker-download_00: started
worker-emails:worker-emails_00: started
worker-es-update:worker-es-update_00: started
worker-nudges:worker-nudges_00: started
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 php -v
PHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)
Copyright (c) The PHP Group
Zend En...
|
[{"role":"AXTextArea","text [{"role":"AXTextArea","text":"Last login: Thu May 7 09:44:56 on ttys007\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Console/Commands/JiminnyDebugCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Services/PlaybackService.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: config/logging.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: resources/views/partials/crm/push-summary/html-assembly.blade.php\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 1482, done.\nremote: Counting objects: 100% (481/481), done.\nremote: Compressing objects: 100% (191/191), done.\nremote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)\nReceiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.\nResolving deltas: 100% (877/877), completed with 96 local objects.\nFrom github.com:jiminny/app\n 83b628967a..ad2ce76737 master -> origin/master\n 1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n 5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests\n b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n * [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost\n * [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc\n * [new branch] make-claude-great-again -> origin/make-claude-great-again\n * [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507\n * [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nFast-forward\n .cursor/rules/frontend-conventions.mdc | 23 ++\n .env.production-eu | 2 +-\n .env.staging | 2 +-\n Makefile | 10 +\n app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-\n app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-\n app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--\n app/Component/AskAnything/AskAnythingPromptService.php | 3 +\n app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-\n app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-\n app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-\n app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---\n app/Component/Twilio/TwilioRepository.php | 27 ++\n app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----\n app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--\n app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++\n app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----\n app/Console/Commands/Users/SyncToIntercom.php | 4 +-\n app/Console/Kernel.php | 3 +-\n app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -\n app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -\n app/Contracts/Repositories/TeamRepository.php | 3 +-\n app/Events/Activities/ActivityUpdated.php | 10 +-\n app/Events/Activities/Audio/RecordingEvent.php | 6 +-\n app/Events/Activities/Softphone/Ended.php | 8 +-\n app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-\n app/Events/Activities/Softphone/Started.php | 8 +-\n app/Http/Controllers/API/ActivityController.php | 17 +-\n app/Http/Controllers/API/SoftphoneController.php | 9 +-\n app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-\n app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-\n app/Http/Controllers/Auth/SocialController.php | 6 +-\n app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-\n app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-\n app/Http/Controllers/Kiosk/PartnersController.php | 46 +++\n app/Http/Controllers/Kiosk/SearchController.php | 8 +\n app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-\n app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-\n app/Http/Controllers/TeamSetupController.php | 4 +-\n app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-\n app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-\n app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +\n app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +\n app/Http/Transformers/ActivityTransformer.php | 4 +-\n app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-\n app/Http/Transformers/PartnerTransformer.php | 1 +\n app/Http/Transformers/StageTransformer.php | 6 +-\n app/Http/Transformers/UserTransformer.php | 11 +-\n app/Interactions/Settings/Teams/CreateTeam.php | 3 +\n app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-\n app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++\n app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++\n app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-\n app/Jobs/Crm/UpdateStage.php | 3 +\n app/Jobs/Team/SyncToIntercom.php | 7 +-\n app/Listeners/Teams/SyncIntercomCompany.php | 5 +-\n app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-\n app/Listeners/Users/SyncIntercom.php | 5 +-\n app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++\n app/Mail/Reports/ReportNotGenerated.php | 41 +++\n app/Models/Activity.php | 25 +-\n app/Models/Activity/Question.php | 14 +-\n app/Models/Activity/Search.php | 7 +\n app/Models/AskAnything/AskAnythingPrompt.php | 6 +\n app/Models/AutomatedReport.php | 10 +\n app/Models/CoachingFeedback.php | 44 ++-\n app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----\n app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----\n app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --\n app/Models/Partner.php | 13 +\n app/Models/Playlist/Activity.php | 14 +-\n app/Notifications/OwnerInvitedToTrial.php | 14 +-\n app/Policies/UserPolicy.php | 16 +-\n app/Queue/Worker/Worker.php | 3 +-\n app/Repositories/ActivityRepository.php | 13 +-\n app/Repositories/AutomatedReportsRepository.php | 42 ++-\n app/Repositories/TeamRepository.php | 21 +-\n app/Repositories/UserRepository.php | 2 +-\n app/Services/Activity/MeetingBotService.php | 8 +-\n app/Services/ActivityService.php | 111 ++-----\n app/Services/Crm/Hubspot/Service.php | 36 +-\n app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--\n app/Services/Kiosk/KioskService.php | 7 +-\n app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-\n app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++\n app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++\n app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++\n app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----\n composer.json | 1 -\n composer.lock | 95 +-----\n config/secure-headers.php | 5 +-\n database/mappings/mapping_activities.json | 16 +\n database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++\n database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++\n database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++\n database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++\n front-end/package.json | 5 +-\n front-end/src/__mocks__/jiminny.js | 4 +-\n front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +\n front-end/src/__mocks__/setup.js | 1 +\n front-end/src/apps/ai-reports-promo.js | 22 ++\n front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++\n front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++\n .../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++\n front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-\n front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++\n front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++\n .../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++\n front-end/src/components/AiReports/constants.js | 7 +\n front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +\n front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++\n front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +\n front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++\n front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-\n front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-\n front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-\n front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-\n front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++\n front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-\n front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++\n front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++\n front-end/src/main.js | 1 +\n front-end/src/store/modules/TeamInsights/util.js | 1 +\n front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++\n front-end/src/store/modules/platform/getters.js | 3 +\n front-end/src/utils/index.js | 11 +\n front-end/yarn.lock | 21 +-\n phpstan-baseline.neon | 60 ----\n public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes\n public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes\n public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes\n public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes\n public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes\n public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes\n public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes\n public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes\n resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++\n resources/views/emails/reports/report-not-generated.blade.php | 24 ++\n resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-\n routes/api.php | 6 +\n routes/web.php | 4 +\n tests/Feature/Policies/UserPolicyTest.php | 90 ++++-\n tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++\n tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++\n tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++\n tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++\n tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--\n tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-\n tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++\n tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++\n tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++\n tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++\n tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++\n tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-\n tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++\n tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++\n tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-\n tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++\n tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++\n tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-\n tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +\n tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++\n tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-\n tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++\n tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++\n tests/Unit/Models/PartnerTest.php | 28 ++\n tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++\n tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-\n tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++\n tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--\n tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++\n tests/Unit/Services/KioskServiceTest.php | 8 +\n tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-\n tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++\n tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++\n tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----\n 186 files changed, 8538 insertions(+), 1233 deletions(-)\n create mode 100644 app/Component/Twilio/TwilioRepository.php\n delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php\n create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php\n delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php\n create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php\n create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php\n create mode 100644 app/Mail/Reports/ReportNotGenerated.php\n delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php\n create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php\n create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php\n create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php\n create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php\n create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php\n create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php\n create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php\n create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js\n create mode 100644 front-end/src/apps/ai-reports-promo.js\n create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html\n create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js\n create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js\n create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js\n create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/com/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf\n create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf\n create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php\n create mode 100644 resources/views/emails/reports/report-not-generated.blade.php\n create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php\n create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php\n create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php\n create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php\n create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php\n create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php\n create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php\n create mode 100644 tests/Unit/Models/PartnerTest.php\n create mode 100644 tests/Unit/Services/ActivityServiceTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 628, done.\nremote: Counting objects: 100% (331/331), done.\nremote: Compressing objects: 100% (63/63), done.\nremote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)\nReceiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.\nResolving deltas: 100% (391/391), completed with 57 local objects.\nFrom github.com:jiminny/app\n ad2ce76737..12295204cf master -> origin/master\n 14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests\n f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n 1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech\n + ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)\n * [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication\n * [new branch] desktop-app -> origin/desktop-app\n 4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again\n * [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating ad2ce76737..12295204cf\nFast-forward\n app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-\n app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------\n app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------\n tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------\n tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -\n 11 files changed, 49 insertions(+), 90 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric\nSwitched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-calendar:worker-calendar_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker:worker_00: stopped\nworker-emails:worker-emails_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 15, done.\nremote: Counting objects: 100% (15/15), done.\nremote: Compressing objects: 100% (2/2), done.\nremote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)\nUnpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.\nFrom github.com:jiminny/app\n c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit\nAlready up to date.\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 180, done.\nremote: Counting objects: 100% (179/179), done.\nremote: Compressing objects: 100% (55/55), done.\nremote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)\nReceiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.\nResolving deltas: 100% (127/127), completed with 47 local objects.\nFrom github.com:jiminny/app\n 12295204cf..35f036ace6 master -> origin/master\n * [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5\n 5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories\n * [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration\n + ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)\n 8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating 12295204cf..35f036ace6\nFast-forward\n app/Component/Datadog/Constants.php | 1 +\n app/Providers/RouteServiceProvider.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------\n 4 files changed, 62 insertions(+), 8 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr \n* master\n JY-20818-move-AJ-reports-to-separated-datadog-metric\n JY-20773-fix-automated-reports-user-pilot-tracking\n JY-20157-AJ-report-not-send-notification\n JY-20508-notify-before-AJ-report-expiration\n JY-20372-ai-reports-promotion-pages\n JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n JY-20738-debug-AJ-tracking-UP\n a\n JY-18909-automated-reports-ask-jiminny\n JY-20692-fix-integration-app-token-auth-response-change\n JY-20553-debug-crm-sync-delays\n JY-20698-fix-SF-activity-types-on-new-playbook\n JY-20543-AJ-report-tracking\n JY-20384-handle-auto-sync-with-no-access-to-event-type\n JY-20458-ask-jiminny-user-definitions\n JY-19666-fix-import-contacts-account-association\n JY-19666-HS-import-contacts-and-accounts-batch-job\n JY-20458-Ask-Jiminny-Reports\n JY-20200-batch-update-CRM-objects-Salesforce\n JY-19666-HS-webhooks-add-contact-and-company\n JY-20348-trigger-setup-DI-layout-on-team-creation\n JY-20326-refactor-info-message-in-command\n JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled\n JY-20312-remove-on-update-change-last-synced-at-crm-configurations\n JY-20306-SF-skip-auto-sync-for-task-based-playbook\n JY-20192-remove-deleted-team-from-saved-search-filters\n JY-20197-import-opportunity-batch-job\n JY-20293-enable-status-field-for-pipedrive-deals\n JY-20191-remove-commands-interactive-prompts\n JY-20118-change-default-sync-strategy\n JY-20183-add-cache-on-auto-log-delay\n JY-20197-add-import-opportunity-batch-job\n 20118-hs-opportunity-make-webhook-strategy-default\n JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based\n JY-20196-handle-opportunity-without-note\n JY-20118-improve-opportunity-import\n JY-20189-handle-activity-search-on-deleted-groups\n JY-20160\n JY-20145-filter-out-converted-leads-when-matching\n JY-20150-skip-push-summary-on-summary-ready-if-autolog\n JY-20132-fix-note-encoding\n JY-19792-clean-logs\n JY-20117-fix-sync-profile-fields-on-empty-fields\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit \nSwitched to a new branch 'JY-20725-handle-HS-search-rate-limit'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-emails:worker-emails_00: stopped\nworker:worker_00: stopped\nworker-calendar:worker-calendar_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n 1) app/Jobs/Crm/MatchActivityCrmData.php (ordered_class_elements)\n ---------- begin diff ----------\n--- /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n+++ /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n@@ -29,24 +29,14 @@\n use InteractsWithQueue;\n use SerializesModels;\n \n+ private const int RETRY_WINDOW_MINUTES = 120;\n+\n public int $maxExceptions = 3;\n \n- private const int RETRY_WINDOW_MINUTES = 120;\n-\n private int $activityId;\n private ?Configuration $fromConfiguration;\n private bool $remoteSearch;\n \n- public function middleware(): array\n- {\n- return [new HandleHubspotRateLimit()];\n- }\n-\n- public function retryUntil(): \\DateTimeInterface\n- {\n- return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n- }\n-\n public function __construct(\n int $activityId,\n ?Configuration $fromConfiguration = null,\n@@ -57,6 +47,16 @@\n $this->remoteSearch = $remoteSearch;\n \n $this->onQueue(Constants::QUEUE_ANALYTICS_LOW);\n+ }\n+\n+ public function middleware(): array\n+ {\n+ return [new HandleHubspotRateLimit()];\n+ }\n+\n+ public function retryUntil(): \\DateTimeInterface\n+ {\n+ return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n }\n \n public function uniqueId(): string\n\n ----------- end diff -----------\n\n 2) app/Services/Crm/Hubspot/HubspotClientInterface.php (phpdoc_order, phpdoc_separation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n@@ -46,9 +46,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 3) app/Services/Crm/Hubspot/Client.php (phpdoc_order, phpdoc_separation, blank_line_before_statement)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/Client.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/Client.php\n@@ -69,10 +69,12 @@\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- * @return T The result of the API call\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@@ -81,6 +83,7 @@\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@@ -232,9 +235,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 4) app/Console/Commands/JiminnyDebugCommand.php (statement_indentation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n+++ /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n@@ -359,11 +359,11 @@\n $crmService = $crmResolver->prepareCrmService();\n \n for ($i = 0 ; $i < 3; $i++) {\n-// if ($i % 25 === 0) {\n-// $this->info(\"Syncing opportunity {$i}\");\n- $this->info(\"Matching contact {$i}\");\n-// }\n-// $crmService->syncOpportunity('374720564');\n+ // if ($i % 25 === 0) {\n+ // $this->info(\"Syncing opportunity {$i}\");\n+ $this->info(\"Matching contact {$i}\");\n+ // }\n+ // $crmService->syncOpportunity('374720564');\n $crmService->matchByName('Robot');\n }\n }\n\n ----------- end diff -----------\n\n\nFixed 4 of 5666 files in 146.870 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix \ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5666 files in 106.192 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5666 files in 66.457 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $","depth":4,"on_screen":true,"value":"Last login: Thu May 7 09:44:56 on ttys007\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Console/Commands/JiminnyDebugCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Services/PlaybackService.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: config/logging.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: resources/views/partials/crm/push-summary/html-assembly.blade.php\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 1482, done.\nremote: Counting objects: 100% (481/481), done.\nremote: Compressing objects: 100% (191/191), done.\nremote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)\nReceiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.\nResolving deltas: 100% (877/877), completed with 96 local objects.\nFrom github.com:jiminny/app\n 83b628967a..ad2ce76737 master -> origin/master\n 1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n 5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests\n b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n * [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost\n * [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc\n * [new branch] make-claude-great-again -> origin/make-claude-great-again\n * [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507\n * [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nFast-forward\n .cursor/rules/frontend-conventions.mdc | 23 ++\n .env.production-eu | 2 +-\n .env.staging | 2 +-\n Makefile | 10 +\n app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-\n app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-\n app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--\n app/Component/AskAnything/AskAnythingPromptService.php | 3 +\n app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-\n app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-\n app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-\n app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---\n app/Component/Twilio/TwilioRepository.php | 27 ++\n app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----\n app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--\n app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++\n app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----\n app/Console/Commands/Users/SyncToIntercom.php | 4 +-\n app/Console/Kernel.php | 3 +-\n app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -\n app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -\n app/Contracts/Repositories/TeamRepository.php | 3 +-\n app/Events/Activities/ActivityUpdated.php | 10 +-\n app/Events/Activities/Audio/RecordingEvent.php | 6 +-\n app/Events/Activities/Softphone/Ended.php | 8 +-\n app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-\n app/Events/Activities/Softphone/Started.php | 8 +-\n app/Http/Controllers/API/ActivityController.php | 17 +-\n app/Http/Controllers/API/SoftphoneController.php | 9 +-\n app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-\n app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-\n app/Http/Controllers/Auth/SocialController.php | 6 +-\n app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-\n app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-\n app/Http/Controllers/Kiosk/PartnersController.php | 46 +++\n app/Http/Controllers/Kiosk/SearchController.php | 8 +\n app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-\n app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-\n app/Http/Controllers/TeamSetupController.php | 4 +-\n app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-\n app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-\n app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +\n app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +\n app/Http/Transformers/ActivityTransformer.php | 4 +-\n app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-\n app/Http/Transformers/PartnerTransformer.php | 1 +\n app/Http/Transformers/StageTransformer.php | 6 +-\n app/Http/Transformers/UserTransformer.php | 11 +-\n app/Interactions/Settings/Teams/CreateTeam.php | 3 +\n app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-\n app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++\n app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++\n app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-\n app/Jobs/Crm/UpdateStage.php | 3 +\n app/Jobs/Team/SyncToIntercom.php | 7 +-\n app/Listeners/Teams/SyncIntercomCompany.php | 5 +-\n app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-\n app/Listeners/Users/SyncIntercom.php | 5 +-\n app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++\n app/Mail/Reports/ReportNotGenerated.php | 41 +++\n app/Models/Activity.php | 25 +-\n app/Models/Activity/Question.php | 14 +-\n app/Models/Activity/Search.php | 7 +\n app/Models/AskAnything/AskAnythingPrompt.php | 6 +\n app/Models/AutomatedReport.php | 10 +\n app/Models/CoachingFeedback.php | 44 ++-\n app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----\n app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----\n app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --\n app/Models/Partner.php | 13 +\n app/Models/Playlist/Activity.php | 14 +-\n app/Notifications/OwnerInvitedToTrial.php | 14 +-\n app/Policies/UserPolicy.php | 16 +-\n app/Queue/Worker/Worker.php | 3 +-\n app/Repositories/ActivityRepository.php | 13 +-\n app/Repositories/AutomatedReportsRepository.php | 42 ++-\n app/Repositories/TeamRepository.php | 21 +-\n app/Repositories/UserRepository.php | 2 +-\n app/Services/Activity/MeetingBotService.php | 8 +-\n app/Services/ActivityService.php | 111 ++-----\n app/Services/Crm/Hubspot/Service.php | 36 +-\n app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--\n app/Services/Kiosk/KioskService.php | 7 +-\n app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-\n app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++\n app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++\n app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++\n app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----\n composer.json | 1 -\n composer.lock | 95 +-----\n config/secure-headers.php | 5 +-\n database/mappings/mapping_activities.json | 16 +\n database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++\n database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++\n database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++\n database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++\n front-end/package.json | 5 +-\n front-end/src/__mocks__/jiminny.js | 4 +-\n front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +\n front-end/src/__mocks__/setup.js | 1 +\n front-end/src/apps/ai-reports-promo.js | 22 ++\n front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++\n front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++\n .../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++\n front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-\n front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++\n front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++\n .../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++\n front-end/src/components/AiReports/constants.js | 7 +\n front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +\n front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++\n front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +\n front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++\n front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-\n front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-\n front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-\n front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-\n front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++\n front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-\n front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++\n front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++\n front-end/src/main.js | 1 +\n front-end/src/store/modules/TeamInsights/util.js | 1 +\n front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++\n front-end/src/store/modules/platform/getters.js | 3 +\n front-end/src/utils/index.js | 11 +\n front-end/yarn.lock | 21 +-\n phpstan-baseline.neon | 60 ----\n public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes\n public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes\n public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes\n public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes\n public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes\n public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes\n public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes\n public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes\n resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++\n resources/views/emails/reports/report-not-generated.blade.php | 24 ++\n resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-\n routes/api.php | 6 +\n routes/web.php | 4 +\n tests/Feature/Policies/UserPolicyTest.php | 90 ++++-\n tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++\n tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++\n tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++\n tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++\n tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--\n tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-\n tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++\n tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++\n tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++\n tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++\n tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++\n tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-\n tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++\n tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++\n tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-\n tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++\n tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++\n tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-\n tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +\n tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++\n tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-\n tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++\n tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++\n tests/Unit/Models/PartnerTest.php | 28 ++\n tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++\n tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-\n tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++\n tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--\n tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++\n tests/Unit/Services/KioskServiceTest.php | 8 +\n tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-\n tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++\n tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++\n tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----\n 186 files changed, 8538 insertions(+), 1233 deletions(-)\n create mode 100644 app/Component/Twilio/TwilioRepository.php\n delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php\n create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php\n delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php\n create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php\n create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php\n create mode 100644 app/Mail/Reports/ReportNotGenerated.php\n delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php\n create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php\n create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php\n create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php\n create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php\n create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php\n create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php\n create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php\n create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js\n create mode 100644 front-end/src/apps/ai-reports-promo.js\n create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html\n create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js\n create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js\n create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js\n create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/com/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf\n create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf\n create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php\n create mode 100644 resources/views/emails/reports/report-not-generated.blade.php\n create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php\n create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php\n create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php\n create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php\n create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php\n create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php\n create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php\n create mode 100644 tests/Unit/Models/PartnerTest.php\n create mode 100644 tests/Unit/Services/ActivityServiceTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 628, done.\nremote: Counting objects: 100% (331/331), done.\nremote: Compressing objects: 100% (63/63), done.\nremote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)\nReceiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.\nResolving deltas: 100% (391/391), completed with 57 local objects.\nFrom github.com:jiminny/app\n ad2ce76737..12295204cf master -> origin/master\n 14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests\n f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n 1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech\n + ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)\n * [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication\n * [new branch] desktop-app -> origin/desktop-app\n 4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again\n * [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating ad2ce76737..12295204cf\nFast-forward\n app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-\n app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------\n app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------\n tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------\n tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -\n 11 files changed, 49 insertions(+), 90 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric\nSwitched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-calendar:worker-calendar_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker:worker_00: stopped\nworker-emails:worker-emails_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 15, done.\nremote: Counting objects: 100% (15/15), done.\nremote: Compressing objects: 100% (2/2), done.\nremote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)\nUnpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.\nFrom github.com:jiminny/app\n c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit\nAlready up to date.\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 180, done.\nremote: Counting objects: 100% (179/179), done.\nremote: Compressing objects: 100% (55/55), done.\nremote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)\nReceiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.\nResolving deltas: 100% (127/127), completed with 47 local objects.\nFrom github.com:jiminny/app\n 12295204cf..35f036ace6 master -> origin/master\n * [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5\n 5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories\n * [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration\n + ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)\n 8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating 12295204cf..35f036ace6\nFast-forward\n app/Component/Datadog/Constants.php | 1 +\n app/Providers/RouteServiceProvider.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------\n 4 files changed, 62 insertions(+), 8 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr \n* master\n JY-20818-move-AJ-reports-to-separated-datadog-metric\n JY-20773-fix-automated-reports-user-pilot-tracking\n JY-20157-AJ-report-not-send-notification\n JY-20508-notify-before-AJ-report-expiration\n JY-20372-ai-reports-promotion-pages\n JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n JY-20738-debug-AJ-tracking-UP\n a\n JY-18909-automated-reports-ask-jiminny\n JY-20692-fix-integration-app-token-auth-response-change\n JY-20553-debug-crm-sync-delays\n JY-20698-fix-SF-activity-types-on-new-playbook\n JY-20543-AJ-report-tracking\n JY-20384-handle-auto-sync-with-no-access-to-event-type\n JY-20458-ask-jiminny-user-definitions\n JY-19666-fix-import-contacts-account-association\n JY-19666-HS-import-contacts-and-accounts-batch-job\n JY-20458-Ask-Jiminny-Reports\n JY-20200-batch-update-CRM-objects-Salesforce\n JY-19666-HS-webhooks-add-contact-and-company\n JY-20348-trigger-setup-DI-layout-on-team-creation\n JY-20326-refactor-info-message-in-command\n JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled\n JY-20312-remove-on-update-change-last-synced-at-crm-configurations\n JY-20306-SF-skip-auto-sync-for-task-based-playbook\n JY-20192-remove-deleted-team-from-saved-search-filters\n JY-20197-import-opportunity-batch-job\n JY-20293-enable-status-field-for-pipedrive-deals\n JY-20191-remove-commands-interactive-prompts\n JY-20118-change-default-sync-strategy\n JY-20183-add-cache-on-auto-log-delay\n JY-20197-add-import-opportunity-batch-job\n 20118-hs-opportunity-make-webhook-strategy-default\n JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based\n JY-20196-handle-opportunity-without-note\n JY-20118-improve-opportunity-import\n JY-20189-handle-activity-search-on-deleted-groups\n JY-20160\n JY-20145-filter-out-converted-leads-when-matching\n JY-20150-skip-push-summary-on-summary-ready-if-autolog\n JY-20132-fix-note-encoding\n JY-19792-clean-logs\n JY-20117-fix-sync-profile-fields-on-empty-fields\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit \nSwitched to a new branch 'JY-20725-handle-HS-search-rate-limit'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-emails:worker-emails_00: stopped\nworker:worker_00: stopped\nworker-calendar:worker-calendar_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n 1) app/Jobs/Crm/MatchActivityCrmData.php (ordered_class_elements)\n ---------- begin diff ----------\n--- /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n+++ /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n@@ -29,24 +29,14 @@\n use InteractsWithQueue;\n use SerializesModels;\n \n+ private const int RETRY_WINDOW_MINUTES = 120;\n+\n public int $maxExceptions = 3;\n \n- private const int RETRY_WINDOW_MINUTES = 120;\n-\n private int $activityId;\n private ?Configuration $fromConfiguration;\n private bool $remoteSearch;\n \n- public function middleware(): array\n- {\n- return [new HandleHubspotRateLimit()];\n- }\n-\n- public function retryUntil(): \\DateTimeInterface\n- {\n- return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n- }\n-\n public function __construct(\n int $activityId,\n ?Configuration $fromConfiguration = null,\n@@ -57,6 +47,16 @@\n $this->remoteSearch = $remoteSearch;\n \n $this->onQueue(Constants::QUEUE_ANALYTICS_LOW);\n+ }\n+\n+ public function middleware(): array\n+ {\n+ return [new HandleHubspotRateLimit()];\n+ }\n+\n+ public function retryUntil(): \\DateTimeInterface\n+ {\n+ return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n }\n \n public function uniqueId(): string\n\n ----------- end diff -----------\n\n 2) app/Services/Crm/Hubspot/HubspotClientInterface.php (phpdoc_order, phpdoc_separation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n@@ -46,9 +46,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 3) app/Services/Crm/Hubspot/Client.php (phpdoc_order, phpdoc_separation, blank_line_before_statement)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/Client.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/Client.php\n@@ -69,10 +69,12 @@\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- * @return T The result of the API call\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@@ -81,6 +83,7 @@\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@@ -232,9 +235,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 4) app/Console/Commands/JiminnyDebugCommand.php (statement_indentation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n+++ /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n@@ -359,11 +359,11 @@\n $crmService = $crmResolver->prepareCrmService();\n \n for ($i = 0 ; $i < 3; $i++) {\n-// if ($i % 25 === 0) {\n-// $this->info(\"Syncing opportunity {$i}\");\n- $this->info(\"Matching contact {$i}\");\n-// }\n-// $crmService->syncOpportunity('374720564');\n+ // if ($i % 25 === 0) {\n+ // $this->info(\"Syncing opportunity {$i}\");\n+ $this->info(\"Matching contact {$i}\");\n+ // }\n+ // $crmService->syncOpportunity('374720564');\n $crmService->matchByName('Robot');\n }\n }\n\n ----------- end diff -----------\n\n\nFixed 4 of 5666 files in 146.870 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix \ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5666 files in 106.192 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5666 files in 66.457 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $","is_focused":true},{"role":"AXRadioButton","text":"DOCKER","depth":2,"bounds":{"left":0.27027926,"top":1.0,"width":0.06732048,"height":-0.042298436},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.27227393,"top":1.0,"width":0.005319149,"height":-0.04549086},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"DEV (docker)","depth":2,"bounds":{"left":0.33759972,"top":1.0,"width":0.06732048,"height":-0.042298436},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.33959442,"top":1.0,"width":0.005319149,"height":-0.04549086},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"APP (-zsh)","depth":2,"bounds":{"left":0.40492022,"top":1.0,"width":0.06732048,"height":-0.042298436},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.4069149,"top":1.0,"width":0.005319149,"height":-0.04549086},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"-zsh","depth":2,"bounds":{"left":0.4722407,"top":1.0,"width":0.06732048,"height":-0.042298436},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.4742354,"top":1.0,"width":0.005319149,"height":-0.04549086},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"-zsh","depth":2,"bounds":{"left":0.53956115,"top":1.0,"width":0.06715426,"height":-0.042298436},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.5415558,"top":1.0,"width":0.005319149,"height":-0.04549086},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"screenpipe\"","depth":2,"bounds":{"left":0.60671544,"top":1.0,"width":0.06715426,"height":-0.042298436},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.6087101,"top":1.0,"width":0.005319149,"height":-0.04549086},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"-zsh","depth":2,"bounds":{"left":0.67386967,"top":1.0,"width":0.06715426,"height":-0.042298436},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.67586434,"top":1.0,"width":0.005319149,"height":-0.04549086},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"⌥⌘1","depth":1,"bounds":{"left":0.7273936,"top":1.0,"width":0.01861702,"height":-0.023144484},"on_screen":true,"automation_id":"_NS:8","role_description":"text"},{"role":"AXStaticText","text":"APP (-zsh)","depth":1,"bounds":{"left":0.4973404,"top":1.0,"width":0.024601065,"height":-0.02394259},"on_screen":true,"role_description":"text"}]...
|
-5826347064466025141
|
-2292063823433160584
|
idle
|
accessibility
|
NULL
|
Last login: Thu May 7 09:44:56 on ttys007
Poetry Last login: Thu May 7 09:44:56 on ttys007
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status
On branch master
Your branch is up to date with 'origin/master'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: .env.local
modified: app/Console/Commands/JiminnyDebugCommand.php
modified: app/Jobs/Team/SyncToIntercom.php
modified: app/Services/PlaybackService.php
modified: config/logging.php
modified: resources/views/partials/crm/push-summary/html-assembly.blade.php
Untracked files:
(use "git add <file>..." to include in what will be committed)
.env.nikilocal
.env.other
WEBHOOK_FILTERING_IMPLEMENTATION.md
app/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php
app/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php
ids.txt
public/favicon.ico
raw_sql_query.sql
tests/Unit/Policies/CanAccessAiReportsTest.php
no changes added to commit (use "git add" and/or "git commit -a")
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 1482, done.
remote: Counting objects: 100% (481/481), done.
remote: Compressing objects: 100% (191/191), done.
remote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)
Receiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.
Resolving deltas: 100% (877/877), completed with 96 local objects.
From github.com:jiminny/app
83b628967a..ad2ce76737 master -> origin/master
1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests
b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
* [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost
* [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc
* [new branch] make-claude-great-again -> origin/make-claude-great-again
* [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507
* [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
Fast-forward
.cursor/rules/frontend-conventions.mdc | 23 ++
.env.production-eu | 2 +-
.env.staging | 2 +-
Makefile | 10 +
app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-
app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-
app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--
app/Component/AskAnything/AskAnythingPromptService.php | 3 +
app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-
app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-
app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-
app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---
app/Component/Twilio/TwilioRepository.php | 27 ++
app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----
app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--
app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++
app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----
app/Console/Commands/Users/SyncToIntercom.php | 4 +-
app/Console/Kernel.php | 3 +-
app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -
app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -
app/Contracts/Repositories/TeamRepository.php | 3 +-
app/Events/Activities/ActivityUpdated.php | 10 +-
app/Events/Activities/Audio/RecordingEvent.php | 6 +-
app/Events/Activities/Softphone/Ended.php | 8 +-
app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-
app/Events/Activities/Softphone/Started.php | 8 +-
app/Http/Controllers/API/ActivityController.php | 17 +-
app/Http/Controllers/API/SoftphoneController.php | 9 +-
app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-
app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-
app/Http/Controllers/Auth/SocialController.php | 6 +-
app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-
app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-
app/Http/Controllers/Kiosk/PartnersController.php | 46 +++
app/Http/Controllers/Kiosk/SearchController.php | 8 +
app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-
app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-
app/Http/Controllers/TeamSetupController.php | 4 +-
app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-
app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-
app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +
app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +
app/Http/Transformers/ActivityTransformer.php | 4 +-
app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-
app/Http/Transformers/PartnerTransformer.php | 1 +
app/Http/Transformers/StageTransformer.php | 6 +-
app/Http/Transformers/UserTransformer.php | 11 +-
app/Interactions/Settings/Teams/CreateTeam.php | 3 +
app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-
app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++
app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++
app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-
app/Jobs/Crm/UpdateStage.php | 3 +
app/Jobs/Team/SyncToIntercom.php | 7 +-
app/Listeners/Teams/SyncIntercomCompany.php | 5 +-
app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-
app/Listeners/Users/SyncIntercom.php | 5 +-
app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++
app/Mail/Reports/ReportNotGenerated.php | 41 +++
app/Models/Activity.php | 25 +-
app/Models/Activity/Question.php | 14 +-
app/Models/Activity/Search.php | 7 +
app/Models/AskAnything/AskAnythingPrompt.php | 6 +
app/Models/AutomatedReport.php | 10 +
app/Models/CoachingFeedback.php | 44 ++-
app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----
app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----
app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --
app/Models/Partner.php | 13 +
app/Models/Playlist/Activity.php | 14 +-
app/Notifications/OwnerInvitedToTrial.php | 14 +-
app/Policies/UserPolicy.php | 16 +-
app/Queue/Worker/Worker.php | 3 +-
app/Repositories/ActivityRepository.php | 13 +-
app/Repositories/AutomatedReportsRepository.php | 42 ++-
app/Repositories/TeamRepository.php | 21 +-
app/Repositories/UserRepository.php | 2 +-
app/Services/Activity/MeetingBotService.php | 8 +-
app/Services/ActivityService.php | 111 ++-----
app/Services/Crm/Hubspot/Service.php | 36 +-
app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-
app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--
app/Services/Kiosk/KioskService.php | 7 +-
app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-
app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++
app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++
app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++
app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----
composer.json | 1 -
composer.lock | 95 +-----
config/secure-headers.php | 5 +-
database/mappings/mapping_activities.json | 16 +
database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++
database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++
database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++
database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++
front-end/package.json | 5 +-
front-end/src/__mocks__/jiminny.js | 4 +-
front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +
front-end/src/__mocks__/setup.js | 1 +
front-end/src/apps/ai-reports-promo.js | 22 ++
front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++
front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++
front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++
front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++
front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++
.../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++
front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-
front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++
front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++
.../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++
front-end/src/components/AiReports/constants.js | 7 +
front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +
front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++
front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +
front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++
front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-
front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-
front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-
front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-
front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++
front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-
front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++
front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++
front-end/src/main.js | 1 +
front-end/src/store/modules/TeamInsights/util.js | 1 +
front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++
front-end/src/store/modules/platform/getters.js | 3 +
front-end/src/utils/index.js | 11 +
front-end/yarn.lock | 21 +-
phpstan-baseline.neon | 60 ----
public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes
public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes
public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes
public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes
public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes
public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes
public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes
public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes
resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++
resources/views/emails/reports/report-not-generated.blade.php | 24 ++
resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-
routes/api.php | 6 +
routes/web.php | 4 +
tests/Feature/Policies/UserPolicyTest.php | 90 ++++-
tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++
tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++
tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++
tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++
tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--
tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-
tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++
tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++
tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++
tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++
tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++
tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-
tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++
tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++
tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-
tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++
tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++
tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-
tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +
tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++
tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-
tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++
tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++
tests/Unit/Models/PartnerTest.php | 28 ++
tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++
tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-
tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++
tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--
tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++
tests/Unit/Services/KioskServiceTest.php | 8 +
tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-
tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++
tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++
tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----
186 files changed, 8538 insertions(+), 1233 deletions(-)
create mode 100644 app/Component/Twilio/TwilioRepository.php
delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php
create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php
delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php
create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php
create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php
create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php
create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php
create mode 100644 app/Mail/Reports/ReportNotGenerated.php
delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php
create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php
create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php
create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php
create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php
create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php
create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php
create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php
create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js
create mode 100644 front-end/src/apps/ai-reports-promo.js
create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html
create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js
create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js
create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js
create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/com/loss-report.pdf
create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf
create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf
create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf
create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php
create mode 100644 resources/views/emails/reports/report-not-generated.blade.php
create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php
create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php
create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php
create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php
create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php
create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php
create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php
create mode 100644 tests/Unit/Models/PartnerTest.php
create mode 100644 tests/Unit/Services/ActivityServiceTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 628, done.
remote: Counting objects: 100% (331/331), done.
remote: Compressing objects: 100% (63/63), done.
remote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)
Receiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.
Resolving deltas: 100% (391/391), completed with 57 local objects.
From github.com:jiminny/app
ad2ce76737..12295204cf master -> origin/master
14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests
f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech
+ ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)
* [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication
* [new branch] desktop-app -> origin/desktop-app
4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again
* [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas
Updating ad2ce76737..12295204cf
Fast-forward
app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-
app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------
app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------
tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------
tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -
11 files changed, 49 insertions(+), 90 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric
Switched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 15, done.
remote: Counting objects: 100% (15/15), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)
Unpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.
From github.com:jiminny/app
c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit
Already up to date.
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 180, done.
remote: Counting objects: 100% (179/179), done.
remote: Compressing objects: 100% (55/55), done.
remote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)
Receiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.
Resolving deltas: 100% (127/127), completed with 47 local objects.
From github.com:jiminny/app
12295204cf..35f036ace6 master -> origin/master
* [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5
5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories
* [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration
+ ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)
8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas
Updating 12295204cf..35f036ace6
Fast-forward
app/Component/Datadog/Constants.php | 1 +
app/Providers/RouteServiceProvider.php | 2 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------
4 files changed, 62 insertions(+), 8 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr
* master
JY-20818-move-AJ-reports-to-separated-datadog-metric
JY-20773-fix-automated-reports-user-pilot-tracking
JY-20157-AJ-report-not-send-notification
JY-20508-notify-before-AJ-report-expiration
JY-20372-ai-reports-promotion-pages
JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
JY-20738-debug-AJ-tracking-UP
a
JY-18909-automated-reports-ask-jiminny
JY-20692-fix-integration-app-[API_KEY]
JY-20553-debug-crm-sync-delays
JY-20698-fix-SF-activity-types-on-new-playbook
JY-20543-AJ-report-tracking
JY-20384-handle-auto-sync-with-no-access-to-event-type
JY-20458-ask-jiminny-user-definitions
JY-19666-fix-import-contacts-account-association
JY-19666-HS-import-contacts-and-accounts-batch-job
JY-20458-Ask-Jiminny-Reports
JY-20200-batch-update-CRM-objects-Salesforce
JY-19666-HS-webhooks-add-contact-and-company
JY-20348-trigger-setup-DI-layout-on-team-creation
JY-20326-refactor-info-message-in-command
JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled
JY-20312-remove-on-update-change-last-synced-at-crm-configurations
JY-20306-SF-skip-auto-sync-for-task-based-playbook
JY-20192-remove-deleted-team-from-saved-search-filters
JY-20197-import-opportunity-batch-job
JY-20293-enable-status-field-for-pipedrive-deals
JY-20191-remove-commands-interactive-prompts
JY-20118-change-default-sync-strategy
JY-20183-add-cache-on-auto-log-delay
JY-20197-add-import-opportunity-batch-job
20118-hs-opportunity-make-webhook-strategy-default
JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based
JY-20196-handle-opportunity-without-note
JY-20118-improve-opportunity-import
JY-20189-handle-activity-search-on-deleted-groups
JY-20160
JY-20145-filter-out-converted-leads-when-matching
JY-20150-skip-push-summary-on-summary-ready-if-autolog
JY-20132-fix-note-encoding
JY-19792-clean-logs
JY-20117-fix-sync-profile-fields-on-empty-fields
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit
Switched to a new branch 'JY-20725-handle-HS-search-rate-limit'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd
docker exec -it docker_lamp_1 bash -c "mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini"
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 supervisorctl restart all
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped
jiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped
jiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped
jiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped
jiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped
worker-analytics:worker-analytics_00: stopped
worker-crm-update:worker-crm-update_00: stopped
worker-download:worker-download_00: stopped
worker-nudges:worker-nudges_00: stopped
artisan-schedule:artisan-schedule_00: stopped
worker-emails:worker-emails_00: stopped
worker:worker_00: stopped
worker-calendar:worker-calendar_00: stopped
jiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped
worker-crm-sync:worker-crm-sync_00: stopped
worker-audio:worker-audio_00: stopped
worker-conferences:worker-conferences_00: stopped
worker-es-update:worker-es-update_00: stopped
artisan-schedule:artisan-schedule_00: started
jiminny-worker-processing-1:jiminny-worker-processing-1_00: started
jiminny-worker-processing-2:jiminny-worker-processing-2_00: started
jiminny-worker-processing-3:jiminny-worker-processing-3_00: started
jiminny-worker-processing-4:jiminny-worker-processing-4_00: started
jiminny-worker-processing-5:jiminny-worker-processing-5_00: started
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started
worker:worker_00: started
worker-analytics:worker-analytics_00: started
worker-audio:worker-audio_00: started
worker-calendar:worker-calendar_00: started
worker-conferences:worker-conferences_00: started
worker-crm-sync:worker-crm-sync_00: started
worker-crm-update:worker-crm-update_00: started
worker-download:worker-download_00: started
worker-emails:worker-emails_00: started
worker-es-update:worker-es-update_00: started
worker-nudges:worker-nudges_00: started
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 php -v
PHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)
Copyright (c) The PHP Group
Zend En...
|
20463
|
NULL
|
NULL
|
NULL
|
|
20473
|
886
|
0
|
2026-05-11T15:34:48.150853+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778513688150_m1.jpg...
|
iTerm2
|
APP (-zsh)
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Last login: Thu May 7 09:44:56 on ttys007
Poetry Last login: Thu May 7 09:44:56 on ttys007
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status
On branch master
Your branch is up to date with 'origin/master'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: .env.local
modified: app/Console/Commands/JiminnyDebugCommand.php
modified: app/Jobs/Team/SyncToIntercom.php
modified: app/Services/PlaybackService.php
modified: config/logging.php
modified: resources/views/partials/crm/push-summary/html-assembly.blade.php
Untracked files:
(use "git add <file>..." to include in what will be committed)
.env.nikilocal
.env.other
WEBHOOK_FILTERING_IMPLEMENTATION.md
app/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php
app/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php
ids.txt
public/favicon.ico
raw_sql_query.sql
tests/Unit/Policies/CanAccessAiReportsTest.php
no changes added to commit (use "git add" and/or "git commit -a")
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 1482, done.
remote: Counting objects: 100% (481/481), done.
remote: Compressing objects: 100% (191/191), done.
remote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)
Receiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.
Resolving deltas: 100% (877/877), completed with 96 local objects.
From github.com:jiminny/app
83b628967a..ad2ce76737 master -> origin/master
1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests
b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
* [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost
* [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc
* [new branch] make-claude-great-again -> origin/make-claude-great-again
* [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507
* [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
Fast-forward
.cursor/rules/frontend-conventions.mdc | 23 ++
.env.production-eu | 2 +-
.env.staging | 2 +-
Makefile | 10 +
app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-
app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-
app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--
app/Component/AskAnything/AskAnythingPromptService.php | 3 +
app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-
app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-
app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-
app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---
app/Component/Twilio/TwilioRepository.php | 27 ++
app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----
app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--
app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++
app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----
app/Console/Commands/Users/SyncToIntercom.php | 4 +-
app/Console/Kernel.php | 3 +-
app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -
app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -
app/Contracts/Repositories/TeamRepository.php | 3 +-
app/Events/Activities/ActivityUpdated.php | 10 +-
app/Events/Activities/Audio/RecordingEvent.php | 6 +-
app/Events/Activities/Softphone/Ended.php | 8 +-
app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-
app/Events/Activities/Softphone/Started.php | 8 +-
app/Http/Controllers/API/ActivityController.php | 17 +-
app/Http/Controllers/API/SoftphoneController.php | 9 +-
app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-
app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-
app/Http/Controllers/Auth/SocialController.php | 6 +-
app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-
app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-
app/Http/Controllers/Kiosk/PartnersController.php | 46 +++
app/Http/Controllers/Kiosk/SearchController.php | 8 +
app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-
app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-
app/Http/Controllers/TeamSetupController.php | 4 +-
app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-
app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-
app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +
app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +
app/Http/Transformers/ActivityTransformer.php | 4 +-
app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-
app/Http/Transformers/PartnerTransformer.php | 1 +
app/Http/Transformers/StageTransformer.php | 6 +-
app/Http/Transformers/UserTransformer.php | 11 +-
app/Interactions/Settings/Teams/CreateTeam.php | 3 +
app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-
app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++
app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++
app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-
app/Jobs/Crm/UpdateStage.php | 3 +
app/Jobs/Team/SyncToIntercom.php | 7 +-
app/Listeners/Teams/SyncIntercomCompany.php | 5 +-
app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-
app/Listeners/Users/SyncIntercom.php | 5 +-
app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++
app/Mail/Reports/ReportNotGenerated.php | 41 +++
app/Models/Activity.php | 25 +-
app/Models/Activity/Question.php | 14 +-
app/Models/Activity/Search.php | 7 +
app/Models/AskAnything/AskAnythingPrompt.php | 6 +
app/Models/AutomatedReport.php | 10 +
app/Models/CoachingFeedback.php | 44 ++-
app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----
app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----
app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --
app/Models/Partner.php | 13 +
app/Models/Playlist/Activity.php | 14 +-
app/Notifications/OwnerInvitedToTrial.php | 14 +-
app/Policies/UserPolicy.php | 16 +-
app/Queue/Worker/Worker.php | 3 +-
app/Repositories/ActivityRepository.php | 13 +-
app/Repositories/AutomatedReportsRepository.php | 42 ++-
app/Repositories/TeamRepository.php | 21 +-
app/Repositories/UserRepository.php | 2 +-
app/Services/Activity/MeetingBotService.php | 8 +-
app/Services/ActivityService.php | 111 ++-----
app/Services/Crm/Hubspot/Service.php | 36 +-
app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-
app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--
app/Services/Kiosk/KioskService.php | 7 +-
app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-
app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++
app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++
app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++
app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----
composer.json | 1 -
composer.lock | 95 +-----
config/secure-headers.php | 5 +-
database/mappings/mapping_activities.json | 16 +
database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++
database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++
database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++
database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++
front-end/package.json | 5 +-
front-end/src/__mocks__/jiminny.js | 4 +-
front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +
front-end/src/__mocks__/setup.js | 1 +
front-end/src/apps/ai-reports-promo.js | 22 ++
front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++
front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++
front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++
front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++
front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++
.../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++
front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-
front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++
front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++
.../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++
front-end/src/components/AiReports/constants.js | 7 +
front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +
front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++
front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +
front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++
front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-
front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-
front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-
front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-
front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++
front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-
front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++
front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++
front-end/src/main.js | 1 +
front-end/src/store/modules/TeamInsights/util.js | 1 +
front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++
front-end/src/store/modules/platform/getters.js | 3 +
front-end/src/utils/index.js | 11 +
front-end/yarn.lock | 21 +-
phpstan-baseline.neon | 60 ----
public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes
public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes
public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes
public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes
public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes
public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes
public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes
public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes
resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++
resources/views/emails/reports/report-not-generated.blade.php | 24 ++
resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-
routes/api.php | 6 +
routes/web.php | 4 +
tests/Feature/Policies/UserPolicyTest.php | 90 ++++-
tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++
tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++
tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++
tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++
tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--
tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-
tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++
tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++
tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++
tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++
tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++
tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-
tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++
tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++
tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-
tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++
tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++
tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-
tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +
tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++
tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-
tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++
tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++
tests/Unit/Models/PartnerTest.php | 28 ++
tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++
tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-
tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++
tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--
tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++
tests/Unit/Services/KioskServiceTest.php | 8 +
tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-
tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++
tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++
tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----
186 files changed, 8538 insertions(+), 1233 deletions(-)
create mode 100644 app/Component/Twilio/TwilioRepository.php
delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php
create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php
delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php
create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php
create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php
create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php
create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php
create mode 100644 app/Mail/Reports/ReportNotGenerated.php
delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php
create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php
create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php
create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php
create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php
create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php
create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php
create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php
create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js
create mode 100644 front-end/src/apps/ai-reports-promo.js
create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html
create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js
create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js
create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js
create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/com/loss-report.pdf
create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf
create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf
create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf
create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php
create mode 100644 resources/views/emails/reports/report-not-generated.blade.php
create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php
create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php
create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php
create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php
create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php
create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php
create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php
create mode 100644 tests/Unit/Models/PartnerTest.php
create mode 100644 tests/Unit/Services/ActivityServiceTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 628, done.
remote: Counting objects: 100% (331/331), done.
remote: Compressing objects: 100% (63/63), done.
remote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)
Receiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.
Resolving deltas: 100% (391/391), completed with 57 local objects.
From github.com:jiminny/app
ad2ce76737..12295204cf master -> origin/master
14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests
f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech
+ ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)
* [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication
* [new branch] desktop-app -> origin/desktop-app
4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again
* [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas
Updating ad2ce76737..12295204cf
Fast-forward
app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-
app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------
app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------
tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------
tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -
11 files changed, 49 insertions(+), 90 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric
Switched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 15, done.
remote: Counting objects: 100% (15/15), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)
Unpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.
From github.com:jiminny/app
c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit
Already up to date.
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 180, done.
remote: Counting objects: 100% (179/179), done.
remote: Compressing objects: 100% (55/55), done.
remote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)
Receiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.
Resolving deltas: 100% (127/127), completed with 47 local objects.
From github.com:jiminny/app
12295204cf..35f036ace6 master -> origin/master
* [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5
5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories
* [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration
+ ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)
8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas
Updating 12295204cf..35f036ace6
Fast-forward
app/Component/Datadog/Constants.php | 1 +
app/Providers/RouteServiceProvider.php | 2 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------
4 files changed, 62 insertions(+), 8 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr
* master
JY-20818-move-AJ-reports-to-separated-datadog-metric
JY-20773-fix-automated-reports-user-pilot-tracking
JY-20157-AJ-report-not-send-notification
JY-20508-notify-before-AJ-report-expiration
JY-20372-ai-reports-promotion-pages
JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
JY-20738-debug-AJ-tracking-UP
a
JY-18909-automated-reports-ask-jiminny
JY-20692-fix-integration-app-[API_KEY]
JY-20553-debug-crm-sync-delays
JY-20698-fix-SF-activity-types-on-new-playbook
JY-20543-AJ-report-tracking
JY-20384-handle-auto-sync-with-no-access-to-event-type
JY-20458-ask-jiminny-user-definitions
JY-19666-fix-import-contacts-account-association
JY-19666-HS-import-contacts-and-accounts-batch-job
JY-20458-Ask-Jiminny-Reports
JY-20200-batch-update-CRM-objects-Salesforce
JY-19666-HS-webhooks-add-contact-and-company
JY-20348-trigger-setup-DI-layout-on-team-creation
JY-20326-refactor-info-message-in-command
JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled
JY-20312-remove-on-update-change-last-synced-at-crm-configurations
JY-20306-SF-skip-auto-sync-for-task-based-playbook
JY-20192-remove-deleted-team-from-saved-search-filters
JY-20197-import-opportunity-batch-job
JY-20293-enable-status-field-for-pipedrive-deals
JY-20191-remove-commands-interactive-prompts
JY-20118-change-default-sync-strategy
JY-20183-add-cache-on-auto-log-delay
JY-20197-add-import-opportunity-batch-job
20118-hs-opportunity-make-webhook-strategy-default
JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based
JY-20196-handle-opportunity-without-note
JY-20118-improve-opportunity-import
JY-20189-handle-activity-search-on-deleted-groups
JY-20160
JY-20145-filter-out-converted-leads-when-matching
JY-20150-skip-push-summary-on-summary-ready-if-autolog
JY-20132-fix-note-encoding
JY-19792-clean-logs
JY-20117-fix-sync-profile-fields-on-empty-fields
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit
Switched to a new branch 'JY-20725-handle-HS-search-rate-limit'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd
docker exec -it docker_lamp_1 bash -c "mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini"
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 supervisorctl restart all
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped
jiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped
jiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped
jiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped
jiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped
worker-analytics:worker-analytics_00: stopped
worker-crm-update:worker-crm-update_00: stopped
worker-download:worker-download_00: stopped
worker-nudges:worker-nudges_00: stopped
artisan-schedule:artisan-schedule_00: stopped
worker-emails:worker-emails_00: stopped
worker:worker_00: stopped
worker-calendar:worker-calendar_00: stopped
jiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped
worker-crm-sync:worker-crm-sync_00: stopped
worker-audio:worker-audio_00: stopped
worker-conferences:worker-conferences_00: stopped
worker-es-update:worker-es-update_00: stopped
artisan-schedule:artisan-schedule_00: started
jiminny-worker-processing-1:jiminny-worker-processing-1_00: started
jiminny-worker-processing-2:jiminny-worker-processing-2_00: started
jiminny-worker-processing-3:jiminny-worker-processing-3_00: started
jiminny-worker-processing-4:jiminny-worker-processing-4_00: started
jiminny-worker-processing-5:jiminny-worker-processing-5_00: started
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started
worker:worker_00: started
worker-analytics:worker-analytics_00: started
worker-audio:worker-audio_00: started
worker-calendar:worker-calendar_00: started
worker-conferences:worker-conferences_00: started
worker-crm-sync:worker-crm-sync_00: started
worker-crm-update:worker-crm-update_00: started
worker-download:worker-download_00: started
worker-emails:worker-emails_00: started
worker-es-update:worker-es-update_00: started
worker-nudges:worker-nudges_00: started
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 php -v
PHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)
Copyright (c) The PHP Group
Zend En...
|
[{"role":"AXTextArea","text [{"role":"AXTextArea","text":"Last login: Thu May 7 09:44:56 on ttys007\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Console/Commands/JiminnyDebugCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Services/PlaybackService.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: config/logging.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: resources/views/partials/crm/push-summary/html-assembly.blade.php\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 1482, done.\nremote: Counting objects: 100% (481/481), done.\nremote: Compressing objects: 100% (191/191), done.\nremote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)\nReceiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.\nResolving deltas: 100% (877/877), completed with 96 local objects.\nFrom github.com:jiminny/app\n 83b628967a..ad2ce76737 master -> origin/master\n 1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n 5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests\n b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n * [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost\n * [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc\n * [new branch] make-claude-great-again -> origin/make-claude-great-again\n * [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507\n * [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nFast-forward\n .cursor/rules/frontend-conventions.mdc | 23 ++\n .env.production-eu | 2 +-\n .env.staging | 2 +-\n Makefile | 10 +\n app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-\n app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-\n app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--\n app/Component/AskAnything/AskAnythingPromptService.php | 3 +\n app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-\n app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-\n app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-\n app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---\n app/Component/Twilio/TwilioRepository.php | 27 ++\n app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----\n app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--\n app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++\n app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----\n app/Console/Commands/Users/SyncToIntercom.php | 4 +-\n app/Console/Kernel.php | 3 +-\n app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -\n app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -\n app/Contracts/Repositories/TeamRepository.php | 3 +-\n app/Events/Activities/ActivityUpdated.php | 10 +-\n app/Events/Activities/Audio/RecordingEvent.php | 6 +-\n app/Events/Activities/Softphone/Ended.php | 8 +-\n app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-\n app/Events/Activities/Softphone/Started.php | 8 +-\n app/Http/Controllers/API/ActivityController.php | 17 +-\n app/Http/Controllers/API/SoftphoneController.php | 9 +-\n app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-\n app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-\n app/Http/Controllers/Auth/SocialController.php | 6 +-\n app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-\n app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-\n app/Http/Controllers/Kiosk/PartnersController.php | 46 +++\n app/Http/Controllers/Kiosk/SearchController.php | 8 +\n app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-\n app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-\n app/Http/Controllers/TeamSetupController.php | 4 +-\n app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-\n app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-\n app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +\n app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +\n app/Http/Transformers/ActivityTransformer.php | 4 +-\n app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-\n app/Http/Transformers/PartnerTransformer.php | 1 +\n app/Http/Transformers/StageTransformer.php | 6 +-\n app/Http/Transformers/UserTransformer.php | 11 +-\n app/Interactions/Settings/Teams/CreateTeam.php | 3 +\n app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-\n app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++\n app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++\n app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-\n app/Jobs/Crm/UpdateStage.php | 3 +\n app/Jobs/Team/SyncToIntercom.php | 7 +-\n app/Listeners/Teams/SyncIntercomCompany.php | 5 +-\n app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-\n app/Listeners/Users/SyncIntercom.php | 5 +-\n app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++\n app/Mail/Reports/ReportNotGenerated.php | 41 +++\n app/Models/Activity.php | 25 +-\n app/Models/Activity/Question.php | 14 +-\n app/Models/Activity/Search.php | 7 +\n app/Models/AskAnything/AskAnythingPrompt.php | 6 +\n app/Models/AutomatedReport.php | 10 +\n app/Models/CoachingFeedback.php | 44 ++-\n app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----\n app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----\n app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --\n app/Models/Partner.php | 13 +\n app/Models/Playlist/Activity.php | 14 +-\n app/Notifications/OwnerInvitedToTrial.php | 14 +-\n app/Policies/UserPolicy.php | 16 +-\n app/Queue/Worker/Worker.php | 3 +-\n app/Repositories/ActivityRepository.php | 13 +-\n app/Repositories/AutomatedReportsRepository.php | 42 ++-\n app/Repositories/TeamRepository.php | 21 +-\n app/Repositories/UserRepository.php | 2 +-\n app/Services/Activity/MeetingBotService.php | 8 +-\n app/Services/ActivityService.php | 111 ++-----\n app/Services/Crm/Hubspot/Service.php | 36 +-\n app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--\n app/Services/Kiosk/KioskService.php | 7 +-\n app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-\n app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++\n app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++\n app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++\n app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----\n composer.json | 1 -\n composer.lock | 95 +-----\n config/secure-headers.php | 5 +-\n database/mappings/mapping_activities.json | 16 +\n database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++\n database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++\n database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++\n database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++\n front-end/package.json | 5 +-\n front-end/src/__mocks__/jiminny.js | 4 +-\n front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +\n front-end/src/__mocks__/setup.js | 1 +\n front-end/src/apps/ai-reports-promo.js | 22 ++\n front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++\n front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++\n .../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++\n front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-\n front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++\n front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++\n .../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++\n front-end/src/components/AiReports/constants.js | 7 +\n front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +\n front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++\n front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +\n front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++\n front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-\n front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-\n front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-\n front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-\n front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++\n front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-\n front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++\n front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++\n front-end/src/main.js | 1 +\n front-end/src/store/modules/TeamInsights/util.js | 1 +\n front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++\n front-end/src/store/modules/platform/getters.js | 3 +\n front-end/src/utils/index.js | 11 +\n front-end/yarn.lock | 21 +-\n phpstan-baseline.neon | 60 ----\n public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes\n public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes\n public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes\n public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes\n public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes\n public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes\n public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes\n public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes\n resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++\n resources/views/emails/reports/report-not-generated.blade.php | 24 ++\n resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-\n routes/api.php | 6 +\n routes/web.php | 4 +\n tests/Feature/Policies/UserPolicyTest.php | 90 ++++-\n tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++\n tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++\n tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++\n tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++\n tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--\n tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-\n tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++\n tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++\n tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++\n tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++\n tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++\n tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-\n tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++\n tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++\n tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-\n tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++\n tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++\n tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-\n tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +\n tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++\n tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-\n tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++\n tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++\n tests/Unit/Models/PartnerTest.php | 28 ++\n tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++\n tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-\n tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++\n tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--\n tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++\n tests/Unit/Services/KioskServiceTest.php | 8 +\n tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-\n tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++\n tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++\n tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----\n 186 files changed, 8538 insertions(+), 1233 deletions(-)\n create mode 100644 app/Component/Twilio/TwilioRepository.php\n delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php\n create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php\n delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php\n create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php\n create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php\n create mode 100644 app/Mail/Reports/ReportNotGenerated.php\n delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php\n create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php\n create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php\n create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php\n create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php\n create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php\n create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php\n create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php\n create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js\n create mode 100644 front-end/src/apps/ai-reports-promo.js\n create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html\n create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js\n create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js\n create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js\n create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/com/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf\n create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf\n create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php\n create mode 100644 resources/views/emails/reports/report-not-generated.blade.php\n create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php\n create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php\n create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php\n create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php\n create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php\n create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php\n create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php\n create mode 100644 tests/Unit/Models/PartnerTest.php\n create mode 100644 tests/Unit/Services/ActivityServiceTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 628, done.\nremote: Counting objects: 100% (331/331), done.\nremote: Compressing objects: 100% (63/63), done.\nremote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)\nReceiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.\nResolving deltas: 100% (391/391), completed with 57 local objects.\nFrom github.com:jiminny/app\n ad2ce76737..12295204cf master -> origin/master\n 14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests\n f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n 1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech\n + ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)\n * [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication\n * [new branch] desktop-app -> origin/desktop-app\n 4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again\n * [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating ad2ce76737..12295204cf\nFast-forward\n app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-\n app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------\n app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------\n tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------\n tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -\n 11 files changed, 49 insertions(+), 90 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric\nSwitched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-calendar:worker-calendar_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker:worker_00: stopped\nworker-emails:worker-emails_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 15, done.\nremote: Counting objects: 100% (15/15), done.\nremote: Compressing objects: 100% (2/2), done.\nremote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)\nUnpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.\nFrom github.com:jiminny/app\n c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit\nAlready up to date.\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 180, done.\nremote: Counting objects: 100% (179/179), done.\nremote: Compressing objects: 100% (55/55), done.\nremote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)\nReceiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.\nResolving deltas: 100% (127/127), completed with 47 local objects.\nFrom github.com:jiminny/app\n 12295204cf..35f036ace6 master -> origin/master\n * [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5\n 5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories\n * [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration\n + ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)\n 8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating 12295204cf..35f036ace6\nFast-forward\n app/Component/Datadog/Constants.php | 1 +\n app/Providers/RouteServiceProvider.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------\n 4 files changed, 62 insertions(+), 8 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr \n* master\n JY-20818-move-AJ-reports-to-separated-datadog-metric\n JY-20773-fix-automated-reports-user-pilot-tracking\n JY-20157-AJ-report-not-send-notification\n JY-20508-notify-before-AJ-report-expiration\n JY-20372-ai-reports-promotion-pages\n JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n JY-20738-debug-AJ-tracking-UP\n a\n JY-18909-automated-reports-ask-jiminny\n JY-20692-fix-integration-app-token-auth-response-change\n JY-20553-debug-crm-sync-delays\n JY-20698-fix-SF-activity-types-on-new-playbook\n JY-20543-AJ-report-tracking\n JY-20384-handle-auto-sync-with-no-access-to-event-type\n JY-20458-ask-jiminny-user-definitions\n JY-19666-fix-import-contacts-account-association\n JY-19666-HS-import-contacts-and-accounts-batch-job\n JY-20458-Ask-Jiminny-Reports\n JY-20200-batch-update-CRM-objects-Salesforce\n JY-19666-HS-webhooks-add-contact-and-company\n JY-20348-trigger-setup-DI-layout-on-team-creation\n JY-20326-refactor-info-message-in-command\n JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled\n JY-20312-remove-on-update-change-last-synced-at-crm-configurations\n JY-20306-SF-skip-auto-sync-for-task-based-playbook\n JY-20192-remove-deleted-team-from-saved-search-filters\n JY-20197-import-opportunity-batch-job\n JY-20293-enable-status-field-for-pipedrive-deals\n JY-20191-remove-commands-interactive-prompts\n JY-20118-change-default-sync-strategy\n JY-20183-add-cache-on-auto-log-delay\n JY-20197-add-import-opportunity-batch-job\n 20118-hs-opportunity-make-webhook-strategy-default\n JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based\n JY-20196-handle-opportunity-without-note\n JY-20118-improve-opportunity-import\n JY-20189-handle-activity-search-on-deleted-groups\n JY-20160\n JY-20145-filter-out-converted-leads-when-matching\n JY-20150-skip-push-summary-on-summary-ready-if-autolog\n JY-20132-fix-note-encoding\n JY-19792-clean-logs\n JY-20117-fix-sync-profile-fields-on-empty-fields\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit \nSwitched to a new branch 'JY-20725-handle-HS-search-rate-limit'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-emails:worker-emails_00: stopped\nworker:worker_00: stopped\nworker-calendar:worker-calendar_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n 1) app/Jobs/Crm/MatchActivityCrmData.php (ordered_class_elements)\n ---------- begin diff ----------\n--- /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n+++ /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n@@ -29,24 +29,14 @@\n use InteractsWithQueue;\n use SerializesModels;\n \n+ private const int RETRY_WINDOW_MINUTES = 120;\n+\n public int $maxExceptions = 3;\n \n- private const int RETRY_WINDOW_MINUTES = 120;\n-\n private int $activityId;\n private ?Configuration $fromConfiguration;\n private bool $remoteSearch;\n \n- public function middleware(): array\n- {\n- return [new HandleHubspotRateLimit()];\n- }\n-\n- public function retryUntil(): \\DateTimeInterface\n- {\n- return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n- }\n-\n public function __construct(\n int $activityId,\n ?Configuration $fromConfiguration = null,\n@@ -57,6 +47,16 @@\n $this->remoteSearch = $remoteSearch;\n \n $this->onQueue(Constants::QUEUE_ANALYTICS_LOW);\n+ }\n+\n+ public function middleware(): array\n+ {\n+ return [new HandleHubspotRateLimit()];\n+ }\n+\n+ public function retryUntil(): \\DateTimeInterface\n+ {\n+ return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n }\n \n public function uniqueId(): string\n\n ----------- end diff -----------\n\n 2) app/Services/Crm/Hubspot/HubspotClientInterface.php (phpdoc_order, phpdoc_separation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n@@ -46,9 +46,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 3) app/Services/Crm/Hubspot/Client.php (phpdoc_order, phpdoc_separation, blank_line_before_statement)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/Client.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/Client.php\n@@ -69,10 +69,12 @@\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- * @return T The result of the API call\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@@ -81,6 +83,7 @@\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@@ -232,9 +235,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 4) app/Console/Commands/JiminnyDebugCommand.php (statement_indentation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n+++ /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n@@ -359,11 +359,11 @@\n $crmService = $crmResolver->prepareCrmService();\n \n for ($i = 0 ; $i < 3; $i++) {\n-// if ($i % 25 === 0) {\n-// $this->info(\"Syncing opportunity {$i}\");\n- $this->info(\"Matching contact {$i}\");\n-// }\n-// $crmService->syncOpportunity('374720564');\n+ // if ($i % 25 === 0) {\n+ // $this->info(\"Syncing opportunity {$i}\");\n+ $this->info(\"Matching contact {$i}\");\n+ // }\n+ // $crmService->syncOpportunity('374720564');\n $crmService->matchByName('Robot');\n }\n }\n\n ----------- end diff -----------\n\n\nFixed 4 of 5666 files in 146.870 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix \ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5666 files in 106.192 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5666 files in 66.457 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $","depth":4,"on_screen":true,"value":"Last login: Thu May 7 09:44:56 on ttys007\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Console/Commands/JiminnyDebugCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Services/PlaybackService.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: config/logging.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: resources/views/partials/crm/push-summary/html-assembly.blade.php\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 1482, done.\nremote: Counting objects: 100% (481/481), done.\nremote: Compressing objects: 100% (191/191), done.\nremote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)\nReceiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.\nResolving deltas: 100% (877/877), completed with 96 local objects.\nFrom github.com:jiminny/app\n 83b628967a..ad2ce76737 master -> origin/master\n 1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n 5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests\n b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n * [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost\n * [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc\n * [new branch] make-claude-great-again -> origin/make-claude-great-again\n * [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507\n * [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nFast-forward\n .cursor/rules/frontend-conventions.mdc | 23 ++\n .env.production-eu | 2 +-\n .env.staging | 2 +-\n Makefile | 10 +\n app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-\n app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-\n app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--\n app/Component/AskAnything/AskAnythingPromptService.php | 3 +\n app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-\n app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-\n app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-\n app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---\n app/Component/Twilio/TwilioRepository.php | 27 ++\n app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----\n app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--\n app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++\n app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----\n app/Console/Commands/Users/SyncToIntercom.php | 4 +-\n app/Console/Kernel.php | 3 +-\n app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -\n app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -\n app/Contracts/Repositories/TeamRepository.php | 3 +-\n app/Events/Activities/ActivityUpdated.php | 10 +-\n app/Events/Activities/Audio/RecordingEvent.php | 6 +-\n app/Events/Activities/Softphone/Ended.php | 8 +-\n app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-\n app/Events/Activities/Softphone/Started.php | 8 +-\n app/Http/Controllers/API/ActivityController.php | 17 +-\n app/Http/Controllers/API/SoftphoneController.php | 9 +-\n app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-\n app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-\n app/Http/Controllers/Auth/SocialController.php | 6 +-\n app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-\n app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-\n app/Http/Controllers/Kiosk/PartnersController.php | 46 +++\n app/Http/Controllers/Kiosk/SearchController.php | 8 +\n app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-\n app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-\n app/Http/Controllers/TeamSetupController.php | 4 +-\n app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-\n app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-\n app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +\n app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +\n app/Http/Transformers/ActivityTransformer.php | 4 +-\n app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-\n app/Http/Transformers/PartnerTransformer.php | 1 +\n app/Http/Transformers/StageTransformer.php | 6 +-\n app/Http/Transformers/UserTransformer.php | 11 +-\n app/Interactions/Settings/Teams/CreateTeam.php | 3 +\n app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-\n app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++\n app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++\n app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-\n app/Jobs/Crm/UpdateStage.php | 3 +\n app/Jobs/Team/SyncToIntercom.php | 7 +-\n app/Listeners/Teams/SyncIntercomCompany.php | 5 +-\n app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-\n app/Listeners/Users/SyncIntercom.php | 5 +-\n app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++\n app/Mail/Reports/ReportNotGenerated.php | 41 +++\n app/Models/Activity.php | 25 +-\n app/Models/Activity/Question.php | 14 +-\n app/Models/Activity/Search.php | 7 +\n app/Models/AskAnything/AskAnythingPrompt.php | 6 +\n app/Models/AutomatedReport.php | 10 +\n app/Models/CoachingFeedback.php | 44 ++-\n app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----\n app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----\n app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --\n app/Models/Partner.php | 13 +\n app/Models/Playlist/Activity.php | 14 +-\n app/Notifications/OwnerInvitedToTrial.php | 14 +-\n app/Policies/UserPolicy.php | 16 +-\n app/Queue/Worker/Worker.php | 3 +-\n app/Repositories/ActivityRepository.php | 13 +-\n app/Repositories/AutomatedReportsRepository.php | 42 ++-\n app/Repositories/TeamRepository.php | 21 +-\n app/Repositories/UserRepository.php | 2 +-\n app/Services/Activity/MeetingBotService.php | 8 +-\n app/Services/ActivityService.php | 111 ++-----\n app/Services/Crm/Hubspot/Service.php | 36 +-\n app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--\n app/Services/Kiosk/KioskService.php | 7 +-\n app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-\n app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++\n app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++\n app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++\n app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----\n composer.json | 1 -\n composer.lock | 95 +-----\n config/secure-headers.php | 5 +-\n database/mappings/mapping_activities.json | 16 +\n database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++\n database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++\n database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++\n database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++\n front-end/package.json | 5 +-\n front-end/src/__mocks__/jiminny.js | 4 +-\n front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +\n front-end/src/__mocks__/setup.js | 1 +\n front-end/src/apps/ai-reports-promo.js | 22 ++\n front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++\n front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++\n .../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++\n front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-\n front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++\n front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++\n .../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++\n front-end/src/components/AiReports/constants.js | 7 +\n front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +\n front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++\n front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +\n front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++\n front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-\n front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-\n front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-\n front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-\n front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++\n front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-\n front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++\n front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++\n front-end/src/main.js | 1 +\n front-end/src/store/modules/TeamInsights/util.js | 1 +\n front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++\n front-end/src/store/modules/platform/getters.js | 3 +\n front-end/src/utils/index.js | 11 +\n front-end/yarn.lock | 21 +-\n phpstan-baseline.neon | 60 ----\n public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes\n public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes\n public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes\n public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes\n public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes\n public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes\n public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes\n public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes\n resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++\n resources/views/emails/reports/report-not-generated.blade.php | 24 ++\n resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-\n routes/api.php | 6 +\n routes/web.php | 4 +\n tests/Feature/Policies/UserPolicyTest.php | 90 ++++-\n tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++\n tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++\n tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++\n tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++\n tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--\n tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-\n tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++\n tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++\n tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++\n tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++\n tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++\n tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-\n tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++\n tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++\n tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-\n tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++\n tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++\n tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-\n tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +\n tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++\n tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-\n tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++\n tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++\n tests/Unit/Models/PartnerTest.php | 28 ++\n tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++\n tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-\n tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++\n tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--\n tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++\n tests/Unit/Services/KioskServiceTest.php | 8 +\n tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-\n tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++\n tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++\n tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----\n 186 files changed, 8538 insertions(+), 1233 deletions(-)\n create mode 100644 app/Component/Twilio/TwilioRepository.php\n delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php\n create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php\n delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php\n create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php\n create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php\n create mode 100644 app/Mail/Reports/ReportNotGenerated.php\n delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php\n create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php\n create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php\n create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php\n create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php\n create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php\n create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php\n create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php\n create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js\n create mode 100644 front-end/src/apps/ai-reports-promo.js\n create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html\n create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js\n create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js\n create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js\n create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/com/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf\n create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf\n create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php\n create mode 100644 resources/views/emails/reports/report-not-generated.blade.php\n create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php\n create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php\n create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php\n create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php\n create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php\n create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php\n create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php\n create mode 100644 tests/Unit/Models/PartnerTest.php\n create mode 100644 tests/Unit/Services/ActivityServiceTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 628, done.\nremote: Counting objects: 100% (331/331), done.\nremote: Compressing objects: 100% (63/63), done.\nremote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)\nReceiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.\nResolving deltas: 100% (391/391), completed with 57 local objects.\nFrom github.com:jiminny/app\n ad2ce76737..12295204cf master -> origin/master\n 14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests\n f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n 1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech\n + ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)\n * [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication\n * [new branch] desktop-app -> origin/desktop-app\n 4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again\n * [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating ad2ce76737..12295204cf\nFast-forward\n app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-\n app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------\n app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------\n tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------\n tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -\n 11 files changed, 49 insertions(+), 90 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric\nSwitched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-calendar:worker-calendar_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker:worker_00: stopped\nworker-emails:worker-emails_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 15, done.\nremote: Counting objects: 100% (15/15), done.\nremote: Compressing objects: 100% (2/2), done.\nremote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)\nUnpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.\nFrom github.com:jiminny/app\n c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit\nAlready up to date.\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 180, done.\nremote: Counting objects: 100% (179/179), done.\nremote: Compressing objects: 100% (55/55), done.\nremote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)\nReceiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.\nResolving deltas: 100% (127/127), completed with 47 local objects.\nFrom github.com:jiminny/app\n 12295204cf..35f036ace6 master -> origin/master\n * [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5\n 5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories\n * [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration\n + ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)\n 8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating 12295204cf..35f036ace6\nFast-forward\n app/Component/Datadog/Constants.php | 1 +\n app/Providers/RouteServiceProvider.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------\n 4 files changed, 62 insertions(+), 8 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr \n* master\n JY-20818-move-AJ-reports-to-separated-datadog-metric\n JY-20773-fix-automated-reports-user-pilot-tracking\n JY-20157-AJ-report-not-send-notification\n JY-20508-notify-before-AJ-report-expiration\n JY-20372-ai-reports-promotion-pages\n JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n JY-20738-debug-AJ-tracking-UP\n a\n JY-18909-automated-reports-ask-jiminny\n JY-20692-fix-integration-app-token-auth-response-change\n JY-20553-debug-crm-sync-delays\n JY-20698-fix-SF-activity-types-on-new-playbook\n JY-20543-AJ-report-tracking\n JY-20384-handle-auto-sync-with-no-access-to-event-type\n JY-20458-ask-jiminny-user-definitions\n JY-19666-fix-import-contacts-account-association\n JY-19666-HS-import-contacts-and-accounts-batch-job\n JY-20458-Ask-Jiminny-Reports\n JY-20200-batch-update-CRM-objects-Salesforce\n JY-19666-HS-webhooks-add-contact-and-company\n JY-20348-trigger-setup-DI-layout-on-team-creation\n JY-20326-refactor-info-message-in-command\n JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled\n JY-20312-remove-on-update-change-last-synced-at-crm-configurations\n JY-20306-SF-skip-auto-sync-for-task-based-playbook\n JY-20192-remove-deleted-team-from-saved-search-filters\n JY-20197-import-opportunity-batch-job\n JY-20293-enable-status-field-for-pipedrive-deals\n JY-20191-remove-commands-interactive-prompts\n JY-20118-change-default-sync-strategy\n JY-20183-add-cache-on-auto-log-delay\n JY-20197-add-import-opportunity-batch-job\n 20118-hs-opportunity-make-webhook-strategy-default\n JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based\n JY-20196-handle-opportunity-without-note\n JY-20118-improve-opportunity-import\n JY-20189-handle-activity-search-on-deleted-groups\n JY-20160\n JY-20145-filter-out-converted-leads-when-matching\n JY-20150-skip-push-summary-on-summary-ready-if-autolog\n JY-20132-fix-note-encoding\n JY-19792-clean-logs\n JY-20117-fix-sync-profile-fields-on-empty-fields\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit \nSwitched to a new branch 'JY-20725-handle-HS-search-rate-limit'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-emails:worker-emails_00: stopped\nworker:worker_00: stopped\nworker-calendar:worker-calendar_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n 1) app/Jobs/Crm/MatchActivityCrmData.php (ordered_class_elements)\n ---------- begin diff ----------\n--- /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n+++ /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n@@ -29,24 +29,14 @@\n use InteractsWithQueue;\n use SerializesModels;\n \n+ private const int RETRY_WINDOW_MINUTES = 120;\n+\n public int $maxExceptions = 3;\n \n- private const int RETRY_WINDOW_MINUTES = 120;\n-\n private int $activityId;\n private ?Configuration $fromConfiguration;\n private bool $remoteSearch;\n \n- public function middleware(): array\n- {\n- return [new HandleHubspotRateLimit()];\n- }\n-\n- public function retryUntil(): \\DateTimeInterface\n- {\n- return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n- }\n-\n public function __construct(\n int $activityId,\n ?Configuration $fromConfiguration = null,\n@@ -57,6 +47,16 @@\n $this->remoteSearch = $remoteSearch;\n \n $this->onQueue(Constants::QUEUE_ANALYTICS_LOW);\n+ }\n+\n+ public function middleware(): array\n+ {\n+ return [new HandleHubspotRateLimit()];\n+ }\n+\n+ public function retryUntil(): \\DateTimeInterface\n+ {\n+ return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n }\n \n public function uniqueId(): string\n\n ----------- end diff -----------\n\n 2) app/Services/Crm/Hubspot/HubspotClientInterface.php (phpdoc_order, phpdoc_separation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n@@ -46,9 +46,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 3) app/Services/Crm/Hubspot/Client.php (phpdoc_order, phpdoc_separation, blank_line_before_statement)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/Client.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/Client.php\n@@ -69,10 +69,12 @@\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- * @return T The result of the API call\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@@ -81,6 +83,7 @@\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@@ -232,9 +235,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 4) app/Console/Commands/JiminnyDebugCommand.php (statement_indentation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n+++ /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n@@ -359,11 +359,11 @@\n $crmService = $crmResolver->prepareCrmService();\n \n for ($i = 0 ; $i < 3; $i++) {\n-// if ($i % 25 === 0) {\n-// $this->info(\"Syncing opportunity {$i}\");\n- $this->info(\"Matching contact {$i}\");\n-// }\n-// $crmService->syncOpportunity('374720564');\n+ // if ($i % 25 === 0) {\n+ // $this->info(\"Syncing opportunity {$i}\");\n+ $this->info(\"Matching contact {$i}\");\n+ // }\n+ // $crmService->syncOpportunity('374720564');\n $crmService->matchByName('Robot');\n }\n }\n\n ----------- end diff -----------\n\n\nFixed 4 of 5666 files in 146.870 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix \ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5666 files in 106.192 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5666 files in 66.457 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $","is_focused":true},{"role":"AXRadioButton","text":"DOCKER","depth":2,"bounds":{"left":0.0,"top":0.05888889,"width":0.140625,"height":0.026666667},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.004166667,"top":0.06333333,"width":0.011111111,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"DEV (docker)","depth":2,"bounds":{"left":0.140625,"top":0.05888889,"width":0.140625,"height":0.026666667},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.14479166,"top":0.06333333,"width":0.011111111,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"APP (-zsh)","depth":2,"bounds":{"left":0.28125,"top":0.05888889,"width":0.140625,"height":0.026666667},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.28541666,"top":0.06333333,"width":0.011111111,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"-zsh","depth":2,"bounds":{"left":0.421875,"top":0.05888889,"width":0.140625,"height":0.026666667},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.42604166,"top":0.06333333,"width":0.011111111,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"-zsh","depth":2,"bounds":{"left":0.5625,"top":0.05888889,"width":0.14027777,"height":0.026666667},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.56666666,"top":0.06333333,"width":0.011111111,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"ffmpeg","depth":2,"bounds":{"left":0.7027778,"top":0.05888889,"width":0.14027777,"height":0.026666667},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.70694447,"top":0.06333333,"width":0.011111111,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"-zsh","depth":2,"bounds":{"left":0.84305555,"top":0.05888889,"width":0.14027777,"height":0.026666667},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.8472222,"top":0.06333333,"width":0.011111111,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"⌥⌘1","depth":1,"bounds":{"left":0.9548611,"top":0.032222223,"width":0.03888889,"height":0.018888889},"on_screen":true,"automation_id":"_NS:8","role_description":"text"},{"role":"AXStaticText","text":"APP (-zsh)","depth":1,"bounds":{"left":0.47430557,"top":0.033333335,"width":0.05138889,"height":0.017777778},"on_screen":true,"role_description":"text"}]...
|
-5826347064466025141
|
-2292063823433160584
|
idle
|
accessibility
|
NULL
|
Last login: Thu May 7 09:44:56 on ttys007
Poetry Last login: Thu May 7 09:44:56 on ttys007
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status
On branch master
Your branch is up to date with 'origin/master'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: .env.local
modified: app/Console/Commands/JiminnyDebugCommand.php
modified: app/Jobs/Team/SyncToIntercom.php
modified: app/Services/PlaybackService.php
modified: config/logging.php
modified: resources/views/partials/crm/push-summary/html-assembly.blade.php
Untracked files:
(use "git add <file>..." to include in what will be committed)
.env.nikilocal
.env.other
WEBHOOK_FILTERING_IMPLEMENTATION.md
app/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php
app/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php
ids.txt
public/favicon.ico
raw_sql_query.sql
tests/Unit/Policies/CanAccessAiReportsTest.php
no changes added to commit (use "git add" and/or "git commit -a")
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 1482, done.
remote: Counting objects: 100% (481/481), done.
remote: Compressing objects: 100% (191/191), done.
remote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)
Receiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.
Resolving deltas: 100% (877/877), completed with 96 local objects.
From github.com:jiminny/app
83b628967a..ad2ce76737 master -> origin/master
1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests
b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
* [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost
* [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc
* [new branch] make-claude-great-again -> origin/make-claude-great-again
* [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507
* [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
Fast-forward
.cursor/rules/frontend-conventions.mdc | 23 ++
.env.production-eu | 2 +-
.env.staging | 2 +-
Makefile | 10 +
app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-
app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-
app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--
app/Component/AskAnything/AskAnythingPromptService.php | 3 +
app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-
app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-
app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-
app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---
app/Component/Twilio/TwilioRepository.php | 27 ++
app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----
app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--
app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++
app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----
app/Console/Commands/Users/SyncToIntercom.php | 4 +-
app/Console/Kernel.php | 3 +-
app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -
app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -
app/Contracts/Repositories/TeamRepository.php | 3 +-
app/Events/Activities/ActivityUpdated.php | 10 +-
app/Events/Activities/Audio/RecordingEvent.php | 6 +-
app/Events/Activities/Softphone/Ended.php | 8 +-
app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-
app/Events/Activities/Softphone/Started.php | 8 +-
app/Http/Controllers/API/ActivityController.php | 17 +-
app/Http/Controllers/API/SoftphoneController.php | 9 +-
app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-
app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-
app/Http/Controllers/Auth/SocialController.php | 6 +-
app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-
app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-
app/Http/Controllers/Kiosk/PartnersController.php | 46 +++
app/Http/Controllers/Kiosk/SearchController.php | 8 +
app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-
app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-
app/Http/Controllers/TeamSetupController.php | 4 +-
app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-
app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-
app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +
app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +
app/Http/Transformers/ActivityTransformer.php | 4 +-
app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-
app/Http/Transformers/PartnerTransformer.php | 1 +
app/Http/Transformers/StageTransformer.php | 6 +-
app/Http/Transformers/UserTransformer.php | 11 +-
app/Interactions/Settings/Teams/CreateTeam.php | 3 +
app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-
app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++
app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++
app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-
app/Jobs/Crm/UpdateStage.php | 3 +
app/Jobs/Team/SyncToIntercom.php | 7 +-
app/Listeners/Teams/SyncIntercomCompany.php | 5 +-
app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-
app/Listeners/Users/SyncIntercom.php | 5 +-
app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++
app/Mail/Reports/ReportNotGenerated.php | 41 +++
app/Models/Activity.php | 25 +-
app/Models/Activity/Question.php | 14 +-
app/Models/Activity/Search.php | 7 +
app/Models/AskAnything/AskAnythingPrompt.php | 6 +
app/Models/AutomatedReport.php | 10 +
app/Models/CoachingFeedback.php | 44 ++-
app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----
app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----
app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --
app/Models/Partner.php | 13 +
app/Models/Playlist/Activity.php | 14 +-
app/Notifications/OwnerInvitedToTrial.php | 14 +-
app/Policies/UserPolicy.php | 16 +-
app/Queue/Worker/Worker.php | 3 +-
app/Repositories/ActivityRepository.php | 13 +-
app/Repositories/AutomatedReportsRepository.php | 42 ++-
app/Repositories/TeamRepository.php | 21 +-
app/Repositories/UserRepository.php | 2 +-
app/Services/Activity/MeetingBotService.php | 8 +-
app/Services/ActivityService.php | 111 ++-----
app/Services/Crm/Hubspot/Service.php | 36 +-
app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-
app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--
app/Services/Kiosk/KioskService.php | 7 +-
app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-
app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++
app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++
app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++
app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----
composer.json | 1 -
composer.lock | 95 +-----
config/secure-headers.php | 5 +-
database/mappings/mapping_activities.json | 16 +
database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++
database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++
database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++
database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++
front-end/package.json | 5 +-
front-end/src/__mocks__/jiminny.js | 4 +-
front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +
front-end/src/__mocks__/setup.js | 1 +
front-end/src/apps/ai-reports-promo.js | 22 ++
front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++
front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++
front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++
front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++
front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++
.../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++
front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-
front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++
front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++
.../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++
front-end/src/components/AiReports/constants.js | 7 +
front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +
front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++
front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +
front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++
front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-
front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-
front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-
front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-
front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++
front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-
front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++
front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++
front-end/src/main.js | 1 +
front-end/src/store/modules/TeamInsights/util.js | 1 +
front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++
front-end/src/store/modules/platform/getters.js | 3 +
front-end/src/utils/index.js | 11 +
front-end/yarn.lock | 21 +-
phpstan-baseline.neon | 60 ----
public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes
public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes
public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes
public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes
public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes
public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes
public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes
public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes
resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++
resources/views/emails/reports/report-not-generated.blade.php | 24 ++
resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-
routes/api.php | 6 +
routes/web.php | 4 +
tests/Feature/Policies/UserPolicyTest.php | 90 ++++-
tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++
tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++
tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++
tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++
tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--
tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-
tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++
tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++
tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++
tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++
tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++
tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-
tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++
tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++
tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-
tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++
tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++
tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-
tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +
tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++
tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-
tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++
tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++
tests/Unit/Models/PartnerTest.php | 28 ++
tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++
tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-
tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++
tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--
tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++
tests/Unit/Services/KioskServiceTest.php | 8 +
tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-
tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++
tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++
tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----
186 files changed, 8538 insertions(+), 1233 deletions(-)
create mode 100644 app/Component/Twilio/TwilioRepository.php
delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php
create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php
delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php
create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php
create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php
create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php
create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php
create mode 100644 app/Mail/Reports/ReportNotGenerated.php
delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php
create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php
create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php
create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php
create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php
create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php
create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php
create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php
create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js
create mode 100644 front-end/src/apps/ai-reports-promo.js
create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html
create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js
create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js
create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js
create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/com/loss-report.pdf
create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf
create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf
create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf
create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php
create mode 100644 resources/views/emails/reports/report-not-generated.blade.php
create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php
create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php
create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php
create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php
create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php
create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php
create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php
create mode 100644 tests/Unit/Models/PartnerTest.php
create mode 100644 tests/Unit/Services/ActivityServiceTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 628, done.
remote: Counting objects: 100% (331/331), done.
remote: Compressing objects: 100% (63/63), done.
remote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)
Receiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.
Resolving deltas: 100% (391/391), completed with 57 local objects.
From github.com:jiminny/app
ad2ce76737..12295204cf master -> origin/master
14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests
f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech
+ ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)
* [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication
* [new branch] desktop-app -> origin/desktop-app
4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again
* [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas
Updating ad2ce76737..12295204cf
Fast-forward
app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-
app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------
app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------
tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------
tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -
11 files changed, 49 insertions(+), 90 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric
Switched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 15, done.
remote: Counting objects: 100% (15/15), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)
Unpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.
From github.com:jiminny/app
c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit
Already up to date.
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 180, done.
remote: Counting objects: 100% (179/179), done.
remote: Compressing objects: 100% (55/55), done.
remote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)
Receiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.
Resolving deltas: 100% (127/127), completed with 47 local objects.
From github.com:jiminny/app
12295204cf..35f036ace6 master -> origin/master
* [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5
5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories
* [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration
+ ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)
8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas
Updating 12295204cf..35f036ace6
Fast-forward
app/Component/Datadog/Constants.php | 1 +
app/Providers/RouteServiceProvider.php | 2 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------
4 files changed, 62 insertions(+), 8 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr
* master
JY-20818-move-AJ-reports-to-separated-datadog-metric
JY-20773-fix-automated-reports-user-pilot-tracking
JY-20157-AJ-report-not-send-notification
JY-20508-notify-before-AJ-report-expiration
JY-20372-ai-reports-promotion-pages
JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
JY-20738-debug-AJ-tracking-UP
a
JY-18909-automated-reports-ask-jiminny
JY-20692-fix-integration-app-[API_KEY]
JY-20553-debug-crm-sync-delays
JY-20698-fix-SF-activity-types-on-new-playbook
JY-20543-AJ-report-tracking
JY-20384-handle-auto-sync-with-no-access-to-event-type
JY-20458-ask-jiminny-user-definitions
JY-19666-fix-import-contacts-account-association
JY-19666-HS-import-contacts-and-accounts-batch-job
JY-20458-Ask-Jiminny-Reports
JY-20200-batch-update-CRM-objects-Salesforce
JY-19666-HS-webhooks-add-contact-and-company
JY-20348-trigger-setup-DI-layout-on-team-creation
JY-20326-refactor-info-message-in-command
JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled
JY-20312-remove-on-update-change-last-synced-at-crm-configurations
JY-20306-SF-skip-auto-sync-for-task-based-playbook
JY-20192-remove-deleted-team-from-saved-search-filters
JY-20197-import-opportunity-batch-job
JY-20293-enable-status-field-for-pipedrive-deals
JY-20191-remove-commands-interactive-prompts
JY-20118-change-default-sync-strategy
JY-20183-add-cache-on-auto-log-delay
JY-20197-add-import-opportunity-batch-job
20118-hs-opportunity-make-webhook-strategy-default
JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based
JY-20196-handle-opportunity-without-note
JY-20118-improve-opportunity-import
JY-20189-handle-activity-search-on-deleted-groups
JY-20160
JY-20145-filter-out-converted-leads-when-matching
JY-20150-skip-push-summary-on-summary-ready-if-autolog
JY-20132-fix-note-encoding
JY-19792-clean-logs
JY-20117-fix-sync-profile-fields-on-empty-fields
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit
Switched to a new branch 'JY-20725-handle-HS-search-rate-limit'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd
docker exec -it docker_lamp_1 bash -c "mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini"
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 supervisorctl restart all
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped
jiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped
jiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped
jiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped
jiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped
worker-analytics:worker-analytics_00: stopped
worker-crm-update:worker-crm-update_00: stopped
worker-download:worker-download_00: stopped
worker-nudges:worker-nudges_00: stopped
artisan-schedule:artisan-schedule_00: stopped
worker-emails:worker-emails_00: stopped
worker:worker_00: stopped
worker-calendar:worker-calendar_00: stopped
jiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped
worker-crm-sync:worker-crm-sync_00: stopped
worker-audio:worker-audio_00: stopped
worker-conferences:worker-conferences_00: stopped
worker-es-update:worker-es-update_00: stopped
artisan-schedule:artisan-schedule_00: started
jiminny-worker-processing-1:jiminny-worker-processing-1_00: started
jiminny-worker-processing-2:jiminny-worker-processing-2_00: started
jiminny-worker-processing-3:jiminny-worker-processing-3_00: started
jiminny-worker-processing-4:jiminny-worker-processing-4_00: started
jiminny-worker-processing-5:jiminny-worker-processing-5_00: started
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started
worker:worker_00: started
worker-analytics:worker-analytics_00: started
worker-audio:worker-audio_00: started
worker-calendar:worker-calendar_00: started
worker-conferences:worker-conferences_00: started
worker-crm-sync:worker-crm-sync_00: started
worker-crm-update:worker-crm-update_00: started
worker-download:worker-download_00: started
worker-emails:worker-emails_00: started
worker-es-update:worker-es-update_00: started
worker-nudges:worker-nudges_00: started
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 php -v
PHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)
Copyright (c) The PHP Group
Zend En...
|
20462
|
NULL
|
NULL
|
NULL
|
|
20490
|
NULL
|
0
|
2026-05-11T15:39:16.109624+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778513956109_m2.jpg...
|
iTerm2
|
APP (-zsh)
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Last login: Thu May 7 09:44:56 on ttys007
Poetry Last login: Thu May 7 09:44:56 on ttys007
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status
On branch master
Your branch is up to date with 'origin/master'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: .env.local
modified: app/Console/Commands/JiminnyDebugCommand.php
modified: app/Jobs/Team/SyncToIntercom.php
modified: app/Services/PlaybackService.php
modified: config/logging.php
modified: resources/views/partials/crm/push-summary/html-assembly.blade.php
Untracked files:
(use "git add <file>..." to include in what will be committed)
.env.nikilocal
.env.other
WEBHOOK_FILTERING_IMPLEMENTATION.md
app/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php
app/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php
ids.txt
public/favicon.ico
raw_sql_query.sql
tests/Unit/Policies/CanAccessAiReportsTest.php
no changes added to commit (use "git add" and/or "git commit -a")
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 1482, done.
remote: Counting objects: 100% (481/481), done.
remote: Compressing objects: 100% (191/191), done.
remote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)
Receiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.
Resolving deltas: 100% (877/877), completed with 96 local objects.
From github.com:jiminny/app
83b628967a..ad2ce76737 master -> origin/master
1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests
b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
* [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost
* [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc
* [new branch] make-claude-great-again -> origin/make-claude-great-again
* [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507
* [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
Fast-forward
.cursor/rules/frontend-conventions.mdc | 23 ++
.env.production-eu | 2 +-
.env.staging | 2 +-
Makefile | 10 +
app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-
app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-
app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--
app/Component/AskAnything/AskAnythingPromptService.php | 3 +
app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-
app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-
app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-
app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---
app/Component/Twilio/TwilioRepository.php | 27 ++
app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----
app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--
app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++
app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----
app/Console/Commands/Users/SyncToIntercom.php | 4 +-
app/Console/Kernel.php | 3 +-
app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -
app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -
app/Contracts/Repositories/TeamRepository.php | 3 +-
app/Events/Activities/ActivityUpdated.php | 10 +-
app/Events/Activities/Audio/RecordingEvent.php | 6 +-
app/Events/Activities/Softphone/Ended.php | 8 +-
app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-
app/Events/Activities/Softphone/Started.php | 8 +-
app/Http/Controllers/API/ActivityController.php | 17 +-
app/Http/Controllers/API/SoftphoneController.php | 9 +-
app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-
app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-
app/Http/Controllers/Auth/SocialController.php | 6 +-
app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-
app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-
app/Http/Controllers/Kiosk/PartnersController.php | 46 +++
app/Http/Controllers/Kiosk/SearchController.php | 8 +
app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-
app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-
app/Http/Controllers/TeamSetupController.php | 4 +-
app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-
app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-
app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +
app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +
app/Http/Transformers/ActivityTransformer.php | 4 +-
app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-
app/Http/Transformers/PartnerTransformer.php | 1 +
app/Http/Transformers/StageTransformer.php | 6 +-
app/Http/Transformers/UserTransformer.php | 11 +-
app/Interactions/Settings/Teams/CreateTeam.php | 3 +
app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-
app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++
app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++
app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-
app/Jobs/Crm/UpdateStage.php | 3 +
app/Jobs/Team/SyncToIntercom.php | 7 +-
app/Listeners/Teams/SyncIntercomCompany.php | 5 +-
app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-
app/Listeners/Users/SyncIntercom.php | 5 +-
app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++
app/Mail/Reports/ReportNotGenerated.php | 41 +++
app/Models/Activity.php | 25 +-
app/Models/Activity/Question.php | 14 +-
app/Models/Activity/Search.php | 7 +
app/Models/AskAnything/AskAnythingPrompt.php | 6 +
app/Models/AutomatedReport.php | 10 +
app/Models/CoachingFeedback.php | 44 ++-
app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----
app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----
app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --
app/Models/Partner.php | 13 +
app/Models/Playlist/Activity.php | 14 +-
app/Notifications/OwnerInvitedToTrial.php | 14 +-
app/Policies/UserPolicy.php | 16 +-
app/Queue/Worker/Worker.php | 3 +-
app/Repositories/ActivityRepository.php | 13 +-
app/Repositories/AutomatedReportsRepository.php | 42 ++-
app/Repositories/TeamRepository.php | 21 +-
app/Repositories/UserRepository.php | 2 +-
app/Services/Activity/MeetingBotService.php | 8 +-
app/Services/ActivityService.php | 111 ++-----
app/Services/Crm/Hubspot/Service.php | 36 +-
app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-
app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--
app/Services/Kiosk/KioskService.php | 7 +-
app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-
app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++
app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++
app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++
app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----
composer.json | 1 -
composer.lock | 95 +-----
config/secure-headers.php | 5 +-
database/mappings/mapping_activities.json | 16 +
database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++
database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++
database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++
database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++
front-end/package.json | 5 +-
front-end/src/__mocks__/jiminny.js | 4 +-
front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +
front-end/src/__mocks__/setup.js | 1 +
front-end/src/apps/ai-reports-promo.js | 22 ++
front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++
front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++
front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++
front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++
front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++
.../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++
front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-
front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++
front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++
.../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++
front-end/src/components/AiReports/constants.js | 7 +
front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +
front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++
front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +
front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++
front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-
front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-
front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-
front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-
front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++
front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-
front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++
front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++
front-end/src/main.js | 1 +
front-end/src/store/modules/TeamInsights/util.js | 1 +
front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++
front-end/src/store/modules/platform/getters.js | 3 +
front-end/src/utils/index.js | 11 +
front-end/yarn.lock | 21 +-
phpstan-baseline.neon | 60 ----
public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes
public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes
public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes
public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes
public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes
public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes
public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes
public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes
resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++
resources/views/emails/reports/report-not-generated.blade.php | 24 ++
resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-
routes/api.php | 6 +
routes/web.php | 4 +
tests/Feature/Policies/UserPolicyTest.php | 90 ++++-
tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++
tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++
tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++
tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++
tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--
tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-
tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++
tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++
tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++
tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++
tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++
tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-
tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++
tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++
tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-
tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++
tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++
tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-
tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +
tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++
tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-
tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++
tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++
tests/Unit/Models/PartnerTest.php | 28 ++
tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++
tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-
tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++
tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--
tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++
tests/Unit/Services/KioskServiceTest.php | 8 +
tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-
tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++
tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++
tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----
186 files changed, 8538 insertions(+), 1233 deletions(-)
create mode 100644 app/Component/Twilio/TwilioRepository.php
delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php
create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php
delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php
create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php
create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php
create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php
create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php
create mode 100644 app/Mail/Reports/ReportNotGenerated.php
delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php
create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php
create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php
create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php
create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php
create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php
create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php
create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php
create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js
create mode 100644 front-end/src/apps/ai-reports-promo.js
create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html
create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js
create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js
create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js
create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/com/loss-report.pdf
create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf
create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf
create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf
create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php
create mode 100644 resources/views/emails/reports/report-not-generated.blade.php
create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php
create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php
create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php
create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php
create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php
create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php
create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php
create mode 100644 tests/Unit/Models/PartnerTest.php
create mode 100644 tests/Unit/Services/ActivityServiceTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 628, done.
remote: Counting objects: 100% (331/331), done.
remote: Compressing objects: 100% (63/63), done.
remote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)
Receiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.
Resolving deltas: 100% (391/391), completed with 57 local objects.
From github.com:jiminny/app
ad2ce76737..12295204cf master -> origin/master
14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests
f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech
+ ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)
* [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication
* [new branch] desktop-app -> origin/desktop-app
4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again
* [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas
Updating ad2ce76737..12295204cf
Fast-forward
app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-
app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------
app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------
tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------
tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -
11 files changed, 49 insertions(+), 90 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric
Switched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 15, done.
remote: Counting objects: 100% (15/15), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)
Unpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.
From github.com:jiminny/app
c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit
Already up to date.
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 180, done.
remote: Counting objects: 100% (179/179), done.
remote: Compressing objects: 100% (55/55), done.
remote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)
Receiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.
Resolving deltas: 100% (127/127), completed with 47 local objects.
From github.com:jiminny/app
12295204cf..35f036ace6 master -> origin/master
* [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5
5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories
* [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration
+ ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)
8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas
Updating 12295204cf..35f036ace6
Fast-forward
app/Component/Datadog/Constants.php | 1 +
app/Providers/RouteServiceProvider.php | 2 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------
4 files changed, 62 insertions(+), 8 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr
* master
JY-20818-move-AJ-reports-to-separated-datadog-metric
JY-20773-fix-automated-reports-user-pilot-tracking
JY-20157-AJ-report-not-send-notification
JY-20508-notify-before-AJ-report-expiration
JY-20372-ai-reports-promotion-pages
JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
JY-20738-debug-AJ-tracking-UP
a
JY-18909-automated-reports-ask-jiminny
JY-20692-fix-integration-app-[API_KEY]
JY-20553-debug-crm-sync-delays
JY-20698-fix-SF-activity-types-on-new-playbook
JY-20543-AJ-report-tracking
JY-20384-handle-auto-sync-with-no-access-to-event-type
JY-20458-ask-jiminny-user-definitions
JY-19666-fix-import-contacts-account-association
JY-19666-HS-import-contacts-and-accounts-batch-job
JY-20458-Ask-Jiminny-Reports
JY-20200-batch-update-CRM-objects-Salesforce
JY-19666-HS-webhooks-add-contact-and-company
JY-20348-trigger-setup-DI-layout-on-team-creation
JY-20326-refactor-info-message-in-command
JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled
JY-20312-remove-on-update-change-last-synced-at-crm-configurations
JY-20306-SF-skip-auto-sync-for-task-based-playbook
JY-20192-remove-deleted-team-from-saved-search-filters
JY-20197-import-opportunity-batch-job
JY-20293-enable-status-field-for-pipedrive-deals
JY-20191-remove-commands-interactive-prompts
JY-20118-change-default-sync-strategy
JY-20183-add-cache-on-auto-log-delay
JY-20197-add-import-opportunity-batch-job
20118-hs-opportunity-make-webhook-strategy-default
JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based
JY-20196-handle-opportunity-without-note
JY-20118-improve-opportunity-import
JY-20189-handle-activity-search-on-deleted-groups
JY-20160
JY-20145-filter-out-converted-leads-when-matching
JY-20150-skip-push-summary-on-summary-ready-if-autolog
JY-20132-fix-note-encoding
JY-19792-clean-logs
JY-20117-fix-sync-profile-fields-on-empty-fields
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit
Switched to a new branch 'JY-20725-handle-HS-search-rate-limit'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd
docker exec -it docker_lamp_1 bash -c "mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini"
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 supervisorctl restart all
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped
jiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped
jiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped
jiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped
jiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped
worker-analytics:worker-analytics_00: stopped
worker-crm-update:worker-crm-update_00: stopped
worker-download:worker-download_00: stopped
worker-nudges:worker-nudges_00: stopped
artisan-schedule:artisan-schedule_00: stopped
worker-emails:worker-emails_00: stopped
worker:worker_00: stopped
worker-calendar:worker-calendar_00: stopped
jiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped
worker-crm-sync:worker-crm-sync_00: stopped
worker-audio:worker-audio_00: stopped
worker-conferences:worker-conferences_00: stopped
worker-es-update:worker-es-update_00: stopped
artisan-schedule:artisan-schedule_00: started
jiminny-worker-processing-1:jiminny-worker-processing-1_00: started
jiminny-worker-processing-2:jiminny-worker-processing-2_00: started
jiminny-worker-processing-3:jiminny-worker-processing-3_00: started
jiminny-worker-processing-4:jiminny-worker-processing-4_00: started
jiminny-worker-processing-5:jiminny-worker-processing-5_00: started
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started
worker:worker_00: started
worker-analytics:worker-analytics_00: started
worker-audio:worker-audio_00: started
worker-calendar:worker-calendar_00: started
worker-conferences:worker-conferences_00: started
worker-crm-sync:worker-crm-sync_00: started
worker-crm-update:worker-crm-update_00: started
worker-download:worker-download_00: started
worker-emails:worker-emails_00: started
worker-es-update:worker-es-update_00: started
worker-nudges:worker-nudges_00: started
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 php -v
PHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)
Copyright (c) The PHP Group
Zend En...
|
[{"role":"AXTextArea","text [{"role":"AXTextArea","text":"Last login: Thu May 7 09:44:56 on ttys007\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Console/Commands/JiminnyDebugCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Services/PlaybackService.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: config/logging.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: resources/views/partials/crm/push-summary/html-assembly.blade.php\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 1482, done.\nremote: Counting objects: 100% (481/481), done.\nremote: Compressing objects: 100% (191/191), done.\nremote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)\nReceiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.\nResolving deltas: 100% (877/877), completed with 96 local objects.\nFrom github.com:jiminny/app\n 83b628967a..ad2ce76737 master -> origin/master\n 1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n 5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests\n b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n * [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost\n * [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc\n * [new branch] make-claude-great-again -> origin/make-claude-great-again\n * [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507\n * [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nFast-forward\n .cursor/rules/frontend-conventions.mdc | 23 ++\n .env.production-eu | 2 +-\n .env.staging | 2 +-\n Makefile | 10 +\n app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-\n app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-\n app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--\n app/Component/AskAnything/AskAnythingPromptService.php | 3 +\n app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-\n app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-\n app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-\n app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---\n app/Component/Twilio/TwilioRepository.php | 27 ++\n app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----\n app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--\n app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++\n app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----\n app/Console/Commands/Users/SyncToIntercom.php | 4 +-\n app/Console/Kernel.php | 3 +-\n app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -\n app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -\n app/Contracts/Repositories/TeamRepository.php | 3 +-\n app/Events/Activities/ActivityUpdated.php | 10 +-\n app/Events/Activities/Audio/RecordingEvent.php | 6 +-\n app/Events/Activities/Softphone/Ended.php | 8 +-\n app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-\n app/Events/Activities/Softphone/Started.php | 8 +-\n app/Http/Controllers/API/ActivityController.php | 17 +-\n app/Http/Controllers/API/SoftphoneController.php | 9 +-\n app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-\n app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-\n app/Http/Controllers/Auth/SocialController.php | 6 +-\n app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-\n app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-\n app/Http/Controllers/Kiosk/PartnersController.php | 46 +++\n app/Http/Controllers/Kiosk/SearchController.php | 8 +\n app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-\n app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-\n app/Http/Controllers/TeamSetupController.php | 4 +-\n app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-\n app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-\n app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +\n app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +\n app/Http/Transformers/ActivityTransformer.php | 4 +-\n app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-\n app/Http/Transformers/PartnerTransformer.php | 1 +\n app/Http/Transformers/StageTransformer.php | 6 +-\n app/Http/Transformers/UserTransformer.php | 11 +-\n app/Interactions/Settings/Teams/CreateTeam.php | 3 +\n app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-\n app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++\n app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++\n app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-\n app/Jobs/Crm/UpdateStage.php | 3 +\n app/Jobs/Team/SyncToIntercom.php | 7 +-\n app/Listeners/Teams/SyncIntercomCompany.php | 5 +-\n app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-\n app/Listeners/Users/SyncIntercom.php | 5 +-\n app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++\n app/Mail/Reports/ReportNotGenerated.php | 41 +++\n app/Models/Activity.php | 25 +-\n app/Models/Activity/Question.php | 14 +-\n app/Models/Activity/Search.php | 7 +\n app/Models/AskAnything/AskAnythingPrompt.php | 6 +\n app/Models/AutomatedReport.php | 10 +\n app/Models/CoachingFeedback.php | 44 ++-\n app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----\n app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----\n app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --\n app/Models/Partner.php | 13 +\n app/Models/Playlist/Activity.php | 14 +-\n app/Notifications/OwnerInvitedToTrial.php | 14 +-\n app/Policies/UserPolicy.php | 16 +-\n app/Queue/Worker/Worker.php | 3 +-\n app/Repositories/ActivityRepository.php | 13 +-\n app/Repositories/AutomatedReportsRepository.php | 42 ++-\n app/Repositories/TeamRepository.php | 21 +-\n app/Repositories/UserRepository.php | 2 +-\n app/Services/Activity/MeetingBotService.php | 8 +-\n app/Services/ActivityService.php | 111 ++-----\n app/Services/Crm/Hubspot/Service.php | 36 +-\n app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--\n app/Services/Kiosk/KioskService.php | 7 +-\n app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-\n app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++\n app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++\n app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++\n app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----\n composer.json | 1 -\n composer.lock | 95 +-----\n config/secure-headers.php | 5 +-\n database/mappings/mapping_activities.json | 16 +\n database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++\n database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++\n database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++\n database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++\n front-end/package.json | 5 +-\n front-end/src/__mocks__/jiminny.js | 4 +-\n front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +\n front-end/src/__mocks__/setup.js | 1 +\n front-end/src/apps/ai-reports-promo.js | 22 ++\n front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++\n front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++\n .../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++\n front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-\n front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++\n front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++\n .../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++\n front-end/src/components/AiReports/constants.js | 7 +\n front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +\n front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++\n front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +\n front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++\n front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-\n front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-\n front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-\n front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-\n front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++\n front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-\n front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++\n front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++\n front-end/src/main.js | 1 +\n front-end/src/store/modules/TeamInsights/util.js | 1 +\n front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++\n front-end/src/store/modules/platform/getters.js | 3 +\n front-end/src/utils/index.js | 11 +\n front-end/yarn.lock | 21 +-\n phpstan-baseline.neon | 60 ----\n public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes\n public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes\n public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes\n public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes\n public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes\n public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes\n public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes\n public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes\n resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++\n resources/views/emails/reports/report-not-generated.blade.php | 24 ++\n resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-\n routes/api.php | 6 +\n routes/web.php | 4 +\n tests/Feature/Policies/UserPolicyTest.php | 90 ++++-\n tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++\n tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++\n tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++\n tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++\n tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--\n tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-\n tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++\n tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++\n tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++\n tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++\n tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++\n tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-\n tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++\n tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++\n tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-\n tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++\n tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++\n tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-\n tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +\n tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++\n tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-\n tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++\n tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++\n tests/Unit/Models/PartnerTest.php | 28 ++\n tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++\n tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-\n tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++\n tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--\n tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++\n tests/Unit/Services/KioskServiceTest.php | 8 +\n tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-\n tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++\n tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++\n tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----\n 186 files changed, 8538 insertions(+), 1233 deletions(-)\n create mode 100644 app/Component/Twilio/TwilioRepository.php\n delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php\n create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php\n delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php\n create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php\n create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php\n create mode 100644 app/Mail/Reports/ReportNotGenerated.php\n delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php\n create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php\n create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php\n create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php\n create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php\n create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php\n create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php\n create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php\n create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js\n create mode 100644 front-end/src/apps/ai-reports-promo.js\n create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html\n create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js\n create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js\n create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js\n create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/com/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf\n create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf\n create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php\n create mode 100644 resources/views/emails/reports/report-not-generated.blade.php\n create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php\n create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php\n create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php\n create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php\n create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php\n create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php\n create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php\n create mode 100644 tests/Unit/Models/PartnerTest.php\n create mode 100644 tests/Unit/Services/ActivityServiceTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 628, done.\nremote: Counting objects: 100% (331/331), done.\nremote: Compressing objects: 100% (63/63), done.\nremote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)\nReceiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.\nResolving deltas: 100% (391/391), completed with 57 local objects.\nFrom github.com:jiminny/app\n ad2ce76737..12295204cf master -> origin/master\n 14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests\n f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n 1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech\n + ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)\n * [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication\n * [new branch] desktop-app -> origin/desktop-app\n 4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again\n * [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating ad2ce76737..12295204cf\nFast-forward\n app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-\n app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------\n app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------\n tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------\n tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -\n 11 files changed, 49 insertions(+), 90 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric\nSwitched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-calendar:worker-calendar_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker:worker_00: stopped\nworker-emails:worker-emails_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 15, done.\nremote: Counting objects: 100% (15/15), done.\nremote: Compressing objects: 100% (2/2), done.\nremote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)\nUnpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.\nFrom github.com:jiminny/app\n c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit\nAlready up to date.\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 180, done.\nremote: Counting objects: 100% (179/179), done.\nremote: Compressing objects: 100% (55/55), done.\nremote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)\nReceiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.\nResolving deltas: 100% (127/127), completed with 47 local objects.\nFrom github.com:jiminny/app\n 12295204cf..35f036ace6 master -> origin/master\n * [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5\n 5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories\n * [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration\n + ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)\n 8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating 12295204cf..35f036ace6\nFast-forward\n app/Component/Datadog/Constants.php | 1 +\n app/Providers/RouteServiceProvider.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------\n 4 files changed, 62 insertions(+), 8 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr \n* master\n JY-20818-move-AJ-reports-to-separated-datadog-metric\n JY-20773-fix-automated-reports-user-pilot-tracking\n JY-20157-AJ-report-not-send-notification\n JY-20508-notify-before-AJ-report-expiration\n JY-20372-ai-reports-promotion-pages\n JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n JY-20738-debug-AJ-tracking-UP\n a\n JY-18909-automated-reports-ask-jiminny\n JY-20692-fix-integration-app-token-auth-response-change\n JY-20553-debug-crm-sync-delays\n JY-20698-fix-SF-activity-types-on-new-playbook\n JY-20543-AJ-report-tracking\n JY-20384-handle-auto-sync-with-no-access-to-event-type\n JY-20458-ask-jiminny-user-definitions\n JY-19666-fix-import-contacts-account-association\n JY-19666-HS-import-contacts-and-accounts-batch-job\n JY-20458-Ask-Jiminny-Reports\n JY-20200-batch-update-CRM-objects-Salesforce\n JY-19666-HS-webhooks-add-contact-and-company\n JY-20348-trigger-setup-DI-layout-on-team-creation\n JY-20326-refactor-info-message-in-command\n JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled\n JY-20312-remove-on-update-change-last-synced-at-crm-configurations\n JY-20306-SF-skip-auto-sync-for-task-based-playbook\n JY-20192-remove-deleted-team-from-saved-search-filters\n JY-20197-import-opportunity-batch-job\n JY-20293-enable-status-field-for-pipedrive-deals\n JY-20191-remove-commands-interactive-prompts\n JY-20118-change-default-sync-strategy\n JY-20183-add-cache-on-auto-log-delay\n JY-20197-add-import-opportunity-batch-job\n 20118-hs-opportunity-make-webhook-strategy-default\n JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based\n JY-20196-handle-opportunity-without-note\n JY-20118-improve-opportunity-import\n JY-20189-handle-activity-search-on-deleted-groups\n JY-20160\n JY-20145-filter-out-converted-leads-when-matching\n JY-20150-skip-push-summary-on-summary-ready-if-autolog\n JY-20132-fix-note-encoding\n JY-19792-clean-logs\n JY-20117-fix-sync-profile-fields-on-empty-fields\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit \nSwitched to a new branch 'JY-20725-handle-HS-search-rate-limit'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-emails:worker-emails_00: stopped\nworker:worker_00: stopped\nworker-calendar:worker-calendar_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n 1) app/Jobs/Crm/MatchActivityCrmData.php (ordered_class_elements)\n ---------- begin diff ----------\n--- /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n+++ /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n@@ -29,24 +29,14 @@\n use InteractsWithQueue;\n use SerializesModels;\n \n+ private const int RETRY_WINDOW_MINUTES = 120;\n+\n public int $maxExceptions = 3;\n \n- private const int RETRY_WINDOW_MINUTES = 120;\n-\n private int $activityId;\n private ?Configuration $fromConfiguration;\n private bool $remoteSearch;\n \n- public function middleware(): array\n- {\n- return [new HandleHubspotRateLimit()];\n- }\n-\n- public function retryUntil(): \\DateTimeInterface\n- {\n- return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n- }\n-\n public function __construct(\n int $activityId,\n ?Configuration $fromConfiguration = null,\n@@ -57,6 +47,16 @@\n $this->remoteSearch = $remoteSearch;\n \n $this->onQueue(Constants::QUEUE_ANALYTICS_LOW);\n+ }\n+\n+ public function middleware(): array\n+ {\n+ return [new HandleHubspotRateLimit()];\n+ }\n+\n+ public function retryUntil(): \\DateTimeInterface\n+ {\n+ return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n }\n \n public function uniqueId(): string\n\n ----------- end diff -----------\n\n 2) app/Services/Crm/Hubspot/HubspotClientInterface.php (phpdoc_order, phpdoc_separation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n@@ -46,9 +46,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 3) app/Services/Crm/Hubspot/Client.php (phpdoc_order, phpdoc_separation, blank_line_before_statement)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/Client.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/Client.php\n@@ -69,10 +69,12 @@\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- * @return T The result of the API call\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@@ -81,6 +83,7 @@\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@@ -232,9 +235,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 4) app/Console/Commands/JiminnyDebugCommand.php (statement_indentation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n+++ /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n@@ -359,11 +359,11 @@\n $crmService = $crmResolver->prepareCrmService();\n \n for ($i = 0 ; $i < 3; $i++) {\n-// if ($i % 25 === 0) {\n-// $this->info(\"Syncing opportunity {$i}\");\n- $this->info(\"Matching contact {$i}\");\n-// }\n-// $crmService->syncOpportunity('374720564');\n+ // if ($i % 25 === 0) {\n+ // $this->info(\"Syncing opportunity {$i}\");\n+ $this->info(\"Matching contact {$i}\");\n+ // }\n+ // $crmService->syncOpportunity('374720564');\n $crmService->matchByName('Robot');\n }\n }\n\n ----------- end diff -----------\n\n\nFixed 4 of 5666 files in 146.870 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix \ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5666 files in 106.192 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5666 files in 66.457 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $","depth":4,"on_screen":true,"value":"Last login: Thu May 7 09:44:56 on ttys007\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Console/Commands/JiminnyDebugCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Services/PlaybackService.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: config/logging.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: resources/views/partials/crm/push-summary/html-assembly.blade.php\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 1482, done.\nremote: Counting objects: 100% (481/481), done.\nremote: Compressing objects: 100% (191/191), done.\nremote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)\nReceiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.\nResolving deltas: 100% (877/877), completed with 96 local objects.\nFrom github.com:jiminny/app\n 83b628967a..ad2ce76737 master -> origin/master\n 1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n 5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests\n b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n * [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost\n * [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc\n * [new branch] make-claude-great-again -> origin/make-claude-great-again\n * [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507\n * [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nFast-forward\n .cursor/rules/frontend-conventions.mdc | 23 ++\n .env.production-eu | 2 +-\n .env.staging | 2 +-\n Makefile | 10 +\n app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-\n app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-\n app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--\n app/Component/AskAnything/AskAnythingPromptService.php | 3 +\n app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-\n app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-\n app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-\n app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---\n app/Component/Twilio/TwilioRepository.php | 27 ++\n app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----\n app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--\n app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++\n app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----\n app/Console/Commands/Users/SyncToIntercom.php | 4 +-\n app/Console/Kernel.php | 3 +-\n app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -\n app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -\n app/Contracts/Repositories/TeamRepository.php | 3 +-\n app/Events/Activities/ActivityUpdated.php | 10 +-\n app/Events/Activities/Audio/RecordingEvent.php | 6 +-\n app/Events/Activities/Softphone/Ended.php | 8 +-\n app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-\n app/Events/Activities/Softphone/Started.php | 8 +-\n app/Http/Controllers/API/ActivityController.php | 17 +-\n app/Http/Controllers/API/SoftphoneController.php | 9 +-\n app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-\n app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-\n app/Http/Controllers/Auth/SocialController.php | 6 +-\n app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-\n app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-\n app/Http/Controllers/Kiosk/PartnersController.php | 46 +++\n app/Http/Controllers/Kiosk/SearchController.php | 8 +\n app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-\n app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-\n app/Http/Controllers/TeamSetupController.php | 4 +-\n app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-\n app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-\n app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +\n app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +\n app/Http/Transformers/ActivityTransformer.php | 4 +-\n app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-\n app/Http/Transformers/PartnerTransformer.php | 1 +\n app/Http/Transformers/StageTransformer.php | 6 +-\n app/Http/Transformers/UserTransformer.php | 11 +-\n app/Interactions/Settings/Teams/CreateTeam.php | 3 +\n app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-\n app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++\n app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++\n app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-\n app/Jobs/Crm/UpdateStage.php | 3 +\n app/Jobs/Team/SyncToIntercom.php | 7 +-\n app/Listeners/Teams/SyncIntercomCompany.php | 5 +-\n app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-\n app/Listeners/Users/SyncIntercom.php | 5 +-\n app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++\n app/Mail/Reports/ReportNotGenerated.php | 41 +++\n app/Models/Activity.php | 25 +-\n app/Models/Activity/Question.php | 14 +-\n app/Models/Activity/Search.php | 7 +\n app/Models/AskAnything/AskAnythingPrompt.php | 6 +\n app/Models/AutomatedReport.php | 10 +\n app/Models/CoachingFeedback.php | 44 ++-\n app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----\n app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----\n app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --\n app/Models/Partner.php | 13 +\n app/Models/Playlist/Activity.php | 14 +-\n app/Notifications/OwnerInvitedToTrial.php | 14 +-\n app/Policies/UserPolicy.php | 16 +-\n app/Queue/Worker/Worker.php | 3 +-\n app/Repositories/ActivityRepository.php | 13 +-\n app/Repositories/AutomatedReportsRepository.php | 42 ++-\n app/Repositories/TeamRepository.php | 21 +-\n app/Repositories/UserRepository.php | 2 +-\n app/Services/Activity/MeetingBotService.php | 8 +-\n app/Services/ActivityService.php | 111 ++-----\n app/Services/Crm/Hubspot/Service.php | 36 +-\n app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--\n app/Services/Kiosk/KioskService.php | 7 +-\n app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-\n app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++\n app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++\n app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++\n app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----\n composer.json | 1 -\n composer.lock | 95 +-----\n config/secure-headers.php | 5 +-\n database/mappings/mapping_activities.json | 16 +\n database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++\n database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++\n database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++\n database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++\n front-end/package.json | 5 +-\n front-end/src/__mocks__/jiminny.js | 4 +-\n front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +\n front-end/src/__mocks__/setup.js | 1 +\n front-end/src/apps/ai-reports-promo.js | 22 ++\n front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++\n front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++\n .../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++\n front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-\n front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++\n front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++\n .../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++\n front-end/src/components/AiReports/constants.js | 7 +\n front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +\n front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++\n front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +\n front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++\n front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-\n front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-\n front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-\n front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-\n front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++\n front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-\n front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++\n front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++\n front-end/src/main.js | 1 +\n front-end/src/store/modules/TeamInsights/util.js | 1 +\n front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++\n front-end/src/store/modules/platform/getters.js | 3 +\n front-end/src/utils/index.js | 11 +\n front-end/yarn.lock | 21 +-\n phpstan-baseline.neon | 60 ----\n public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes\n public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes\n public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes\n public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes\n public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes\n public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes\n public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes\n public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes\n resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++\n resources/views/emails/reports/report-not-generated.blade.php | 24 ++\n resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-\n routes/api.php | 6 +\n routes/web.php | 4 +\n tests/Feature/Policies/UserPolicyTest.php | 90 ++++-\n tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++\n tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++\n tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++\n tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++\n tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--\n tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-\n tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++\n tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++\n tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++\n tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++\n tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++\n tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-\n tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++\n tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++\n tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-\n tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++\n tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++\n tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-\n tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +\n tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++\n tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-\n tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++\n tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++\n tests/Unit/Models/PartnerTest.php | 28 ++\n tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++\n tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-\n tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++\n tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--\n tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++\n tests/Unit/Services/KioskServiceTest.php | 8 +\n tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-\n tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++\n tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++\n tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----\n 186 files changed, 8538 insertions(+), 1233 deletions(-)\n create mode 100644 app/Component/Twilio/TwilioRepository.php\n delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php\n create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php\n delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php\n create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php\n create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php\n create mode 100644 app/Mail/Reports/ReportNotGenerated.php\n delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php\n create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php\n create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php\n create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php\n create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php\n create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php\n create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php\n create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php\n create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js\n create mode 100644 front-end/src/apps/ai-reports-promo.js\n create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html\n create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js\n create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js\n create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js\n create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/com/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf\n create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf\n create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php\n create mode 100644 resources/views/emails/reports/report-not-generated.blade.php\n create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php\n create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php\n create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php\n create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php\n create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php\n create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php\n create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php\n create mode 100644 tests/Unit/Models/PartnerTest.php\n create mode 100644 tests/Unit/Services/ActivityServiceTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 628, done.\nremote: Counting objects: 100% (331/331), done.\nremote: Compressing objects: 100% (63/63), done.\nremote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)\nReceiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.\nResolving deltas: 100% (391/391), completed with 57 local objects.\nFrom github.com:jiminny/app\n ad2ce76737..12295204cf master -> origin/master\n 14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests\n f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n 1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech\n + ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)\n * [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication\n * [new branch] desktop-app -> origin/desktop-app\n 4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again\n * [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating ad2ce76737..12295204cf\nFast-forward\n app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-\n app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------\n app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------\n tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------\n tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -\n 11 files changed, 49 insertions(+), 90 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric\nSwitched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-calendar:worker-calendar_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker:worker_00: stopped\nworker-emails:worker-emails_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 15, done.\nremote: Counting objects: 100% (15/15), done.\nremote: Compressing objects: 100% (2/2), done.\nremote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)\nUnpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.\nFrom github.com:jiminny/app\n c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit\nAlready up to date.\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 180, done.\nremote: Counting objects: 100% (179/179), done.\nremote: Compressing objects: 100% (55/55), done.\nremote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)\nReceiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.\nResolving deltas: 100% (127/127), completed with 47 local objects.\nFrom github.com:jiminny/app\n 12295204cf..35f036ace6 master -> origin/master\n * [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5\n 5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories\n * [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration\n + ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)\n 8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating 12295204cf..35f036ace6\nFast-forward\n app/Component/Datadog/Constants.php | 1 +\n app/Providers/RouteServiceProvider.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------\n 4 files changed, 62 insertions(+), 8 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr \n* master\n JY-20818-move-AJ-reports-to-separated-datadog-metric\n JY-20773-fix-automated-reports-user-pilot-tracking\n JY-20157-AJ-report-not-send-notification\n JY-20508-notify-before-AJ-report-expiration\n JY-20372-ai-reports-promotion-pages\n JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n JY-20738-debug-AJ-tracking-UP\n a\n JY-18909-automated-reports-ask-jiminny\n JY-20692-fix-integration-app-token-auth-response-change\n JY-20553-debug-crm-sync-delays\n JY-20698-fix-SF-activity-types-on-new-playbook\n JY-20543-AJ-report-tracking\n JY-20384-handle-auto-sync-with-no-access-to-event-type\n JY-20458-ask-jiminny-user-definitions\n JY-19666-fix-import-contacts-account-association\n JY-19666-HS-import-contacts-and-accounts-batch-job\n JY-20458-Ask-Jiminny-Reports\n JY-20200-batch-update-CRM-objects-Salesforce\n JY-19666-HS-webhooks-add-contact-and-company\n JY-20348-trigger-setup-DI-layout-on-team-creation\n JY-20326-refactor-info-message-in-command\n JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled\n JY-20312-remove-on-update-change-last-synced-at-crm-configurations\n JY-20306-SF-skip-auto-sync-for-task-based-playbook\n JY-20192-remove-deleted-team-from-saved-search-filters\n JY-20197-import-opportunity-batch-job\n JY-20293-enable-status-field-for-pipedrive-deals\n JY-20191-remove-commands-interactive-prompts\n JY-20118-change-default-sync-strategy\n JY-20183-add-cache-on-auto-log-delay\n JY-20197-add-import-opportunity-batch-job\n 20118-hs-opportunity-make-webhook-strategy-default\n JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based\n JY-20196-handle-opportunity-without-note\n JY-20118-improve-opportunity-import\n JY-20189-handle-activity-search-on-deleted-groups\n JY-20160\n JY-20145-filter-out-converted-leads-when-matching\n JY-20150-skip-push-summary-on-summary-ready-if-autolog\n JY-20132-fix-note-encoding\n JY-19792-clean-logs\n JY-20117-fix-sync-profile-fields-on-empty-fields\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit \nSwitched to a new branch 'JY-20725-handle-HS-search-rate-limit'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-emails:worker-emails_00: stopped\nworker:worker_00: stopped\nworker-calendar:worker-calendar_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n 1) app/Jobs/Crm/MatchActivityCrmData.php (ordered_class_elements)\n ---------- begin diff ----------\n--- /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n+++ /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n@@ -29,24 +29,14 @@\n use InteractsWithQueue;\n use SerializesModels;\n \n+ private const int RETRY_WINDOW_MINUTES = 120;\n+\n public int $maxExceptions = 3;\n \n- private const int RETRY_WINDOW_MINUTES = 120;\n-\n private int $activityId;\n private ?Configuration $fromConfiguration;\n private bool $remoteSearch;\n \n- public function middleware(): array\n- {\n- return [new HandleHubspotRateLimit()];\n- }\n-\n- public function retryUntil(): \\DateTimeInterface\n- {\n- return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n- }\n-\n public function __construct(\n int $activityId,\n ?Configuration $fromConfiguration = null,\n@@ -57,6 +47,16 @@\n $this->remoteSearch = $remoteSearch;\n \n $this->onQueue(Constants::QUEUE_ANALYTICS_LOW);\n+ }\n+\n+ public function middleware(): array\n+ {\n+ return [new HandleHubspotRateLimit()];\n+ }\n+\n+ public function retryUntil(): \\DateTimeInterface\n+ {\n+ return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n }\n \n public function uniqueId(): string\n\n ----------- end diff -----------\n\n 2) app/Services/Crm/Hubspot/HubspotClientInterface.php (phpdoc_order, phpdoc_separation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n@@ -46,9 +46,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 3) app/Services/Crm/Hubspot/Client.php (phpdoc_order, phpdoc_separation, blank_line_before_statement)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/Client.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/Client.php\n@@ -69,10 +69,12 @@\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- * @return T The result of the API call\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@@ -81,6 +83,7 @@\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@@ -232,9 +235,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 4) app/Console/Commands/JiminnyDebugCommand.php (statement_indentation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n+++ /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n@@ -359,11 +359,11 @@\n $crmService = $crmResolver->prepareCrmService();\n \n for ($i = 0 ; $i < 3; $i++) {\n-// if ($i % 25 === 0) {\n-// $this->info(\"Syncing opportunity {$i}\");\n- $this->info(\"Matching contact {$i}\");\n-// }\n-// $crmService->syncOpportunity('374720564');\n+ // if ($i % 25 === 0) {\n+ // $this->info(\"Syncing opportunity {$i}\");\n+ $this->info(\"Matching contact {$i}\");\n+ // }\n+ // $crmService->syncOpportunity('374720564');\n $crmService->matchByName('Robot');\n }\n }\n\n ----------- end diff -----------\n\n\nFixed 4 of 5666 files in 146.870 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix \ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5666 files in 106.192 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5666 files in 66.457 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $","is_focused":true},{"role":"AXRadioButton","text":"DOCKER","depth":2,"bounds":{"left":0.27027926,"top":1.0,"width":0.06732048,"height":-0.042298436},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.27227393,"top":1.0,"width":0.005319149,"height":-0.04549086},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"DEV (docker)","depth":2,"bounds":{"left":0.33759972,"top":1.0,"width":0.06732048,"height":-0.042298436},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.33959442,"top":1.0,"width":0.005319149,"height":-0.04549086},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"APP (-zsh)","depth":2,"bounds":{"left":0.40492022,"top":1.0,"width":0.06732048,"height":-0.042298436},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.4069149,"top":1.0,"width":0.005319149,"height":-0.04549086},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"-zsh","depth":2,"bounds":{"left":0.4722407,"top":1.0,"width":0.06732048,"height":-0.042298436},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.4742354,"top":1.0,"width":0.005319149,"height":-0.04549086},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"-zsh","depth":2,"bounds":{"left":0.53956115,"top":1.0,"width":0.06715426,"height":-0.042298436},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.5415558,"top":1.0,"width":0.005319149,"height":-0.04549086},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"screenpipe\"","depth":2,"bounds":{"left":0.60671544,"top":1.0,"width":0.06715426,"height":-0.042298436},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.6087101,"top":1.0,"width":0.005319149,"height":-0.04549086},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"-zsh","depth":2,"bounds":{"left":0.67386967,"top":1.0,"width":0.06715426,"height":-0.042298436},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.67586434,"top":1.0,"width":0.005319149,"height":-0.04549086},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"⌥⌘1","depth":1,"bounds":{"left":0.7273936,"top":1.0,"width":0.01861702,"height":-0.023144484},"on_screen":true,"automation_id":"_NS:8","role_description":"text"},{"role":"AXStaticText","text":"APP (-zsh)","depth":1,"bounds":{"left":0.4973404,"top":1.0,"width":0.024601065,"height":-0.02394259},"on_screen":true,"role_description":"text"}]...
|
-5826347064466025141
|
-2292063823433160584
|
idle
|
accessibility
|
NULL
|
Last login: Thu May 7 09:44:56 on ttys007
Poetry Last login: Thu May 7 09:44:56 on ttys007
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status
On branch master
Your branch is up to date with 'origin/master'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: .env.local
modified: app/Console/Commands/JiminnyDebugCommand.php
modified: app/Jobs/Team/SyncToIntercom.php
modified: app/Services/PlaybackService.php
modified: config/logging.php
modified: resources/views/partials/crm/push-summary/html-assembly.blade.php
Untracked files:
(use "git add <file>..." to include in what will be committed)
.env.nikilocal
.env.other
WEBHOOK_FILTERING_IMPLEMENTATION.md
app/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php
app/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php
ids.txt
public/favicon.ico
raw_sql_query.sql
tests/Unit/Policies/CanAccessAiReportsTest.php
no changes added to commit (use "git add" and/or "git commit -a")
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 1482, done.
remote: Counting objects: 100% (481/481), done.
remote: Compressing objects: 100% (191/191), done.
remote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)
Receiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.
Resolving deltas: 100% (877/877), completed with 96 local objects.
From github.com:jiminny/app
83b628967a..ad2ce76737 master -> origin/master
1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests
b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
* [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost
* [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc
* [new branch] make-claude-great-again -> origin/make-claude-great-again
* [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507
* [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
Fast-forward
.cursor/rules/frontend-conventions.mdc | 23 ++
.env.production-eu | 2 +-
.env.staging | 2 +-
Makefile | 10 +
app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-
app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-
app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--
app/Component/AskAnything/AskAnythingPromptService.php | 3 +
app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-
app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-
app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-
app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---
app/Component/Twilio/TwilioRepository.php | 27 ++
app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----
app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--
app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++
app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----
app/Console/Commands/Users/SyncToIntercom.php | 4 +-
app/Console/Kernel.php | 3 +-
app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -
app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -
app/Contracts/Repositories/TeamRepository.php | 3 +-
app/Events/Activities/ActivityUpdated.php | 10 +-
app/Events/Activities/Audio/RecordingEvent.php | 6 +-
app/Events/Activities/Softphone/Ended.php | 8 +-
app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-
app/Events/Activities/Softphone/Started.php | 8 +-
app/Http/Controllers/API/ActivityController.php | 17 +-
app/Http/Controllers/API/SoftphoneController.php | 9 +-
app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-
app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-
app/Http/Controllers/Auth/SocialController.php | 6 +-
app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-
app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-
app/Http/Controllers/Kiosk/PartnersController.php | 46 +++
app/Http/Controllers/Kiosk/SearchController.php | 8 +
app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-
app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-
app/Http/Controllers/TeamSetupController.php | 4 +-
app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-
app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-
app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +
app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +
app/Http/Transformers/ActivityTransformer.php | 4 +-
app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-
app/Http/Transformers/PartnerTransformer.php | 1 +
app/Http/Transformers/StageTransformer.php | 6 +-
app/Http/Transformers/UserTransformer.php | 11 +-
app/Interactions/Settings/Teams/CreateTeam.php | 3 +
app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-
app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++
app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++
app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-
app/Jobs/Crm/UpdateStage.php | 3 +
app/Jobs/Team/SyncToIntercom.php | 7 +-
app/Listeners/Teams/SyncIntercomCompany.php | 5 +-
app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-
app/Listeners/Users/SyncIntercom.php | 5 +-
app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++
app/Mail/Reports/ReportNotGenerated.php | 41 +++
app/Models/Activity.php | 25 +-
app/Models/Activity/Question.php | 14 +-
app/Models/Activity/Search.php | 7 +
app/Models/AskAnything/AskAnythingPrompt.php | 6 +
app/Models/AutomatedReport.php | 10 +
app/Models/CoachingFeedback.php | 44 ++-
app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----
app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----
app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --
app/Models/Partner.php | 13 +
app/Models/Playlist/Activity.php | 14 +-
app/Notifications/OwnerInvitedToTrial.php | 14 +-
app/Policies/UserPolicy.php | 16 +-
app/Queue/Worker/Worker.php | 3 +-
app/Repositories/ActivityRepository.php | 13 +-
app/Repositories/AutomatedReportsRepository.php | 42 ++-
app/Repositories/TeamRepository.php | 21 +-
app/Repositories/UserRepository.php | 2 +-
app/Services/Activity/MeetingBotService.php | 8 +-
app/Services/ActivityService.php | 111 ++-----
app/Services/Crm/Hubspot/Service.php | 36 +-
app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-
app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--
app/Services/Kiosk/KioskService.php | 7 +-
app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-
app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++
app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++
app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++
app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----
composer.json | 1 -
composer.lock | 95 +-----
config/secure-headers.php | 5 +-
database/mappings/mapping_activities.json | 16 +
database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++
database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++
database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++
database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++
front-end/package.json | 5 +-
front-end/src/__mocks__/jiminny.js | 4 +-
front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +
front-end/src/__mocks__/setup.js | 1 +
front-end/src/apps/ai-reports-promo.js | 22 ++
front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++
front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++
front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++
front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++
front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++
.../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++
front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-
front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++
front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++
.../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++
front-end/src/components/AiReports/constants.js | 7 +
front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +
front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++
front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +
front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++
front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-
front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-
front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-
front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-
front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++
front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-
front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++
front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++
front-end/src/main.js | 1 +
front-end/src/store/modules/TeamInsights/util.js | 1 +
front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++
front-end/src/store/modules/platform/getters.js | 3 +
front-end/src/utils/index.js | 11 +
front-end/yarn.lock | 21 +-
phpstan-baseline.neon | 60 ----
public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes
public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes
public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes
public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes
public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes
public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes
public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes
public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes
resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++
resources/views/emails/reports/report-not-generated.blade.php | 24 ++
resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-
routes/api.php | 6 +
routes/web.php | 4 +
tests/Feature/Policies/UserPolicyTest.php | 90 ++++-
tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++
tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++
tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++
tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++
tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--
tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-
tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++
tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++
tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++
tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++
tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++
tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-
tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++
tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++
tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-
tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++
tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++
tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-
tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +
tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++
tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-
tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++
tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++
tests/Unit/Models/PartnerTest.php | 28 ++
tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++
tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-
tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++
tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--
tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++
tests/Unit/Services/KioskServiceTest.php | 8 +
tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-
tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++
tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++
tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----
186 files changed, 8538 insertions(+), 1233 deletions(-)
create mode 100644 app/Component/Twilio/TwilioRepository.php
delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php
create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php
delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php
create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php
create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php
create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php
create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php
create mode 100644 app/Mail/Reports/ReportNotGenerated.php
delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php
create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php
create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php
create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php
create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php
create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php
create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php
create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php
create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js
create mode 100644 front-end/src/apps/ai-reports-promo.js
create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html
create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js
create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js
create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js
create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/com/loss-report.pdf
create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf
create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf
create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf
create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php
create mode 100644 resources/views/emails/reports/report-not-generated.blade.php
create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php
create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php
create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php
create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php
create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php
create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php
create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php
create mode 100644 tests/Unit/Models/PartnerTest.php
create mode 100644 tests/Unit/Services/ActivityServiceTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 628, done.
remote: Counting objects: 100% (331/331), done.
remote: Compressing objects: 100% (63/63), done.
remote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)
Receiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.
Resolving deltas: 100% (391/391), completed with 57 local objects.
From github.com:jiminny/app
ad2ce76737..12295204cf master -> origin/master
14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests
f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech
+ ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)
* [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication
* [new branch] desktop-app -> origin/desktop-app
4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again
* [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas
Updating ad2ce76737..12295204cf
Fast-forward
app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-
app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------
app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------
tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------
tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -
11 files changed, 49 insertions(+), 90 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric
Switched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 15, done.
remote: Counting objects: 100% (15/15), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)
Unpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.
From github.com:jiminny/app
c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit
Already up to date.
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 180, done.
remote: Counting objects: 100% (179/179), done.
remote: Compressing objects: 100% (55/55), done.
remote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)
Receiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.
Resolving deltas: 100% (127/127), completed with 47 local objects.
From github.com:jiminny/app
12295204cf..35f036ace6 master -> origin/master
* [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5
5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories
* [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration
+ ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)
8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas
Updating 12295204cf..35f036ace6
Fast-forward
app/Component/Datadog/Constants.php | 1 +
app/Providers/RouteServiceProvider.php | 2 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------
4 files changed, 62 insertions(+), 8 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr
* master
JY-20818-move-AJ-reports-to-separated-datadog-metric
JY-20773-fix-automated-reports-user-pilot-tracking
JY-20157-AJ-report-not-send-notification
JY-20508-notify-before-AJ-report-expiration
JY-20372-ai-reports-promotion-pages
JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
JY-20738-debug-AJ-tracking-UP
a
JY-18909-automated-reports-ask-jiminny
JY-20692-fix-integration-app-[API_KEY]
JY-20553-debug-crm-sync-delays
JY-20698-fix-SF-activity-types-on-new-playbook
JY-20543-AJ-report-tracking
JY-20384-handle-auto-sync-with-no-access-to-event-type
JY-20458-ask-jiminny-user-definitions
JY-19666-fix-import-contacts-account-association
JY-19666-HS-import-contacts-and-accounts-batch-job
JY-20458-Ask-Jiminny-Reports
JY-20200-batch-update-CRM-objects-Salesforce
JY-19666-HS-webhooks-add-contact-and-company
JY-20348-trigger-setup-DI-layout-on-team-creation
JY-20326-refactor-info-message-in-command
JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled
JY-20312-remove-on-update-change-last-synced-at-crm-configurations
JY-20306-SF-skip-auto-sync-for-task-based-playbook
JY-20192-remove-deleted-team-from-saved-search-filters
JY-20197-import-opportunity-batch-job
JY-20293-enable-status-field-for-pipedrive-deals
JY-20191-remove-commands-interactive-prompts
JY-20118-change-default-sync-strategy
JY-20183-add-cache-on-auto-log-delay
JY-20197-add-import-opportunity-batch-job
20118-hs-opportunity-make-webhook-strategy-default
JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based
JY-20196-handle-opportunity-without-note
JY-20118-improve-opportunity-import
JY-20189-handle-activity-search-on-deleted-groups
JY-20160
JY-20145-filter-out-converted-leads-when-matching
JY-20150-skip-push-summary-on-summary-ready-if-autolog
JY-20132-fix-note-encoding
JY-19792-clean-logs
JY-20117-fix-sync-profile-fields-on-empty-fields
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit
Switched to a new branch 'JY-20725-handle-HS-search-rate-limit'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd
docker exec -it docker_lamp_1 bash -c "mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini"
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 supervisorctl restart all
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped
jiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped
jiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped
jiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped
jiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped
worker-analytics:worker-analytics_00: stopped
worker-crm-update:worker-crm-update_00: stopped
worker-download:worker-download_00: stopped
worker-nudges:worker-nudges_00: stopped
artisan-schedule:artisan-schedule_00: stopped
worker-emails:worker-emails_00: stopped
worker:worker_00: stopped
worker-calendar:worker-calendar_00: stopped
jiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped
worker-crm-sync:worker-crm-sync_00: stopped
worker-audio:worker-audio_00: stopped
worker-conferences:worker-conferences_00: stopped
worker-es-update:worker-es-update_00: stopped
artisan-schedule:artisan-schedule_00: started
jiminny-worker-processing-1:jiminny-worker-processing-1_00: started
jiminny-worker-processing-2:jiminny-worker-processing-2_00: started
jiminny-worker-processing-3:jiminny-worker-processing-3_00: started
jiminny-worker-processing-4:jiminny-worker-processing-4_00: started
jiminny-worker-processing-5:jiminny-worker-processing-5_00: started
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started
worker:worker_00: started
worker-analytics:worker-analytics_00: started
worker-audio:worker-audio_00: started
worker-calendar:worker-calendar_00: started
worker-conferences:worker-conferences_00: started
worker-crm-sync:worker-crm-sync_00: started
worker-crm-update:worker-crm-update_00: started
worker-download:worker-download_00: started
worker-emails:worker-emails_00: started
worker-es-update:worker-es-update_00: started
worker-nudges:worker-nudges_00: started
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 php -v
PHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)
Copyright (c) The PHP Group
Zend En...
|
20463
|
NULL
|
NULL
|
NULL
|
|
20491
|
NULL
|
0
|
2026-05-11T15:39:20.633471+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778513960633_m1.jpg...
|
iTerm2
|
APP (-zsh)
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Last login: Thu May 7 09:44:56 on ttys007
Poetry Last login: Thu May 7 09:44:56 on ttys007
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status
On branch master
Your branch is up to date with 'origin/master'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: .env.local
modified: app/Console/Commands/JiminnyDebugCommand.php
modified: app/Jobs/Team/SyncToIntercom.php
modified: app/Services/PlaybackService.php
modified: config/logging.php
modified: resources/views/partials/crm/push-summary/html-assembly.blade.php
Untracked files:
(use "git add <file>..." to include in what will be committed)
.env.nikilocal
.env.other
WEBHOOK_FILTERING_IMPLEMENTATION.md
app/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php
app/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php
ids.txt
public/favicon.ico
raw_sql_query.sql
tests/Unit/Policies/CanAccessAiReportsTest.php
no changes added to commit (use "git add" and/or "git commit -a")
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 1482, done.
remote: Counting objects: 100% (481/481), done.
remote: Compressing objects: 100% (191/191), done.
remote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)
Receiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.
Resolving deltas: 100% (877/877), completed with 96 local objects.
From github.com:jiminny/app
83b628967a..ad2ce76737 master -> origin/master
1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests
b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
* [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost
* [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc
* [new branch] make-claude-great-again -> origin/make-claude-great-again
* [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507
* [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
Fast-forward
.cursor/rules/frontend-conventions.mdc | 23 ++
.env.production-eu | 2 +-
.env.staging | 2 +-
Makefile | 10 +
app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-
app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-
app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--
app/Component/AskAnything/AskAnythingPromptService.php | 3 +
app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-
app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-
app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-
app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---
app/Component/Twilio/TwilioRepository.php | 27 ++
app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----
app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--
app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++
app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----
app/Console/Commands/Users/SyncToIntercom.php | 4 +-
app/Console/Kernel.php | 3 +-
app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -
app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -
app/Contracts/Repositories/TeamRepository.php | 3 +-
app/Events/Activities/ActivityUpdated.php | 10 +-
app/Events/Activities/Audio/RecordingEvent.php | 6 +-
app/Events/Activities/Softphone/Ended.php | 8 +-
app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-
app/Events/Activities/Softphone/Started.php | 8 +-
app/Http/Controllers/API/ActivityController.php | 17 +-
app/Http/Controllers/API/SoftphoneController.php | 9 +-
app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-
app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-
app/Http/Controllers/Auth/SocialController.php | 6 +-
app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-
app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-
app/Http/Controllers/Kiosk/PartnersController.php | 46 +++
app/Http/Controllers/Kiosk/SearchController.php | 8 +
app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-
app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-
app/Http/Controllers/TeamSetupController.php | 4 +-
app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-
app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-
app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +
app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +
app/Http/Transformers/ActivityTransformer.php | 4 +-
app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-
app/Http/Transformers/PartnerTransformer.php | 1 +
app/Http/Transformers/StageTransformer.php | 6 +-
app/Http/Transformers/UserTransformer.php | 11 +-
app/Interactions/Settings/Teams/CreateTeam.php | 3 +
app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-
app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++
app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++
app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-
app/Jobs/Crm/UpdateStage.php | 3 +
app/Jobs/Team/SyncToIntercom.php | 7 +-
app/Listeners/Teams/SyncIntercomCompany.php | 5 +-
app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-
app/Listeners/Users/SyncIntercom.php | 5 +-
app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++
app/Mail/Reports/ReportNotGenerated.php | 41 +++
app/Models/Activity.php | 25 +-
app/Models/Activity/Question.php | 14 +-
app/Models/Activity/Search.php | 7 +
app/Models/AskAnything/AskAnythingPrompt.php | 6 +
app/Models/AutomatedReport.php | 10 +
app/Models/CoachingFeedback.php | 44 ++-
app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----
app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----
app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --
app/Models/Partner.php | 13 +
app/Models/Playlist/Activity.php | 14 +-
app/Notifications/OwnerInvitedToTrial.php | 14 +-
app/Policies/UserPolicy.php | 16 +-
app/Queue/Worker/Worker.php | 3 +-
app/Repositories/ActivityRepository.php | 13 +-
app/Repositories/AutomatedReportsRepository.php | 42 ++-
app/Repositories/TeamRepository.php | 21 +-
app/Repositories/UserRepository.php | 2 +-
app/Services/Activity/MeetingBotService.php | 8 +-
app/Services/ActivityService.php | 111 ++-----
app/Services/Crm/Hubspot/Service.php | 36 +-
app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-
app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--
app/Services/Kiosk/KioskService.php | 7 +-
app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-
app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++
app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++
app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++
app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----
composer.json | 1 -
composer.lock | 95 +-----
config/secure-headers.php | 5 +-
database/mappings/mapping_activities.json | 16 +
database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++
database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++
database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++
database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++
front-end/package.json | 5 +-
front-end/src/__mocks__/jiminny.js | 4 +-
front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +
front-end/src/__mocks__/setup.js | 1 +
front-end/src/apps/ai-reports-promo.js | 22 ++
front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++
front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++
front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++
front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++
front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++
.../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++
front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-
front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++
front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++
.../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++
front-end/src/components/AiReports/constants.js | 7 +
front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +
front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++
front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +
front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++
front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-
front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-
front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-
front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-
front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++
front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-
front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++
front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++
front-end/src/main.js | 1 +
front-end/src/store/modules/TeamInsights/util.js | 1 +
front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++
front-end/src/store/modules/platform/getters.js | 3 +
front-end/src/utils/index.js | 11 +
front-end/yarn.lock | 21 +-
phpstan-baseline.neon | 60 ----
public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes
public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes
public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes
public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes
public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes
public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes
public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes
public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes
resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++
resources/views/emails/reports/report-not-generated.blade.php | 24 ++
resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-
routes/api.php | 6 +
routes/web.php | 4 +
tests/Feature/Policies/UserPolicyTest.php | 90 ++++-
tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++
tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++
tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++
tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++
tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--
tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-
tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++
tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++
tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++
tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++
tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++
tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-
tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++
tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++
tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-
tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++
tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++
tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-
tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +
tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++
tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-
tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++
tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++
tests/Unit/Models/PartnerTest.php | 28 ++
tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++
tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-
tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++
tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--
tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++
tests/Unit/Services/KioskServiceTest.php | 8 +
tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-
tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++
tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++
tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----
186 files changed, 8538 insertions(+), 1233 deletions(-)
create mode 100644 app/Component/Twilio/TwilioRepository.php
delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php
create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php
delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php
create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php
create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php
create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php
create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php
create mode 100644 app/Mail/Reports/ReportNotGenerated.php
delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php
create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php
create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php
create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php
create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php
create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php
create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php
create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php
create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js
create mode 100644 front-end/src/apps/ai-reports-promo.js
create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html
create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js
create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js
create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js
create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/com/loss-report.pdf
create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf
create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf
create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf
create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php
create mode 100644 resources/views/emails/reports/report-not-generated.blade.php
create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php
create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php
create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php
create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php
create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php
create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php
create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php
create mode 100644 tests/Unit/Models/PartnerTest.php
create mode 100644 tests/Unit/Services/ActivityServiceTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 628, done.
remote: Counting objects: 100% (331/331), done.
remote: Compressing objects: 100% (63/63), done.
remote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)
Receiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.
Resolving deltas: 100% (391/391), completed with 57 local objects.
From github.com:jiminny/app
ad2ce76737..12295204cf master -> origin/master
14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests
f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech
+ ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)
* [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication
* [new branch] desktop-app -> origin/desktop-app
4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again
* [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas
Updating ad2ce76737..12295204cf
Fast-forward
app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-
app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------
app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------
tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------
tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -
11 files changed, 49 insertions(+), 90 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric
Switched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 15, done.
remote: Counting objects: 100% (15/15), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)
Unpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.
From github.com:jiminny/app
c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit
Already up to date.
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 180, done.
remote: Counting objects: 100% (179/179), done.
remote: Compressing objects: 100% (55/55), done.
remote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)
Receiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.
Resolving deltas: 100% (127/127), completed with 47 local objects.
From github.com:jiminny/app
12295204cf..35f036ace6 master -> origin/master
* [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5
5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories
* [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration
+ ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)
8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas
Updating 12295204cf..35f036ace6
Fast-forward
app/Component/Datadog/Constants.php | 1 +
app/Providers/RouteServiceProvider.php | 2 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------
4 files changed, 62 insertions(+), 8 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr
* master
JY-20818-move-AJ-reports-to-separated-datadog-metric
JY-20773-fix-automated-reports-user-pilot-tracking
JY-20157-AJ-report-not-send-notification
JY-20508-notify-before-AJ-report-expiration
JY-20372-ai-reports-promotion-pages
JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
JY-20738-debug-AJ-tracking-UP
a
JY-18909-automated-reports-ask-jiminny
JY-20692-fix-integration-app-[API_KEY]
JY-20553-debug-crm-sync-delays
JY-20698-fix-SF-activity-types-on-new-playbook
JY-20543-AJ-report-tracking
JY-20384-handle-auto-sync-with-no-access-to-event-type
JY-20458-ask-jiminny-user-definitions
JY-19666-fix-import-contacts-account-association
JY-19666-HS-import-contacts-and-accounts-batch-job
JY-20458-Ask-Jiminny-Reports
JY-20200-batch-update-CRM-objects-Salesforce
JY-19666-HS-webhooks-add-contact-and-company
JY-20348-trigger-setup-DI-layout-on-team-creation
JY-20326-refactor-info-message-in-command
JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled
JY-20312-remove-on-update-change-last-synced-at-crm-configurations
JY-20306-SF-skip-auto-sync-for-task-based-playbook
JY-20192-remove-deleted-team-from-saved-search-filters
JY-20197-import-opportunity-batch-job
JY-20293-enable-status-field-for-pipedrive-deals
JY-20191-remove-commands-interactive-prompts
JY-20118-change-default-sync-strategy
JY-20183-add-cache-on-auto-log-delay
JY-20197-add-import-opportunity-batch-job
20118-hs-opportunity-make-webhook-strategy-default
JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based
JY-20196-handle-opportunity-without-note
JY-20118-improve-opportunity-import
JY-20189-handle-activity-search-on-deleted-groups
JY-20160
JY-20145-filter-out-converted-leads-when-matching
JY-20150-skip-push-summary-on-summary-ready-if-autolog
JY-20132-fix-note-encoding
JY-19792-clean-logs
JY-20117-fix-sync-profile-fields-on-empty-fields
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit
Switched to a new branch 'JY-20725-handle-HS-search-rate-limit'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd
docker exec -it docker_lamp_1 bash -c "mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini"
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 supervisorctl restart all
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped
jiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped
jiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped
jiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped
jiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped
worker-analytics:worker-analytics_00: stopped
worker-crm-update:worker-crm-update_00: stopped
worker-download:worker-download_00: stopped
worker-nudges:worker-nudges_00: stopped
artisan-schedule:artisan-schedule_00: stopped
worker-emails:worker-emails_00: stopped
worker:worker_00: stopped
worker-calendar:worker-calendar_00: stopped
jiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped
worker-crm-sync:worker-crm-sync_00: stopped
worker-audio:worker-audio_00: stopped
worker-conferences:worker-conferences_00: stopped
worker-es-update:worker-es-update_00: stopped
artisan-schedule:artisan-schedule_00: started
jiminny-worker-processing-1:jiminny-worker-processing-1_00: started
jiminny-worker-processing-2:jiminny-worker-processing-2_00: started
jiminny-worker-processing-3:jiminny-worker-processing-3_00: started
jiminny-worker-processing-4:jiminny-worker-processing-4_00: started
jiminny-worker-processing-5:jiminny-worker-processing-5_00: started
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started
worker:worker_00: started
worker-analytics:worker-analytics_00: started
worker-audio:worker-audio_00: started
worker-calendar:worker-calendar_00: started
worker-conferences:worker-conferences_00: started
worker-crm-sync:worker-crm-sync_00: started
worker-crm-update:worker-crm-update_00: started
worker-download:worker-download_00: started
worker-emails:worker-emails_00: started
worker-es-update:worker-es-update_00: started
worker-nudges:worker-nudges_00: started
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 php -v
PHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)
Copyright (c) The PHP Group
Zend En...
|
[{"role":"AXTextArea","text [{"role":"AXTextArea","text":"Last login: Thu May 7 09:44:56 on ttys007\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Console/Commands/JiminnyDebugCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Services/PlaybackService.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: config/logging.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: resources/views/partials/crm/push-summary/html-assembly.blade.php\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 1482, done.\nremote: Counting objects: 100% (481/481), done.\nremote: Compressing objects: 100% (191/191), done.\nremote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)\nReceiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.\nResolving deltas: 100% (877/877), completed with 96 local objects.\nFrom github.com:jiminny/app\n 83b628967a..ad2ce76737 master -> origin/master\n 1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n 5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests\n b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n * [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost\n * [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc\n * [new branch] make-claude-great-again -> origin/make-claude-great-again\n * [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507\n * [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nFast-forward\n .cursor/rules/frontend-conventions.mdc | 23 ++\n .env.production-eu | 2 +-\n .env.staging | 2 +-\n Makefile | 10 +\n app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-\n app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-\n app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--\n app/Component/AskAnything/AskAnythingPromptService.php | 3 +\n app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-\n app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-\n app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-\n app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---\n app/Component/Twilio/TwilioRepository.php | 27 ++\n app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----\n app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--\n app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++\n app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----\n app/Console/Commands/Users/SyncToIntercom.php | 4 +-\n app/Console/Kernel.php | 3 +-\n app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -\n app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -\n app/Contracts/Repositories/TeamRepository.php | 3 +-\n app/Events/Activities/ActivityUpdated.php | 10 +-\n app/Events/Activities/Audio/RecordingEvent.php | 6 +-\n app/Events/Activities/Softphone/Ended.php | 8 +-\n app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-\n app/Events/Activities/Softphone/Started.php | 8 +-\n app/Http/Controllers/API/ActivityController.php | 17 +-\n app/Http/Controllers/API/SoftphoneController.php | 9 +-\n app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-\n app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-\n app/Http/Controllers/Auth/SocialController.php | 6 +-\n app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-\n app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-\n app/Http/Controllers/Kiosk/PartnersController.php | 46 +++\n app/Http/Controllers/Kiosk/SearchController.php | 8 +\n app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-\n app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-\n app/Http/Controllers/TeamSetupController.php | 4 +-\n app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-\n app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-\n app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +\n app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +\n app/Http/Transformers/ActivityTransformer.php | 4 +-\n app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-\n app/Http/Transformers/PartnerTransformer.php | 1 +\n app/Http/Transformers/StageTransformer.php | 6 +-\n app/Http/Transformers/UserTransformer.php | 11 +-\n app/Interactions/Settings/Teams/CreateTeam.php | 3 +\n app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-\n app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++\n app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++\n app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-\n app/Jobs/Crm/UpdateStage.php | 3 +\n app/Jobs/Team/SyncToIntercom.php | 7 +-\n app/Listeners/Teams/SyncIntercomCompany.php | 5 +-\n app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-\n app/Listeners/Users/SyncIntercom.php | 5 +-\n app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++\n app/Mail/Reports/ReportNotGenerated.php | 41 +++\n app/Models/Activity.php | 25 +-\n app/Models/Activity/Question.php | 14 +-\n app/Models/Activity/Search.php | 7 +\n app/Models/AskAnything/AskAnythingPrompt.php | 6 +\n app/Models/AutomatedReport.php | 10 +\n app/Models/CoachingFeedback.php | 44 ++-\n app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----\n app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----\n app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --\n app/Models/Partner.php | 13 +\n app/Models/Playlist/Activity.php | 14 +-\n app/Notifications/OwnerInvitedToTrial.php | 14 +-\n app/Policies/UserPolicy.php | 16 +-\n app/Queue/Worker/Worker.php | 3 +-\n app/Repositories/ActivityRepository.php | 13 +-\n app/Repositories/AutomatedReportsRepository.php | 42 ++-\n app/Repositories/TeamRepository.php | 21 +-\n app/Repositories/UserRepository.php | 2 +-\n app/Services/Activity/MeetingBotService.php | 8 +-\n app/Services/ActivityService.php | 111 ++-----\n app/Services/Crm/Hubspot/Service.php | 36 +-\n app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--\n app/Services/Kiosk/KioskService.php | 7 +-\n app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-\n app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++\n app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++\n app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++\n app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----\n composer.json | 1 -\n composer.lock | 95 +-----\n config/secure-headers.php | 5 +-\n database/mappings/mapping_activities.json | 16 +\n database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++\n database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++\n database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++\n database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++\n front-end/package.json | 5 +-\n front-end/src/__mocks__/jiminny.js | 4 +-\n front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +\n front-end/src/__mocks__/setup.js | 1 +\n front-end/src/apps/ai-reports-promo.js | 22 ++\n front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++\n front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++\n .../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++\n front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-\n front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++\n front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++\n .../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++\n front-end/src/components/AiReports/constants.js | 7 +\n front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +\n front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++\n front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +\n front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++\n front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-\n front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-\n front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-\n front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-\n front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++\n front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-\n front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++\n front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++\n front-end/src/main.js | 1 +\n front-end/src/store/modules/TeamInsights/util.js | 1 +\n front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++\n front-end/src/store/modules/platform/getters.js | 3 +\n front-end/src/utils/index.js | 11 +\n front-end/yarn.lock | 21 +-\n phpstan-baseline.neon | 60 ----\n public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes\n public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes\n public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes\n public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes\n public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes\n public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes\n public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes\n public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes\n resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++\n resources/views/emails/reports/report-not-generated.blade.php | 24 ++\n resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-\n routes/api.php | 6 +\n routes/web.php | 4 +\n tests/Feature/Policies/UserPolicyTest.php | 90 ++++-\n tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++\n tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++\n tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++\n tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++\n tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--\n tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-\n tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++\n tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++\n tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++\n tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++\n tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++\n tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-\n tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++\n tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++\n tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-\n tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++\n tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++\n tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-\n tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +\n tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++\n tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-\n tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++\n tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++\n tests/Unit/Models/PartnerTest.php | 28 ++\n tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++\n tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-\n tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++\n tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--\n tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++\n tests/Unit/Services/KioskServiceTest.php | 8 +\n tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-\n tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++\n tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++\n tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----\n 186 files changed, 8538 insertions(+), 1233 deletions(-)\n create mode 100644 app/Component/Twilio/TwilioRepository.php\n delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php\n create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php\n delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php\n create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php\n create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php\n create mode 100644 app/Mail/Reports/ReportNotGenerated.php\n delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php\n create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php\n create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php\n create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php\n create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php\n create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php\n create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php\n create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php\n create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js\n create mode 100644 front-end/src/apps/ai-reports-promo.js\n create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html\n create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js\n create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js\n create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js\n create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/com/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf\n create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf\n create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php\n create mode 100644 resources/views/emails/reports/report-not-generated.blade.php\n create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php\n create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php\n create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php\n create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php\n create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php\n create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php\n create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php\n create mode 100644 tests/Unit/Models/PartnerTest.php\n create mode 100644 tests/Unit/Services/ActivityServiceTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 628, done.\nremote: Counting objects: 100% (331/331), done.\nremote: Compressing objects: 100% (63/63), done.\nremote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)\nReceiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.\nResolving deltas: 100% (391/391), completed with 57 local objects.\nFrom github.com:jiminny/app\n ad2ce76737..12295204cf master -> origin/master\n 14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests\n f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n 1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech\n + ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)\n * [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication\n * [new branch] desktop-app -> origin/desktop-app\n 4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again\n * [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating ad2ce76737..12295204cf\nFast-forward\n app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-\n app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------\n app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------\n tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------\n tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -\n 11 files changed, 49 insertions(+), 90 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric\nSwitched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-calendar:worker-calendar_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker:worker_00: stopped\nworker-emails:worker-emails_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 15, done.\nremote: Counting objects: 100% (15/15), done.\nremote: Compressing objects: 100% (2/2), done.\nremote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)\nUnpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.\nFrom github.com:jiminny/app\n c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit\nAlready up to date.\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 180, done.\nremote: Counting objects: 100% (179/179), done.\nremote: Compressing objects: 100% (55/55), done.\nremote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)\nReceiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.\nResolving deltas: 100% (127/127), completed with 47 local objects.\nFrom github.com:jiminny/app\n 12295204cf..35f036ace6 master -> origin/master\n * [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5\n 5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories\n * [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration\n + ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)\n 8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating 12295204cf..35f036ace6\nFast-forward\n app/Component/Datadog/Constants.php | 1 +\n app/Providers/RouteServiceProvider.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------\n 4 files changed, 62 insertions(+), 8 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr \n* master\n JY-20818-move-AJ-reports-to-separated-datadog-metric\n JY-20773-fix-automated-reports-user-pilot-tracking\n JY-20157-AJ-report-not-send-notification\n JY-20508-notify-before-AJ-report-expiration\n JY-20372-ai-reports-promotion-pages\n JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n JY-20738-debug-AJ-tracking-UP\n a\n JY-18909-automated-reports-ask-jiminny\n JY-20692-fix-integration-app-token-auth-response-change\n JY-20553-debug-crm-sync-delays\n JY-20698-fix-SF-activity-types-on-new-playbook\n JY-20543-AJ-report-tracking\n JY-20384-handle-auto-sync-with-no-access-to-event-type\n JY-20458-ask-jiminny-user-definitions\n JY-19666-fix-import-contacts-account-association\n JY-19666-HS-import-contacts-and-accounts-batch-job\n JY-20458-Ask-Jiminny-Reports\n JY-20200-batch-update-CRM-objects-Salesforce\n JY-19666-HS-webhooks-add-contact-and-company\n JY-20348-trigger-setup-DI-layout-on-team-creation\n JY-20326-refactor-info-message-in-command\n JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled\n JY-20312-remove-on-update-change-last-synced-at-crm-configurations\n JY-20306-SF-skip-auto-sync-for-task-based-playbook\n JY-20192-remove-deleted-team-from-saved-search-filters\n JY-20197-import-opportunity-batch-job\n JY-20293-enable-status-field-for-pipedrive-deals\n JY-20191-remove-commands-interactive-prompts\n JY-20118-change-default-sync-strategy\n JY-20183-add-cache-on-auto-log-delay\n JY-20197-add-import-opportunity-batch-job\n 20118-hs-opportunity-make-webhook-strategy-default\n JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based\n JY-20196-handle-opportunity-without-note\n JY-20118-improve-opportunity-import\n JY-20189-handle-activity-search-on-deleted-groups\n JY-20160\n JY-20145-filter-out-converted-leads-when-matching\n JY-20150-skip-push-summary-on-summary-ready-if-autolog\n JY-20132-fix-note-encoding\n JY-19792-clean-logs\n JY-20117-fix-sync-profile-fields-on-empty-fields\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit \nSwitched to a new branch 'JY-20725-handle-HS-search-rate-limit'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-emails:worker-emails_00: stopped\nworker:worker_00: stopped\nworker-calendar:worker-calendar_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n 1) app/Jobs/Crm/MatchActivityCrmData.php (ordered_class_elements)\n ---------- begin diff ----------\n--- /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n+++ /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n@@ -29,24 +29,14 @@\n use InteractsWithQueue;\n use SerializesModels;\n \n+ private const int RETRY_WINDOW_MINUTES = 120;\n+\n public int $maxExceptions = 3;\n \n- private const int RETRY_WINDOW_MINUTES = 120;\n-\n private int $activityId;\n private ?Configuration $fromConfiguration;\n private bool $remoteSearch;\n \n- public function middleware(): array\n- {\n- return [new HandleHubspotRateLimit()];\n- }\n-\n- public function retryUntil(): \\DateTimeInterface\n- {\n- return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n- }\n-\n public function __construct(\n int $activityId,\n ?Configuration $fromConfiguration = null,\n@@ -57,6 +47,16 @@\n $this->remoteSearch = $remoteSearch;\n \n $this->onQueue(Constants::QUEUE_ANALYTICS_LOW);\n+ }\n+\n+ public function middleware(): array\n+ {\n+ return [new HandleHubspotRateLimit()];\n+ }\n+\n+ public function retryUntil(): \\DateTimeInterface\n+ {\n+ return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n }\n \n public function uniqueId(): string\n\n ----------- end diff -----------\n\n 2) app/Services/Crm/Hubspot/HubspotClientInterface.php (phpdoc_order, phpdoc_separation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n@@ -46,9 +46,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 3) app/Services/Crm/Hubspot/Client.php (phpdoc_order, phpdoc_separation, blank_line_before_statement)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/Client.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/Client.php\n@@ -69,10 +69,12 @@\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- * @return T The result of the API call\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@@ -81,6 +83,7 @@\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@@ -232,9 +235,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 4) app/Console/Commands/JiminnyDebugCommand.php (statement_indentation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n+++ /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n@@ -359,11 +359,11 @@\n $crmService = $crmResolver->prepareCrmService();\n \n for ($i = 0 ; $i < 3; $i++) {\n-// if ($i % 25 === 0) {\n-// $this->info(\"Syncing opportunity {$i}\");\n- $this->info(\"Matching contact {$i}\");\n-// }\n-// $crmService->syncOpportunity('374720564');\n+ // if ($i % 25 === 0) {\n+ // $this->info(\"Syncing opportunity {$i}\");\n+ $this->info(\"Matching contact {$i}\");\n+ // }\n+ // $crmService->syncOpportunity('374720564');\n $crmService->matchByName('Robot');\n }\n }\n\n ----------- end diff -----------\n\n\nFixed 4 of 5666 files in 146.870 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix \ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5666 files in 106.192 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5666 files in 66.457 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $","depth":4,"on_screen":true,"value":"Last login: Thu May 7 09:44:56 on ttys007\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Console/Commands/JiminnyDebugCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Services/PlaybackService.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: config/logging.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: resources/views/partials/crm/push-summary/html-assembly.blade.php\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 1482, done.\nremote: Counting objects: 100% (481/481), done.\nremote: Compressing objects: 100% (191/191), done.\nremote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)\nReceiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.\nResolving deltas: 100% (877/877), completed with 96 local objects.\nFrom github.com:jiminny/app\n 83b628967a..ad2ce76737 master -> origin/master\n 1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n 5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests\n b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n * [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost\n * [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc\n * [new branch] make-claude-great-again -> origin/make-claude-great-again\n * [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507\n * [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nFast-forward\n .cursor/rules/frontend-conventions.mdc | 23 ++\n .env.production-eu | 2 +-\n .env.staging | 2 +-\n Makefile | 10 +\n app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-\n app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-\n app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--\n app/Component/AskAnything/AskAnythingPromptService.php | 3 +\n app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-\n app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-\n app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-\n app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---\n app/Component/Twilio/TwilioRepository.php | 27 ++\n app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----\n app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--\n app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++\n app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----\n app/Console/Commands/Users/SyncToIntercom.php | 4 +-\n app/Console/Kernel.php | 3 +-\n app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -\n app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -\n app/Contracts/Repositories/TeamRepository.php | 3 +-\n app/Events/Activities/ActivityUpdated.php | 10 +-\n app/Events/Activities/Audio/RecordingEvent.php | 6 +-\n app/Events/Activities/Softphone/Ended.php | 8 +-\n app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-\n app/Events/Activities/Softphone/Started.php | 8 +-\n app/Http/Controllers/API/ActivityController.php | 17 +-\n app/Http/Controllers/API/SoftphoneController.php | 9 +-\n app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-\n app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-\n app/Http/Controllers/Auth/SocialController.php | 6 +-\n app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-\n app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-\n app/Http/Controllers/Kiosk/PartnersController.php | 46 +++\n app/Http/Controllers/Kiosk/SearchController.php | 8 +\n app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-\n app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-\n app/Http/Controllers/TeamSetupController.php | 4 +-\n app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-\n app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-\n app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +\n app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +\n app/Http/Transformers/ActivityTransformer.php | 4 +-\n app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-\n app/Http/Transformers/PartnerTransformer.php | 1 +\n app/Http/Transformers/StageTransformer.php | 6 +-\n app/Http/Transformers/UserTransformer.php | 11 +-\n app/Interactions/Settings/Teams/CreateTeam.php | 3 +\n app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-\n app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++\n app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++\n app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-\n app/Jobs/Crm/UpdateStage.php | 3 +\n app/Jobs/Team/SyncToIntercom.php | 7 +-\n app/Listeners/Teams/SyncIntercomCompany.php | 5 +-\n app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-\n app/Listeners/Users/SyncIntercom.php | 5 +-\n app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++\n app/Mail/Reports/ReportNotGenerated.php | 41 +++\n app/Models/Activity.php | 25 +-\n app/Models/Activity/Question.php | 14 +-\n app/Models/Activity/Search.php | 7 +\n app/Models/AskAnything/AskAnythingPrompt.php | 6 +\n app/Models/AutomatedReport.php | 10 +\n app/Models/CoachingFeedback.php | 44 ++-\n app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----\n app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----\n app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --\n app/Models/Partner.php | 13 +\n app/Models/Playlist/Activity.php | 14 +-\n app/Notifications/OwnerInvitedToTrial.php | 14 +-\n app/Policies/UserPolicy.php | 16 +-\n app/Queue/Worker/Worker.php | 3 +-\n app/Repositories/ActivityRepository.php | 13 +-\n app/Repositories/AutomatedReportsRepository.php | 42 ++-\n app/Repositories/TeamRepository.php | 21 +-\n app/Repositories/UserRepository.php | 2 +-\n app/Services/Activity/MeetingBotService.php | 8 +-\n app/Services/ActivityService.php | 111 ++-----\n app/Services/Crm/Hubspot/Service.php | 36 +-\n app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--\n app/Services/Kiosk/KioskService.php | 7 +-\n app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-\n app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++\n app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++\n app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++\n app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----\n composer.json | 1 -\n composer.lock | 95 +-----\n config/secure-headers.php | 5 +-\n database/mappings/mapping_activities.json | 16 +\n database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++\n database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++\n database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++\n database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++\n front-end/package.json | 5 +-\n front-end/src/__mocks__/jiminny.js | 4 +-\n front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +\n front-end/src/__mocks__/setup.js | 1 +\n front-end/src/apps/ai-reports-promo.js | 22 ++\n front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++\n front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++\n .../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++\n front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-\n front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++\n front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++\n .../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++\n front-end/src/components/AiReports/constants.js | 7 +\n front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +\n front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++\n front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +\n front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++\n front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-\n front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-\n front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-\n front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-\n front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++\n front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-\n front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++\n front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++\n front-end/src/main.js | 1 +\n front-end/src/store/modules/TeamInsights/util.js | 1 +\n front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++\n front-end/src/store/modules/platform/getters.js | 3 +\n front-end/src/utils/index.js | 11 +\n front-end/yarn.lock | 21 +-\n phpstan-baseline.neon | 60 ----\n public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes\n public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes\n public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes\n public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes\n public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes\n public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes\n public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes\n public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes\n resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++\n resources/views/emails/reports/report-not-generated.blade.php | 24 ++\n resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-\n routes/api.php | 6 +\n routes/web.php | 4 +\n tests/Feature/Policies/UserPolicyTest.php | 90 ++++-\n tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++\n tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++\n tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++\n tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++\n tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--\n tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-\n tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++\n tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++\n tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++\n tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++\n tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++\n tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-\n tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++\n tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++\n tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-\n tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++\n tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++\n tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-\n tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +\n tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++\n tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-\n tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++\n tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++\n tests/Unit/Models/PartnerTest.php | 28 ++\n tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++\n tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-\n tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++\n tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--\n tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++\n tests/Unit/Services/KioskServiceTest.php | 8 +\n tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-\n tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++\n tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++\n tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----\n 186 files changed, 8538 insertions(+), 1233 deletions(-)\n create mode 100644 app/Component/Twilio/TwilioRepository.php\n delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php\n create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php\n delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php\n create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php\n create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php\n create mode 100644 app/Mail/Reports/ReportNotGenerated.php\n delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php\n create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php\n create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php\n create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php\n create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php\n create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php\n create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php\n create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php\n create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js\n create mode 100644 front-end/src/apps/ai-reports-promo.js\n create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html\n create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js\n create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js\n create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js\n create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/com/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf\n create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf\n create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php\n create mode 100644 resources/views/emails/reports/report-not-generated.blade.php\n create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php\n create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php\n create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php\n create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php\n create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php\n create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php\n create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php\n create mode 100644 tests/Unit/Models/PartnerTest.php\n create mode 100644 tests/Unit/Services/ActivityServiceTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 628, done.\nremote: Counting objects: 100% (331/331), done.\nremote: Compressing objects: 100% (63/63), done.\nremote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)\nReceiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.\nResolving deltas: 100% (391/391), completed with 57 local objects.\nFrom github.com:jiminny/app\n ad2ce76737..12295204cf master -> origin/master\n 14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests\n f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n 1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech\n + ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)\n * [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication\n * [new branch] desktop-app -> origin/desktop-app\n 4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again\n * [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating ad2ce76737..12295204cf\nFast-forward\n app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-\n app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------\n app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------\n tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------\n tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -\n 11 files changed, 49 insertions(+), 90 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric\nSwitched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-calendar:worker-calendar_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker:worker_00: stopped\nworker-emails:worker-emails_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 15, done.\nremote: Counting objects: 100% (15/15), done.\nremote: Compressing objects: 100% (2/2), done.\nremote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)\nUnpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.\nFrom github.com:jiminny/app\n c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit\nAlready up to date.\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 180, done.\nremote: Counting objects: 100% (179/179), done.\nremote: Compressing objects: 100% (55/55), done.\nremote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)\nReceiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.\nResolving deltas: 100% (127/127), completed with 47 local objects.\nFrom github.com:jiminny/app\n 12295204cf..35f036ace6 master -> origin/master\n * [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5\n 5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories\n * [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration\n + ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)\n 8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating 12295204cf..35f036ace6\nFast-forward\n app/Component/Datadog/Constants.php | 1 +\n app/Providers/RouteServiceProvider.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------\n 4 files changed, 62 insertions(+), 8 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr \n* master\n JY-20818-move-AJ-reports-to-separated-datadog-metric\n JY-20773-fix-automated-reports-user-pilot-tracking\n JY-20157-AJ-report-not-send-notification\n JY-20508-notify-before-AJ-report-expiration\n JY-20372-ai-reports-promotion-pages\n JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n JY-20738-debug-AJ-tracking-UP\n a\n JY-18909-automated-reports-ask-jiminny\n JY-20692-fix-integration-app-token-auth-response-change\n JY-20553-debug-crm-sync-delays\n JY-20698-fix-SF-activity-types-on-new-playbook\n JY-20543-AJ-report-tracking\n JY-20384-handle-auto-sync-with-no-access-to-event-type\n JY-20458-ask-jiminny-user-definitions\n JY-19666-fix-import-contacts-account-association\n JY-19666-HS-import-contacts-and-accounts-batch-job\n JY-20458-Ask-Jiminny-Reports\n JY-20200-batch-update-CRM-objects-Salesforce\n JY-19666-HS-webhooks-add-contact-and-company\n JY-20348-trigger-setup-DI-layout-on-team-creation\n JY-20326-refactor-info-message-in-command\n JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled\n JY-20312-remove-on-update-change-last-synced-at-crm-configurations\n JY-20306-SF-skip-auto-sync-for-task-based-playbook\n JY-20192-remove-deleted-team-from-saved-search-filters\n JY-20197-import-opportunity-batch-job\n JY-20293-enable-status-field-for-pipedrive-deals\n JY-20191-remove-commands-interactive-prompts\n JY-20118-change-default-sync-strategy\n JY-20183-add-cache-on-auto-log-delay\n JY-20197-add-import-opportunity-batch-job\n 20118-hs-opportunity-make-webhook-strategy-default\n JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based\n JY-20196-handle-opportunity-without-note\n JY-20118-improve-opportunity-import\n JY-20189-handle-activity-search-on-deleted-groups\n JY-20160\n JY-20145-filter-out-converted-leads-when-matching\n JY-20150-skip-push-summary-on-summary-ready-if-autolog\n JY-20132-fix-note-encoding\n JY-19792-clean-logs\n JY-20117-fix-sync-profile-fields-on-empty-fields\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit \nSwitched to a new branch 'JY-20725-handle-HS-search-rate-limit'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-emails:worker-emails_00: stopped\nworker:worker_00: stopped\nworker-calendar:worker-calendar_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n 1) app/Jobs/Crm/MatchActivityCrmData.php (ordered_class_elements)\n ---------- begin diff ----------\n--- /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n+++ /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n@@ -29,24 +29,14 @@\n use InteractsWithQueue;\n use SerializesModels;\n \n+ private const int RETRY_WINDOW_MINUTES = 120;\n+\n public int $maxExceptions = 3;\n \n- private const int RETRY_WINDOW_MINUTES = 120;\n-\n private int $activityId;\n private ?Configuration $fromConfiguration;\n private bool $remoteSearch;\n \n- public function middleware(): array\n- {\n- return [new HandleHubspotRateLimit()];\n- }\n-\n- public function retryUntil(): \\DateTimeInterface\n- {\n- return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n- }\n-\n public function __construct(\n int $activityId,\n ?Configuration $fromConfiguration = null,\n@@ -57,6 +47,16 @@\n $this->remoteSearch = $remoteSearch;\n \n $this->onQueue(Constants::QUEUE_ANALYTICS_LOW);\n+ }\n+\n+ public function middleware(): array\n+ {\n+ return [new HandleHubspotRateLimit()];\n+ }\n+\n+ public function retryUntil(): \\DateTimeInterface\n+ {\n+ return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n }\n \n public function uniqueId(): string\n\n ----------- end diff -----------\n\n 2) app/Services/Crm/Hubspot/HubspotClientInterface.php (phpdoc_order, phpdoc_separation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n@@ -46,9 +46,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 3) app/Services/Crm/Hubspot/Client.php (phpdoc_order, phpdoc_separation, blank_line_before_statement)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/Client.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/Client.php\n@@ -69,10 +69,12 @@\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- * @return T The result of the API call\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@@ -81,6 +83,7 @@\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@@ -232,9 +235,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 4) app/Console/Commands/JiminnyDebugCommand.php (statement_indentation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n+++ /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n@@ -359,11 +359,11 @@\n $crmService = $crmResolver->prepareCrmService();\n \n for ($i = 0 ; $i < 3; $i++) {\n-// if ($i % 25 === 0) {\n-// $this->info(\"Syncing opportunity {$i}\");\n- $this->info(\"Matching contact {$i}\");\n-// }\n-// $crmService->syncOpportunity('374720564');\n+ // if ($i % 25 === 0) {\n+ // $this->info(\"Syncing opportunity {$i}\");\n+ $this->info(\"Matching contact {$i}\");\n+ // }\n+ // $crmService->syncOpportunity('374720564');\n $crmService->matchByName('Robot');\n }\n }\n\n ----------- end diff -----------\n\n\nFixed 4 of 5666 files in 146.870 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix \ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5666 files in 106.192 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5666 files in 66.457 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $","is_focused":true},{"role":"AXRadioButton","text":"DOCKER","depth":2,"bounds":{"left":0.0,"top":0.05888889,"width":0.140625,"height":0.026666667},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.004166667,"top":0.06333333,"width":0.011111111,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"DEV (docker)","depth":2,"bounds":{"left":0.140625,"top":0.05888889,"width":0.140625,"height":0.026666667},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.14479166,"top":0.06333333,"width":0.011111111,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"APP (-zsh)","depth":2,"bounds":{"left":0.28125,"top":0.05888889,"width":0.140625,"height":0.026666667},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.28541666,"top":0.06333333,"width":0.011111111,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"-zsh","depth":2,"bounds":{"left":0.421875,"top":0.05888889,"width":0.140625,"height":0.026666667},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.42604166,"top":0.06333333,"width":0.011111111,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"-zsh","depth":2,"bounds":{"left":0.5625,"top":0.05888889,"width":0.14027777,"height":0.026666667},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.56666666,"top":0.06333333,"width":0.011111111,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"screenpipe\"","depth":2,"bounds":{"left":0.7027778,"top":0.05888889,"width":0.14027777,"height":0.026666667},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.70694447,"top":0.06333333,"width":0.011111111,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"-zsh","depth":2,"bounds":{"left":0.84305555,"top":0.05888889,"width":0.14027777,"height":0.026666667},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.8472222,"top":0.06333333,"width":0.011111111,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"⌥⌘1","depth":1,"bounds":{"left":0.9548611,"top":0.032222223,"width":0.03888889,"height":0.018888889},"on_screen":true,"automation_id":"_NS:8","role_description":"text"},{"role":"AXStaticText","text":"APP (-zsh)","depth":1,"bounds":{"left":0.47430557,"top":0.033333335,"width":0.05138889,"height":0.017777778},"on_screen":true,"role_description":"text"}]...
|
-5826347064466025141
|
-2292063823433160584
|
idle
|
accessibility
|
NULL
|
Last login: Thu May 7 09:44:56 on ttys007
Poetry Last login: Thu May 7 09:44:56 on ttys007
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status
On branch master
Your branch is up to date with 'origin/master'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: .env.local
modified: app/Console/Commands/JiminnyDebugCommand.php
modified: app/Jobs/Team/SyncToIntercom.php
modified: app/Services/PlaybackService.php
modified: config/logging.php
modified: resources/views/partials/crm/push-summary/html-assembly.blade.php
Untracked files:
(use "git add <file>..." to include in what will be committed)
.env.nikilocal
.env.other
WEBHOOK_FILTERING_IMPLEMENTATION.md
app/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php
app/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php
ids.txt
public/favicon.ico
raw_sql_query.sql
tests/Unit/Policies/CanAccessAiReportsTest.php
no changes added to commit (use "git add" and/or "git commit -a")
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 1482, done.
remote: Counting objects: 100% (481/481), done.
remote: Compressing objects: 100% (191/191), done.
remote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)
Receiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.
Resolving deltas: 100% (877/877), completed with 96 local objects.
From github.com:jiminny/app
83b628967a..ad2ce76737 master -> origin/master
1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests
b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
* [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost
* [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc
* [new branch] make-claude-great-again -> origin/make-claude-great-again
* [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507
* [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
Fast-forward
.cursor/rules/frontend-conventions.mdc | 23 ++
.env.production-eu | 2 +-
.env.staging | 2 +-
Makefile | 10 +
app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-
app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-
app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--
app/Component/AskAnything/AskAnythingPromptService.php | 3 +
app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-
app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-
app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-
app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---
app/Component/Twilio/TwilioRepository.php | 27 ++
app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----
app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--
app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++
app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----
app/Console/Commands/Users/SyncToIntercom.php | 4 +-
app/Console/Kernel.php | 3 +-
app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -
app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -
app/Contracts/Repositories/TeamRepository.php | 3 +-
app/Events/Activities/ActivityUpdated.php | 10 +-
app/Events/Activities/Audio/RecordingEvent.php | 6 +-
app/Events/Activities/Softphone/Ended.php | 8 +-
app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-
app/Events/Activities/Softphone/Started.php | 8 +-
app/Http/Controllers/API/ActivityController.php | 17 +-
app/Http/Controllers/API/SoftphoneController.php | 9 +-
app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-
app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-
app/Http/Controllers/Auth/SocialController.php | 6 +-
app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-
app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-
app/Http/Controllers/Kiosk/PartnersController.php | 46 +++
app/Http/Controllers/Kiosk/SearchController.php | 8 +
app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-
app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-
app/Http/Controllers/TeamSetupController.php | 4 +-
app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-
app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-
app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +
app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +
app/Http/Transformers/ActivityTransformer.php | 4 +-
app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-
app/Http/Transformers/PartnerTransformer.php | 1 +
app/Http/Transformers/StageTransformer.php | 6 +-
app/Http/Transformers/UserTransformer.php | 11 +-
app/Interactions/Settings/Teams/CreateTeam.php | 3 +
app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-
app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++
app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++
app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-
app/Jobs/Crm/UpdateStage.php | 3 +
app/Jobs/Team/SyncToIntercom.php | 7 +-
app/Listeners/Teams/SyncIntercomCompany.php | 5 +-
app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-
app/Listeners/Users/SyncIntercom.php | 5 +-
app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++
app/Mail/Reports/ReportNotGenerated.php | 41 +++
app/Models/Activity.php | 25 +-
app/Models/Activity/Question.php | 14 +-
app/Models/Activity/Search.php | 7 +
app/Models/AskAnything/AskAnythingPrompt.php | 6 +
app/Models/AutomatedReport.php | 10 +
app/Models/CoachingFeedback.php | 44 ++-
app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----
app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----
app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --
app/Models/Partner.php | 13 +
app/Models/Playlist/Activity.php | 14 +-
app/Notifications/OwnerInvitedToTrial.php | 14 +-
app/Policies/UserPolicy.php | 16 +-
app/Queue/Worker/Worker.php | 3 +-
app/Repositories/ActivityRepository.php | 13 +-
app/Repositories/AutomatedReportsRepository.php | 42 ++-
app/Repositories/TeamRepository.php | 21 +-
app/Repositories/UserRepository.php | 2 +-
app/Services/Activity/MeetingBotService.php | 8 +-
app/Services/ActivityService.php | 111 ++-----
app/Services/Crm/Hubspot/Service.php | 36 +-
app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-
app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--
app/Services/Kiosk/KioskService.php | 7 +-
app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-
app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++
app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++
app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++
app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----
composer.json | 1 -
composer.lock | 95 +-----
config/secure-headers.php | 5 +-
database/mappings/mapping_activities.json | 16 +
database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++
database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++
database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++
database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++
front-end/package.json | 5 +-
front-end/src/__mocks__/jiminny.js | 4 +-
front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +
front-end/src/__mocks__/setup.js | 1 +
front-end/src/apps/ai-reports-promo.js | 22 ++
front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++
front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++
front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++
front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++
front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++
.../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++
front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-
front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++
front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++
.../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++
front-end/src/components/AiReports/constants.js | 7 +
front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +
front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++
front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +
front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++
front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-
front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-
front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-
front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-
front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++
front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-
front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++
front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++
front-end/src/main.js | 1 +
front-end/src/store/modules/TeamInsights/util.js | 1 +
front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++
front-end/src/store/modules/platform/getters.js | 3 +
front-end/src/utils/index.js | 11 +
front-end/yarn.lock | 21 +-
phpstan-baseline.neon | 60 ----
public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes
public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes
public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes
public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes
public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes
public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes
public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes
public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes
resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++
resources/views/emails/reports/report-not-generated.blade.php | 24 ++
resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-
routes/api.php | 6 +
routes/web.php | 4 +
tests/Feature/Policies/UserPolicyTest.php | 90 ++++-
tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++
tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++
tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++
tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++
tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--
tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-
tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++
tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++
tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++
tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++
tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++
tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-
tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++
tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++
tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-
tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++
tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++
tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-
tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +
tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++
tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-
tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++
tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++
tests/Unit/Models/PartnerTest.php | 28 ++
tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++
tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-
tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++
tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--
tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++
tests/Unit/Services/KioskServiceTest.php | 8 +
tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-
tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++
tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++
tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----
186 files changed, 8538 insertions(+), 1233 deletions(-)
create mode 100644 app/Component/Twilio/TwilioRepository.php
delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php
create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php
delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php
create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php
create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php
create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php
create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php
create mode 100644 app/Mail/Reports/ReportNotGenerated.php
delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php
create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php
create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php
create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php
create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php
create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php
create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php
create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php
create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js
create mode 100644 front-end/src/apps/ai-reports-promo.js
create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html
create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js
create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js
create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js
create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/com/loss-report.pdf
create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf
create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf
create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf
create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php
create mode 100644 resources/views/emails/reports/report-not-generated.blade.php
create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php
create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php
create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php
create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php
create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php
create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php
create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php
create mode 100644 tests/Unit/Models/PartnerTest.php
create mode 100644 tests/Unit/Services/ActivityServiceTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 628, done.
remote: Counting objects: 100% (331/331), done.
remote: Compressing objects: 100% (63/63), done.
remote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)
Receiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.
Resolving deltas: 100% (391/391), completed with 57 local objects.
From github.com:jiminny/app
ad2ce76737..12295204cf master -> origin/master
14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests
f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech
+ ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)
* [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication
* [new branch] desktop-app -> origin/desktop-app
4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again
* [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas
Updating ad2ce76737..12295204cf
Fast-forward
app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-
app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------
app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------
tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------
tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -
11 files changed, 49 insertions(+), 90 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric
Switched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 15, done.
remote: Counting objects: 100% (15/15), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)
Unpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.
From github.com:jiminny/app
c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit
Already up to date.
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 180, done.
remote: Counting objects: 100% (179/179), done.
remote: Compressing objects: 100% (55/55), done.
remote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)
Receiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.
Resolving deltas: 100% (127/127), completed with 47 local objects.
From github.com:jiminny/app
12295204cf..35f036ace6 master -> origin/master
* [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5
5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories
* [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration
+ ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)
8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas
Updating 12295204cf..35f036ace6
Fast-forward
app/Component/Datadog/Constants.php | 1 +
app/Providers/RouteServiceProvider.php | 2 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------
4 files changed, 62 insertions(+), 8 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr
* master
JY-20818-move-AJ-reports-to-separated-datadog-metric
JY-20773-fix-automated-reports-user-pilot-tracking
JY-20157-AJ-report-not-send-notification
JY-20508-notify-before-AJ-report-expiration
JY-20372-ai-reports-promotion-pages
JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
JY-20738-debug-AJ-tracking-UP
a
JY-18909-automated-reports-ask-jiminny
JY-20692-fix-integration-app-[API_KEY]
JY-20553-debug-crm-sync-delays
JY-20698-fix-SF-activity-types-on-new-playbook
JY-20543-AJ-report-tracking
JY-20384-handle-auto-sync-with-no-access-to-event-type
JY-20458-ask-jiminny-user-definitions
JY-19666-fix-import-contacts-account-association
JY-19666-HS-import-contacts-and-accounts-batch-job
JY-20458-Ask-Jiminny-Reports
JY-20200-batch-update-CRM-objects-Salesforce
JY-19666-HS-webhooks-add-contact-and-company
JY-20348-trigger-setup-DI-layout-on-team-creation
JY-20326-refactor-info-message-in-command
JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled
JY-20312-remove-on-update-change-last-synced-at-crm-configurations
JY-20306-SF-skip-auto-sync-for-task-based-playbook
JY-20192-remove-deleted-team-from-saved-search-filters
JY-20197-import-opportunity-batch-job
JY-20293-enable-status-field-for-pipedrive-deals
JY-20191-remove-commands-interactive-prompts
JY-20118-change-default-sync-strategy
JY-20183-add-cache-on-auto-log-delay
JY-20197-add-import-opportunity-batch-job
20118-hs-opportunity-make-webhook-strategy-default
JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based
JY-20196-handle-opportunity-without-note
JY-20118-improve-opportunity-import
JY-20189-handle-activity-search-on-deleted-groups
JY-20160
JY-20145-filter-out-converted-leads-when-matching
JY-20150-skip-push-summary-on-summary-ready-if-autolog
JY-20132-fix-note-encoding
JY-19792-clean-logs
JY-20117-fix-sync-profile-fields-on-empty-fields
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit
Switched to a new branch 'JY-20725-handle-HS-search-rate-limit'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd
docker exec -it docker_lamp_1 bash -c "mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini"
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 supervisorctl restart all
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped
jiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped
jiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped
jiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped
jiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped
worker-analytics:worker-analytics_00: stopped
worker-crm-update:worker-crm-update_00: stopped
worker-download:worker-download_00: stopped
worker-nudges:worker-nudges_00: stopped
artisan-schedule:artisan-schedule_00: stopped
worker-emails:worker-emails_00: stopped
worker:worker_00: stopped
worker-calendar:worker-calendar_00: stopped
jiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped
worker-crm-sync:worker-crm-sync_00: stopped
worker-audio:worker-audio_00: stopped
worker-conferences:worker-conferences_00: stopped
worker-es-update:worker-es-update_00: stopped
artisan-schedule:artisan-schedule_00: started
jiminny-worker-processing-1:jiminny-worker-processing-1_00: started
jiminny-worker-processing-2:jiminny-worker-processing-2_00: started
jiminny-worker-processing-3:jiminny-worker-processing-3_00: started
jiminny-worker-processing-4:jiminny-worker-processing-4_00: started
jiminny-worker-processing-5:jiminny-worker-processing-5_00: started
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started
worker:worker_00: started
worker-analytics:worker-analytics_00: started
worker-audio:worker-audio_00: started
worker-calendar:worker-calendar_00: started
worker-conferences:worker-conferences_00: started
worker-crm-sync:worker-crm-sync_00: started
worker-crm-update:worker-crm-update_00: started
worker-download:worker-download_00: started
worker-emails:worker-emails_00: started
worker-es-update:worker-es-update_00: started
worker-nudges:worker-nudges_00: started
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 php -v
PHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)
Copyright (c) The PHP Group
Zend En...
|
20462
|
NULL
|
NULL
|
NULL
|
|
20492
|
889
|
0
|
2026-05-11T15:39:46.387603+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778513986387_m2.jpg...
|
iTerm2
|
APP (-zsh)
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Last login: Thu May 7 09:44:56 on ttys007
Poetry Last login: Thu May 7 09:44:56 on ttys007
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status
On branch master
Your branch is up to date with 'origin/master'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: .env.local
modified: app/Console/Commands/JiminnyDebugCommand.php
modified: app/Jobs/Team/SyncToIntercom.php
modified: app/Services/PlaybackService.php
modified: config/logging.php
modified: resources/views/partials/crm/push-summary/html-assembly.blade.php
Untracked files:
(use "git add <file>..." to include in what will be committed)
.env.nikilocal
.env.other
WEBHOOK_FILTERING_IMPLEMENTATION.md
app/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php
app/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php
ids.txt
public/favicon.ico
raw_sql_query.sql
tests/Unit/Policies/CanAccessAiReportsTest.php
no changes added to commit (use "git add" and/or "git commit -a")
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 1482, done.
remote: Counting objects: 100% (481/481), done.
remote: Compressing objects: 100% (191/191), done.
remote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)
Receiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.
Resolving deltas: 100% (877/877), completed with 96 local objects.
From github.com:jiminny/app
83b628967a..ad2ce76737 master -> origin/master
1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests
b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
* [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost
* [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc
* [new branch] make-claude-great-again -> origin/make-claude-great-again
* [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507
* [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
Fast-forward
.cursor/rules/frontend-conventions.mdc | 23 ++
.env.production-eu | 2 +-
.env.staging | 2 +-
Makefile | 10 +
app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-
app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-
app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--
app/Component/AskAnything/AskAnythingPromptService.php | 3 +
app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-
app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-
app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-
app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---
app/Component/Twilio/TwilioRepository.php | 27 ++
app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----
app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--
app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++
app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----
app/Console/Commands/Users/SyncToIntercom.php | 4 +-
app/Console/Kernel.php | 3 +-
app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -
app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -
app/Contracts/Repositories/TeamRepository.php | 3 +-
app/Events/Activities/ActivityUpdated.php | 10 +-
app/Events/Activities/Audio/RecordingEvent.php | 6 +-
app/Events/Activities/Softphone/Ended.php | 8 +-
app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-
app/Events/Activities/Softphone/Started.php | 8 +-
app/Http/Controllers/API/ActivityController.php | 17 +-
app/Http/Controllers/API/SoftphoneController.php | 9 +-
app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-
app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-
app/Http/Controllers/Auth/SocialController.php | 6 +-
app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-
app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-
app/Http/Controllers/Kiosk/PartnersController.php | 46 +++
app/Http/Controllers/Kiosk/SearchController.php | 8 +
app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-
app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-
app/Http/Controllers/TeamSetupController.php | 4 +-
app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-
app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-
app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +
app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +
app/Http/Transformers/ActivityTransformer.php | 4 +-
app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-
app/Http/Transformers/PartnerTransformer.php | 1 +
app/Http/Transformers/StageTransformer.php | 6 +-
app/Http/Transformers/UserTransformer.php | 11 +-
app/Interactions/Settings/Teams/CreateTeam.php | 3 +
app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-
app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++
app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++
app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-
app/Jobs/Crm/UpdateStage.php | 3 +
app/Jobs/Team/SyncToIntercom.php | 7 +-
app/Listeners/Teams/SyncIntercomCompany.php | 5 +-
app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-
app/Listeners/Users/SyncIntercom.php | 5 +-
app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++
app/Mail/Reports/ReportNotGenerated.php | 41 +++
app/Models/Activity.php | 25 +-
app/Models/Activity/Question.php | 14 +-
app/Models/Activity/Search.php | 7 +
app/Models/AskAnything/AskAnythingPrompt.php | 6 +
app/Models/AutomatedReport.php | 10 +
app/Models/CoachingFeedback.php | 44 ++-
app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----
app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----
app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --
app/Models/Partner.php | 13 +
app/Models/Playlist/Activity.php | 14 +-
app/Notifications/OwnerInvitedToTrial.php | 14 +-
app/Policies/UserPolicy.php | 16 +-
app/Queue/Worker/Worker.php | 3 +-
app/Repositories/ActivityRepository.php | 13 +-
app/Repositories/AutomatedReportsRepository.php | 42 ++-
app/Repositories/TeamRepository.php | 21 +-
app/Repositories/UserRepository.php | 2 +-
app/Services/Activity/MeetingBotService.php | 8 +-
app/Services/ActivityService.php | 111 ++-----
app/Services/Crm/Hubspot/Service.php | 36 +-
app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-
app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--
app/Services/Kiosk/KioskService.php | 7 +-
app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-
app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++
app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++
app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++
app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----
composer.json | 1 -
composer.lock | 95 +-----
config/secure-headers.php | 5 +-
database/mappings/mapping_activities.json | 16 +
database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++
database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++
database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++
database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++
front-end/package.json | 5 +-
front-end/src/__mocks__/jiminny.js | 4 +-
front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +
front-end/src/__mocks__/setup.js | 1 +
front-end/src/apps/ai-reports-promo.js | 22 ++
front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++
front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++
front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++
front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++
front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++
.../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++
front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-
front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++
front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++
.../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++
front-end/src/components/AiReports/constants.js | 7 +
front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +
front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++
front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +
front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++
front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-
front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-
front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-
front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-
front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++
front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-
front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++
front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++
front-end/src/main.js | 1 +
front-end/src/store/modules/TeamInsights/util.js | 1 +
front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++
front-end/src/store/modules/platform/getters.js | 3 +
front-end/src/utils/index.js | 11 +
front-end/yarn.lock | 21 +-
phpstan-baseline.neon | 60 ----
public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes
public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes
public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes
public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes
public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes
public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes
public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes
public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes
resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++
resources/views/emails/reports/report-not-generated.blade.php | 24 ++
resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-
routes/api.php | 6 +
routes/web.php | 4 +
tests/Feature/Policies/UserPolicyTest.php | 90 ++++-
tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++
tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++
tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++
tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++
tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--
tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-
tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++
tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++
tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++
tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++
tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++
tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-
tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++
tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++
tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-
tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++
tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++
tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-
tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +
tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++
tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-
tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++
tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++
tests/Unit/Models/PartnerTest.php | 28 ++
tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++
tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-
tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++
tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--
tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++
tests/Unit/Services/KioskServiceTest.php | 8 +
tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-
tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++
tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++
tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----
186 files changed, 8538 insertions(+), 1233 deletions(-)
create mode 100644 app/Component/Twilio/TwilioRepository.php
delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php
create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php
delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php
create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php
create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php
create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php
create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php
create mode 100644 app/Mail/Reports/ReportNotGenerated.php
delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php
create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php
create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php
create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php
create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php
create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php
create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php
create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php
create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js
create mode 100644 front-end/src/apps/ai-reports-promo.js
create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html
create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js
create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js
create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js
create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/com/loss-report.pdf
create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf
create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf
create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf
create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php
create mode 100644 resources/views/emails/reports/report-not-generated.blade.php
create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php
create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php
create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php
create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php
create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php
create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php
create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php
create mode 100644 tests/Unit/Models/PartnerTest.php
create mode 100644 tests/Unit/Services/ActivityServiceTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 628, done.
remote: Counting objects: 100% (331/331), done.
remote: Compressing objects: 100% (63/63), done.
remote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)
Receiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.
Resolving deltas: 100% (391/391), completed with 57 local objects.
From github.com:jiminny/app
ad2ce76737..12295204cf master -> origin/master
14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests
f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech
+ ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)
* [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication
* [new branch] desktop-app -> origin/desktop-app
4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again
* [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas
Updating ad2ce76737..12295204cf
Fast-forward
app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-
app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------
app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------
tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------
tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -
11 files changed, 49 insertions(+), 90 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric
Switched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 15, done.
remote: Counting objects: 100% (15/15), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)
Unpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.
From github.com:jiminny/app
c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit
Already up to date.
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 180, done.
remote: Counting objects: 100% (179/179), done.
remote: Compressing objects: 100% (55/55), done.
remote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)
Receiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.
Resolving deltas: 100% (127/127), completed with 47 local objects.
From github.com:jiminny/app
12295204cf..35f036ace6 master -> origin/master
* [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5
5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories
* [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration
+ ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)
8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas
Updating 12295204cf..35f036ace6
Fast-forward
app/Component/Datadog/Constants.php | 1 +
app/Providers/RouteServiceProvider.php | 2 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------
4 files changed, 62 insertions(+), 8 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr
* master
JY-20818-move-AJ-reports-to-separated-datadog-metric
JY-20773-fix-automated-reports-user-pilot-tracking
JY-20157-AJ-report-not-send-notification
JY-20508-notify-before-AJ-report-expiration
JY-20372-ai-reports-promotion-pages
JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
JY-20738-debug-AJ-tracking-UP
a
JY-18909-automated-reports-ask-jiminny
JY-20692-fix-integration-app-[API_KEY]
JY-20553-debug-crm-sync-delays
JY-20698-fix-SF-activity-types-on-new-playbook
JY-20543-AJ-report-tracking
JY-20384-handle-auto-sync-with-no-access-to-event-type
JY-20458-ask-jiminny-user-definitions
JY-19666-fix-import-contacts-account-association
JY-19666-HS-import-contacts-and-accounts-batch-job
JY-20458-Ask-Jiminny-Reports
JY-20200-batch-update-CRM-objects-Salesforce
JY-19666-HS-webhooks-add-contact-and-company
JY-20348-trigger-setup-DI-layout-on-team-creation
JY-20326-refactor-info-message-in-command
JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled
JY-20312-remove-on-update-change-last-synced-at-crm-configurations
JY-20306-SF-skip-auto-sync-for-task-based-playbook
JY-20192-remove-deleted-team-from-saved-search-filters
JY-20197-import-opportunity-batch-job
JY-20293-enable-status-field-for-pipedrive-deals
JY-20191-remove-commands-interactive-prompts
JY-20118-change-default-sync-strategy
JY-20183-add-cache-on-auto-log-delay
JY-20197-add-import-opportunity-batch-job
20118-hs-opportunity-make-webhook-strategy-default
JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based
JY-20196-handle-opportunity-without-note
JY-20118-improve-opportunity-import
JY-20189-handle-activity-search-on-deleted-groups
JY-20160
JY-20145-filter-out-converted-leads-when-matching
JY-20150-skip-push-summary-on-summary-ready-if-autolog
JY-20132-fix-note-encoding
JY-19792-clean-logs
JY-20117-fix-sync-profile-fields-on-empty-fields
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit
Switched to a new branch 'JY-20725-handle-HS-search-rate-limit'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd
docker exec -it docker_lamp_1 bash -c "mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini"
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 supervisorctl restart all
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped
jiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped
jiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped
jiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped
jiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped
worker-analytics:worker-analytics_00: stopped
worker-crm-update:worker-crm-update_00: stopped
worker-download:worker-download_00: stopped
worker-nudges:worker-nudges_00: stopped
artisan-schedule:artisan-schedule_00: stopped
worker-emails:worker-emails_00: stopped
worker:worker_00: stopped
worker-calendar:worker-calendar_00: stopped
jiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped
worker-crm-sync:worker-crm-sync_00: stopped
worker-audio:worker-audio_00: stopped
worker-conferences:worker-conferences_00: stopped
worker-es-update:worker-es-update_00: stopped
artisan-schedule:artisan-schedule_00: started
jiminny-worker-processing-1:jiminny-worker-processing-1_00: started
jiminny-worker-processing-2:jiminny-worker-processing-2_00: started
jiminny-worker-processing-3:jiminny-worker-processing-3_00: started
jiminny-worker-processing-4:jiminny-worker-processing-4_00: started
jiminny-worker-processing-5:jiminny-worker-processing-5_00: started
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started
worker:worker_00: started
worker-analytics:worker-analytics_00: started
worker-audio:worker-audio_00: started
worker-calendar:worker-calendar_00: started
worker-conferences:worker-conferences_00: started
worker-crm-sync:worker-crm-sync_00: started
worker-crm-update:worker-crm-update_00: started
worker-download:worker-download_00: started
worker-emails:worker-emails_00: started
worker-es-update:worker-es-update_00: started
worker-nudges:worker-nudges_00: started
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 php -v
PHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)
Copyright (c) The PHP Group
Zend En...
|
[{"role":"AXTextArea","text [{"role":"AXTextArea","text":"Last login: Thu May 7 09:44:56 on ttys007\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Console/Commands/JiminnyDebugCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Services/PlaybackService.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: config/logging.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: resources/views/partials/crm/push-summary/html-assembly.blade.php\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 1482, done.\nremote: Counting objects: 100% (481/481), done.\nremote: Compressing objects: 100% (191/191), done.\nremote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)\nReceiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.\nResolving deltas: 100% (877/877), completed with 96 local objects.\nFrom github.com:jiminny/app\n 83b628967a..ad2ce76737 master -> origin/master\n 1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n 5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests\n b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n * [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost\n * [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc\n * [new branch] make-claude-great-again -> origin/make-claude-great-again\n * [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507\n * [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nFast-forward\n .cursor/rules/frontend-conventions.mdc | 23 ++\n .env.production-eu | 2 +-\n .env.staging | 2 +-\n Makefile | 10 +\n app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-\n app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-\n app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--\n app/Component/AskAnything/AskAnythingPromptService.php | 3 +\n app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-\n app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-\n app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-\n app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---\n app/Component/Twilio/TwilioRepository.php | 27 ++\n app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----\n app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--\n app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++\n app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----\n app/Console/Commands/Users/SyncToIntercom.php | 4 +-\n app/Console/Kernel.php | 3 +-\n app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -\n app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -\n app/Contracts/Repositories/TeamRepository.php | 3 +-\n app/Events/Activities/ActivityUpdated.php | 10 +-\n app/Events/Activities/Audio/RecordingEvent.php | 6 +-\n app/Events/Activities/Softphone/Ended.php | 8 +-\n app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-\n app/Events/Activities/Softphone/Started.php | 8 +-\n app/Http/Controllers/API/ActivityController.php | 17 +-\n app/Http/Controllers/API/SoftphoneController.php | 9 +-\n app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-\n app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-\n app/Http/Controllers/Auth/SocialController.php | 6 +-\n app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-\n app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-\n app/Http/Controllers/Kiosk/PartnersController.php | 46 +++\n app/Http/Controllers/Kiosk/SearchController.php | 8 +\n app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-\n app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-\n app/Http/Controllers/TeamSetupController.php | 4 +-\n app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-\n app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-\n app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +\n app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +\n app/Http/Transformers/ActivityTransformer.php | 4 +-\n app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-\n app/Http/Transformers/PartnerTransformer.php | 1 +\n app/Http/Transformers/StageTransformer.php | 6 +-\n app/Http/Transformers/UserTransformer.php | 11 +-\n app/Interactions/Settings/Teams/CreateTeam.php | 3 +\n app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-\n app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++\n app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++\n app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-\n app/Jobs/Crm/UpdateStage.php | 3 +\n app/Jobs/Team/SyncToIntercom.php | 7 +-\n app/Listeners/Teams/SyncIntercomCompany.php | 5 +-\n app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-\n app/Listeners/Users/SyncIntercom.php | 5 +-\n app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++\n app/Mail/Reports/ReportNotGenerated.php | 41 +++\n app/Models/Activity.php | 25 +-\n app/Models/Activity/Question.php | 14 +-\n app/Models/Activity/Search.php | 7 +\n app/Models/AskAnything/AskAnythingPrompt.php | 6 +\n app/Models/AutomatedReport.php | 10 +\n app/Models/CoachingFeedback.php | 44 ++-\n app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----\n app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----\n app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --\n app/Models/Partner.php | 13 +\n app/Models/Playlist/Activity.php | 14 +-\n app/Notifications/OwnerInvitedToTrial.php | 14 +-\n app/Policies/UserPolicy.php | 16 +-\n app/Queue/Worker/Worker.php | 3 +-\n app/Repositories/ActivityRepository.php | 13 +-\n app/Repositories/AutomatedReportsRepository.php | 42 ++-\n app/Repositories/TeamRepository.php | 21 +-\n app/Repositories/UserRepository.php | 2 +-\n app/Services/Activity/MeetingBotService.php | 8 +-\n app/Services/ActivityService.php | 111 ++-----\n app/Services/Crm/Hubspot/Service.php | 36 +-\n app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--\n app/Services/Kiosk/KioskService.php | 7 +-\n app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-\n app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++\n app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++\n app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++\n app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----\n composer.json | 1 -\n composer.lock | 95 +-----\n config/secure-headers.php | 5 +-\n database/mappings/mapping_activities.json | 16 +\n database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++\n database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++\n database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++\n database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++\n front-end/package.json | 5 +-\n front-end/src/__mocks__/jiminny.js | 4 +-\n front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +\n front-end/src/__mocks__/setup.js | 1 +\n front-end/src/apps/ai-reports-promo.js | 22 ++\n front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++\n front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++\n .../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++\n front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-\n front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++\n front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++\n .../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++\n front-end/src/components/AiReports/constants.js | 7 +\n front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +\n front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++\n front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +\n front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++\n front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-\n front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-\n front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-\n front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-\n front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++\n front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-\n front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++\n front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++\n front-end/src/main.js | 1 +\n front-end/src/store/modules/TeamInsights/util.js | 1 +\n front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++\n front-end/src/store/modules/platform/getters.js | 3 +\n front-end/src/utils/index.js | 11 +\n front-end/yarn.lock | 21 +-\n phpstan-baseline.neon | 60 ----\n public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes\n public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes\n public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes\n public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes\n public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes\n public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes\n public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes\n public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes\n resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++\n resources/views/emails/reports/report-not-generated.blade.php | 24 ++\n resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-\n routes/api.php | 6 +\n routes/web.php | 4 +\n tests/Feature/Policies/UserPolicyTest.php | 90 ++++-\n tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++\n tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++\n tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++\n tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++\n tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--\n tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-\n tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++\n tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++\n tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++\n tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++\n tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++\n tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-\n tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++\n tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++\n tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-\n tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++\n tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++\n tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-\n tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +\n tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++\n tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-\n tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++\n tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++\n tests/Unit/Models/PartnerTest.php | 28 ++\n tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++\n tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-\n tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++\n tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--\n tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++\n tests/Unit/Services/KioskServiceTest.php | 8 +\n tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-\n tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++\n tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++\n tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----\n 186 files changed, 8538 insertions(+), 1233 deletions(-)\n create mode 100644 app/Component/Twilio/TwilioRepository.php\n delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php\n create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php\n delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php\n create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php\n create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php\n create mode 100644 app/Mail/Reports/ReportNotGenerated.php\n delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php\n create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php\n create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php\n create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php\n create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php\n create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php\n create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php\n create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php\n create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js\n create mode 100644 front-end/src/apps/ai-reports-promo.js\n create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html\n create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js\n create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js\n create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js\n create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/com/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf\n create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf\n create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php\n create mode 100644 resources/views/emails/reports/report-not-generated.blade.php\n create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php\n create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php\n create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php\n create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php\n create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php\n create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php\n create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php\n create mode 100644 tests/Unit/Models/PartnerTest.php\n create mode 100644 tests/Unit/Services/ActivityServiceTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 628, done.\nremote: Counting objects: 100% (331/331), done.\nremote: Compressing objects: 100% (63/63), done.\nremote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)\nReceiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.\nResolving deltas: 100% (391/391), completed with 57 local objects.\nFrom github.com:jiminny/app\n ad2ce76737..12295204cf master -> origin/master\n 14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests\n f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n 1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech\n + ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)\n * [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication\n * [new branch] desktop-app -> origin/desktop-app\n 4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again\n * [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating ad2ce76737..12295204cf\nFast-forward\n app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-\n app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------\n app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------\n tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------\n tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -\n 11 files changed, 49 insertions(+), 90 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric\nSwitched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-calendar:worker-calendar_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker:worker_00: stopped\nworker-emails:worker-emails_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 15, done.\nremote: Counting objects: 100% (15/15), done.\nremote: Compressing objects: 100% (2/2), done.\nremote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)\nUnpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.\nFrom github.com:jiminny/app\n c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit\nAlready up to date.\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 180, done.\nremote: Counting objects: 100% (179/179), done.\nremote: Compressing objects: 100% (55/55), done.\nremote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)\nReceiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.\nResolving deltas: 100% (127/127), completed with 47 local objects.\nFrom github.com:jiminny/app\n 12295204cf..35f036ace6 master -> origin/master\n * [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5\n 5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories\n * [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration\n + ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)\n 8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating 12295204cf..35f036ace6\nFast-forward\n app/Component/Datadog/Constants.php | 1 +\n app/Providers/RouteServiceProvider.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------\n 4 files changed, 62 insertions(+), 8 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr \n* master\n JY-20818-move-AJ-reports-to-separated-datadog-metric\n JY-20773-fix-automated-reports-user-pilot-tracking\n JY-20157-AJ-report-not-send-notification\n JY-20508-notify-before-AJ-report-expiration\n JY-20372-ai-reports-promotion-pages\n JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n JY-20738-debug-AJ-tracking-UP\n a\n JY-18909-automated-reports-ask-jiminny\n JY-20692-fix-integration-app-token-auth-response-change\n JY-20553-debug-crm-sync-delays\n JY-20698-fix-SF-activity-types-on-new-playbook\n JY-20543-AJ-report-tracking\n JY-20384-handle-auto-sync-with-no-access-to-event-type\n JY-20458-ask-jiminny-user-definitions\n JY-19666-fix-import-contacts-account-association\n JY-19666-HS-import-contacts-and-accounts-batch-job\n JY-20458-Ask-Jiminny-Reports\n JY-20200-batch-update-CRM-objects-Salesforce\n JY-19666-HS-webhooks-add-contact-and-company\n JY-20348-trigger-setup-DI-layout-on-team-creation\n JY-20326-refactor-info-message-in-command\n JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled\n JY-20312-remove-on-update-change-last-synced-at-crm-configurations\n JY-20306-SF-skip-auto-sync-for-task-based-playbook\n JY-20192-remove-deleted-team-from-saved-search-filters\n JY-20197-import-opportunity-batch-job\n JY-20293-enable-status-field-for-pipedrive-deals\n JY-20191-remove-commands-interactive-prompts\n JY-20118-change-default-sync-strategy\n JY-20183-add-cache-on-auto-log-delay\n JY-20197-add-import-opportunity-batch-job\n 20118-hs-opportunity-make-webhook-strategy-default\n JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based\n JY-20196-handle-opportunity-without-note\n JY-20118-improve-opportunity-import\n JY-20189-handle-activity-search-on-deleted-groups\n JY-20160\n JY-20145-filter-out-converted-leads-when-matching\n JY-20150-skip-push-summary-on-summary-ready-if-autolog\n JY-20132-fix-note-encoding\n JY-19792-clean-logs\n JY-20117-fix-sync-profile-fields-on-empty-fields\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit \nSwitched to a new branch 'JY-20725-handle-HS-search-rate-limit'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-emails:worker-emails_00: stopped\nworker:worker_00: stopped\nworker-calendar:worker-calendar_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n 1) app/Jobs/Crm/MatchActivityCrmData.php (ordered_class_elements)\n ---------- begin diff ----------\n--- /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n+++ /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n@@ -29,24 +29,14 @@\n use InteractsWithQueue;\n use SerializesModels;\n \n+ private const int RETRY_WINDOW_MINUTES = 120;\n+\n public int $maxExceptions = 3;\n \n- private const int RETRY_WINDOW_MINUTES = 120;\n-\n private int $activityId;\n private ?Configuration $fromConfiguration;\n private bool $remoteSearch;\n \n- public function middleware(): array\n- {\n- return [new HandleHubspotRateLimit()];\n- }\n-\n- public function retryUntil(): \\DateTimeInterface\n- {\n- return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n- }\n-\n public function __construct(\n int $activityId,\n ?Configuration $fromConfiguration = null,\n@@ -57,6 +47,16 @@\n $this->remoteSearch = $remoteSearch;\n \n $this->onQueue(Constants::QUEUE_ANALYTICS_LOW);\n+ }\n+\n+ public function middleware(): array\n+ {\n+ return [new HandleHubspotRateLimit()];\n+ }\n+\n+ public function retryUntil(): \\DateTimeInterface\n+ {\n+ return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n }\n \n public function uniqueId(): string\n\n ----------- end diff -----------\n\n 2) app/Services/Crm/Hubspot/HubspotClientInterface.php (phpdoc_order, phpdoc_separation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n@@ -46,9 +46,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 3) app/Services/Crm/Hubspot/Client.php (phpdoc_order, phpdoc_separation, blank_line_before_statement)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/Client.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/Client.php\n@@ -69,10 +69,12 @@\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- * @return T The result of the API call\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@@ -81,6 +83,7 @@\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@@ -232,9 +235,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 4) app/Console/Commands/JiminnyDebugCommand.php (statement_indentation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n+++ /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n@@ -359,11 +359,11 @@\n $crmService = $crmResolver->prepareCrmService();\n \n for ($i = 0 ; $i < 3; $i++) {\n-// if ($i % 25 === 0) {\n-// $this->info(\"Syncing opportunity {$i}\");\n- $this->info(\"Matching contact {$i}\");\n-// }\n-// $crmService->syncOpportunity('374720564');\n+ // if ($i % 25 === 0) {\n+ // $this->info(\"Syncing opportunity {$i}\");\n+ $this->info(\"Matching contact {$i}\");\n+ // }\n+ // $crmService->syncOpportunity('374720564');\n $crmService->matchByName('Robot');\n }\n }\n\n ----------- end diff -----------\n\n\nFixed 4 of 5666 files in 146.870 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix \ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5666 files in 106.192 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5666 files in 66.457 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $","depth":4,"on_screen":true,"value":"Last login: Thu May 7 09:44:56 on ttys007\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Console/Commands/JiminnyDebugCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Services/PlaybackService.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: config/logging.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: resources/views/partials/crm/push-summary/html-assembly.blade.php\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 1482, done.\nremote: Counting objects: 100% (481/481), done.\nremote: Compressing objects: 100% (191/191), done.\nremote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)\nReceiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.\nResolving deltas: 100% (877/877), completed with 96 local objects.\nFrom github.com:jiminny/app\n 83b628967a..ad2ce76737 master -> origin/master\n 1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n 5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests\n b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n * [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost\n * [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc\n * [new branch] make-claude-great-again -> origin/make-claude-great-again\n * [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507\n * [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nFast-forward\n .cursor/rules/frontend-conventions.mdc | 23 ++\n .env.production-eu | 2 +-\n .env.staging | 2 +-\n Makefile | 10 +\n app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-\n app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-\n app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--\n app/Component/AskAnything/AskAnythingPromptService.php | 3 +\n app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-\n app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-\n app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-\n app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---\n app/Component/Twilio/TwilioRepository.php | 27 ++\n app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----\n app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--\n app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++\n app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----\n app/Console/Commands/Users/SyncToIntercom.php | 4 +-\n app/Console/Kernel.php | 3 +-\n app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -\n app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -\n app/Contracts/Repositories/TeamRepository.php | 3 +-\n app/Events/Activities/ActivityUpdated.php | 10 +-\n app/Events/Activities/Audio/RecordingEvent.php | 6 +-\n app/Events/Activities/Softphone/Ended.php | 8 +-\n app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-\n app/Events/Activities/Softphone/Started.php | 8 +-\n app/Http/Controllers/API/ActivityController.php | 17 +-\n app/Http/Controllers/API/SoftphoneController.php | 9 +-\n app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-\n app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-\n app/Http/Controllers/Auth/SocialController.php | 6 +-\n app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-\n app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-\n app/Http/Controllers/Kiosk/PartnersController.php | 46 +++\n app/Http/Controllers/Kiosk/SearchController.php | 8 +\n app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-\n app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-\n app/Http/Controllers/TeamSetupController.php | 4 +-\n app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-\n app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-\n app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +\n app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +\n app/Http/Transformers/ActivityTransformer.php | 4 +-\n app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-\n app/Http/Transformers/PartnerTransformer.php | 1 +\n app/Http/Transformers/StageTransformer.php | 6 +-\n app/Http/Transformers/UserTransformer.php | 11 +-\n app/Interactions/Settings/Teams/CreateTeam.php | 3 +\n app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-\n app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++\n app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++\n app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-\n app/Jobs/Crm/UpdateStage.php | 3 +\n app/Jobs/Team/SyncToIntercom.php | 7 +-\n app/Listeners/Teams/SyncIntercomCompany.php | 5 +-\n app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-\n app/Listeners/Users/SyncIntercom.php | 5 +-\n app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++\n app/Mail/Reports/ReportNotGenerated.php | 41 +++\n app/Models/Activity.php | 25 +-\n app/Models/Activity/Question.php | 14 +-\n app/Models/Activity/Search.php | 7 +\n app/Models/AskAnything/AskAnythingPrompt.php | 6 +\n app/Models/AutomatedReport.php | 10 +\n app/Models/CoachingFeedback.php | 44 ++-\n app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----\n app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----\n app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --\n app/Models/Partner.php | 13 +\n app/Models/Playlist/Activity.php | 14 +-\n app/Notifications/OwnerInvitedToTrial.php | 14 +-\n app/Policies/UserPolicy.php | 16 +-\n app/Queue/Worker/Worker.php | 3 +-\n app/Repositories/ActivityRepository.php | 13 +-\n app/Repositories/AutomatedReportsRepository.php | 42 ++-\n app/Repositories/TeamRepository.php | 21 +-\n app/Repositories/UserRepository.php | 2 +-\n app/Services/Activity/MeetingBotService.php | 8 +-\n app/Services/ActivityService.php | 111 ++-----\n app/Services/Crm/Hubspot/Service.php | 36 +-\n app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--\n app/Services/Kiosk/KioskService.php | 7 +-\n app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-\n app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++\n app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++\n app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++\n app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----\n composer.json | 1 -\n composer.lock | 95 +-----\n config/secure-headers.php | 5 +-\n database/mappings/mapping_activities.json | 16 +\n database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++\n database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++\n database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++\n database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++\n front-end/package.json | 5 +-\n front-end/src/__mocks__/jiminny.js | 4 +-\n front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +\n front-end/src/__mocks__/setup.js | 1 +\n front-end/src/apps/ai-reports-promo.js | 22 ++\n front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++\n front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++\n .../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++\n front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-\n front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++\n front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++\n .../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++\n front-end/src/components/AiReports/constants.js | 7 +\n front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +\n front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++\n front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +\n front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++\n front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-\n front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-\n front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-\n front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-\n front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++\n front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-\n front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++\n front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++\n front-end/src/main.js | 1 +\n front-end/src/store/modules/TeamInsights/util.js | 1 +\n front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++\n front-end/src/store/modules/platform/getters.js | 3 +\n front-end/src/utils/index.js | 11 +\n front-end/yarn.lock | 21 +-\n phpstan-baseline.neon | 60 ----\n public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes\n public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes\n public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes\n public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes\n public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes\n public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes\n public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes\n public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes\n resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++\n resources/views/emails/reports/report-not-generated.blade.php | 24 ++\n resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-\n routes/api.php | 6 +\n routes/web.php | 4 +\n tests/Feature/Policies/UserPolicyTest.php | 90 ++++-\n tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++\n tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++\n tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++\n tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++\n tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--\n tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-\n tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++\n tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++\n tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++\n tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++\n tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++\n tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-\n tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++\n tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++\n tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-\n tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++\n tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++\n tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-\n tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +\n tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++\n tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-\n tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++\n tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++\n tests/Unit/Models/PartnerTest.php | 28 ++\n tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++\n tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-\n tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++\n tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--\n tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++\n tests/Unit/Services/KioskServiceTest.php | 8 +\n tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-\n tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++\n tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++\n tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----\n 186 files changed, 8538 insertions(+), 1233 deletions(-)\n create mode 100644 app/Component/Twilio/TwilioRepository.php\n delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php\n create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php\n delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php\n create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php\n create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php\n create mode 100644 app/Mail/Reports/ReportNotGenerated.php\n delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php\n create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php\n create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php\n create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php\n create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php\n create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php\n create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php\n create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php\n create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js\n create mode 100644 front-end/src/apps/ai-reports-promo.js\n create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html\n create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js\n create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js\n create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js\n create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/com/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf\n create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf\n create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php\n create mode 100644 resources/views/emails/reports/report-not-generated.blade.php\n create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php\n create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php\n create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php\n create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php\n create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php\n create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php\n create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php\n create mode 100644 tests/Unit/Models/PartnerTest.php\n create mode 100644 tests/Unit/Services/ActivityServiceTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 628, done.\nremote: Counting objects: 100% (331/331), done.\nremote: Compressing objects: 100% (63/63), done.\nremote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)\nReceiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.\nResolving deltas: 100% (391/391), completed with 57 local objects.\nFrom github.com:jiminny/app\n ad2ce76737..12295204cf master -> origin/master\n 14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests\n f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n 1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech\n + ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)\n * [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication\n * [new branch] desktop-app -> origin/desktop-app\n 4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again\n * [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating ad2ce76737..12295204cf\nFast-forward\n app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-\n app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------\n app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------\n tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------\n tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -\n 11 files changed, 49 insertions(+), 90 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric\nSwitched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-calendar:worker-calendar_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker:worker_00: stopped\nworker-emails:worker-emails_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 15, done.\nremote: Counting objects: 100% (15/15), done.\nremote: Compressing objects: 100% (2/2), done.\nremote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)\nUnpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.\nFrom github.com:jiminny/app\n c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit\nAlready up to date.\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 180, done.\nremote: Counting objects: 100% (179/179), done.\nremote: Compressing objects: 100% (55/55), done.\nremote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)\nReceiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.\nResolving deltas: 100% (127/127), completed with 47 local objects.\nFrom github.com:jiminny/app\n 12295204cf..35f036ace6 master -> origin/master\n * [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5\n 5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories\n * [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration\n + ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)\n 8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating 12295204cf..35f036ace6\nFast-forward\n app/Component/Datadog/Constants.php | 1 +\n app/Providers/RouteServiceProvider.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------\n 4 files changed, 62 insertions(+), 8 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr \n* master\n JY-20818-move-AJ-reports-to-separated-datadog-metric\n JY-20773-fix-automated-reports-user-pilot-tracking\n JY-20157-AJ-report-not-send-notification\n JY-20508-notify-before-AJ-report-expiration\n JY-20372-ai-reports-promotion-pages\n JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n JY-20738-debug-AJ-tracking-UP\n a\n JY-18909-automated-reports-ask-jiminny\n JY-20692-fix-integration-app-token-auth-response-change\n JY-20553-debug-crm-sync-delays\n JY-20698-fix-SF-activity-types-on-new-playbook\n JY-20543-AJ-report-tracking\n JY-20384-handle-auto-sync-with-no-access-to-event-type\n JY-20458-ask-jiminny-user-definitions\n JY-19666-fix-import-contacts-account-association\n JY-19666-HS-import-contacts-and-accounts-batch-job\n JY-20458-Ask-Jiminny-Reports\n JY-20200-batch-update-CRM-objects-Salesforce\n JY-19666-HS-webhooks-add-contact-and-company\n JY-20348-trigger-setup-DI-layout-on-team-creation\n JY-20326-refactor-info-message-in-command\n JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled\n JY-20312-remove-on-update-change-last-synced-at-crm-configurations\n JY-20306-SF-skip-auto-sync-for-task-based-playbook\n JY-20192-remove-deleted-team-from-saved-search-filters\n JY-20197-import-opportunity-batch-job\n JY-20293-enable-status-field-for-pipedrive-deals\n JY-20191-remove-commands-interactive-prompts\n JY-20118-change-default-sync-strategy\n JY-20183-add-cache-on-auto-log-delay\n JY-20197-add-import-opportunity-batch-job\n 20118-hs-opportunity-make-webhook-strategy-default\n JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based\n JY-20196-handle-opportunity-without-note\n JY-20118-improve-opportunity-import\n JY-20189-handle-activity-search-on-deleted-groups\n JY-20160\n JY-20145-filter-out-converted-leads-when-matching\n JY-20150-skip-push-summary-on-summary-ready-if-autolog\n JY-20132-fix-note-encoding\n JY-19792-clean-logs\n JY-20117-fix-sync-profile-fields-on-empty-fields\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit \nSwitched to a new branch 'JY-20725-handle-HS-search-rate-limit'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-emails:worker-emails_00: stopped\nworker:worker_00: stopped\nworker-calendar:worker-calendar_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n 1) app/Jobs/Crm/MatchActivityCrmData.php (ordered_class_elements)\n ---------- begin diff ----------\n--- /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n+++ /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n@@ -29,24 +29,14 @@\n use InteractsWithQueue;\n use SerializesModels;\n \n+ private const int RETRY_WINDOW_MINUTES = 120;\n+\n public int $maxExceptions = 3;\n \n- private const int RETRY_WINDOW_MINUTES = 120;\n-\n private int $activityId;\n private ?Configuration $fromConfiguration;\n private bool $remoteSearch;\n \n- public function middleware(): array\n- {\n- return [new HandleHubspotRateLimit()];\n- }\n-\n- public function retryUntil(): \\DateTimeInterface\n- {\n- return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n- }\n-\n public function __construct(\n int $activityId,\n ?Configuration $fromConfiguration = null,\n@@ -57,6 +47,16 @@\n $this->remoteSearch = $remoteSearch;\n \n $this->onQueue(Constants::QUEUE_ANALYTICS_LOW);\n+ }\n+\n+ public function middleware(): array\n+ {\n+ return [new HandleHubspotRateLimit()];\n+ }\n+\n+ public function retryUntil(): \\DateTimeInterface\n+ {\n+ return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n }\n \n public function uniqueId(): string\n\n ----------- end diff -----------\n\n 2) app/Services/Crm/Hubspot/HubspotClientInterface.php (phpdoc_order, phpdoc_separation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n@@ -46,9 +46,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 3) app/Services/Crm/Hubspot/Client.php (phpdoc_order, phpdoc_separation, blank_line_before_statement)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/Client.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/Client.php\n@@ -69,10 +69,12 @@\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- * @return T The result of the API call\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@@ -81,6 +83,7 @@\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@@ -232,9 +235,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 4) app/Console/Commands/JiminnyDebugCommand.php (statement_indentation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n+++ /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n@@ -359,11 +359,11 @@\n $crmService = $crmResolver->prepareCrmService();\n \n for ($i = 0 ; $i < 3; $i++) {\n-// if ($i % 25 === 0) {\n-// $this->info(\"Syncing opportunity {$i}\");\n- $this->info(\"Matching contact {$i}\");\n-// }\n-// $crmService->syncOpportunity('374720564');\n+ // if ($i % 25 === 0) {\n+ // $this->info(\"Syncing opportunity {$i}\");\n+ $this->info(\"Matching contact {$i}\");\n+ // }\n+ // $crmService->syncOpportunity('374720564');\n $crmService->matchByName('Robot');\n }\n }\n\n ----------- end diff -----------\n\n\nFixed 4 of 5666 files in 146.870 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix \ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5666 files in 106.192 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5666 files in 66.457 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $","is_focused":true},{"role":"AXRadioButton","text":"DOCKER","depth":2,"bounds":{"left":0.27027926,"top":1.0,"width":0.06732048,"height":-0.042298436},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.27227393,"top":1.0,"width":0.005319149,"height":-0.04549086},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"DEV (docker)","depth":2,"bounds":{"left":0.33759972,"top":1.0,"width":0.06732048,"height":-0.042298436},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.33959442,"top":1.0,"width":0.005319149,"height":-0.04549086},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"APP (-zsh)","depth":2,"bounds":{"left":0.40492022,"top":1.0,"width":0.06732048,"height":-0.042298436},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.4069149,"top":1.0,"width":0.005319149,"height":-0.04549086},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"-zsh","depth":2,"bounds":{"left":0.4722407,"top":1.0,"width":0.06732048,"height":-0.042298436},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.4742354,"top":1.0,"width":0.005319149,"height":-0.04549086},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"-zsh","depth":2,"bounds":{"left":0.53956115,"top":1.0,"width":0.06715426,"height":-0.042298436},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.5415558,"top":1.0,"width":0.005319149,"height":-0.04549086},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"screenpipe\"","depth":2,"bounds":{"left":0.60671544,"top":1.0,"width":0.06715426,"height":-0.042298436},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.6087101,"top":1.0,"width":0.005319149,"height":-0.04549086},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"-zsh","depth":2,"bounds":{"left":0.67386967,"top":1.0,"width":0.06715426,"height":-0.042298436},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.67586434,"top":1.0,"width":0.005319149,"height":-0.04549086},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"⌥⌘1","depth":1,"bounds":{"left":0.7273936,"top":1.0,"width":0.01861702,"height":-0.023144484},"on_screen":true,"automation_id":"_NS:8","role_description":"text"},{"role":"AXStaticText","text":"APP (-zsh)","depth":1,"bounds":{"left":0.4973404,"top":1.0,"width":0.024601065,"height":-0.02394259},"on_screen":true,"role_description":"text"}]...
|
-5826347064466025141
|
-2292063823433160584
|
idle
|
accessibility
|
NULL
|
Last login: Thu May 7 09:44:56 on ttys007
Poetry Last login: Thu May 7 09:44:56 on ttys007
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status
On branch master
Your branch is up to date with 'origin/master'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: .env.local
modified: app/Console/Commands/JiminnyDebugCommand.php
modified: app/Jobs/Team/SyncToIntercom.php
modified: app/Services/PlaybackService.php
modified: config/logging.php
modified: resources/views/partials/crm/push-summary/html-assembly.blade.php
Untracked files:
(use "git add <file>..." to include in what will be committed)
.env.nikilocal
.env.other
WEBHOOK_FILTERING_IMPLEMENTATION.md
app/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php
app/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php
ids.txt
public/favicon.ico
raw_sql_query.sql
tests/Unit/Policies/CanAccessAiReportsTest.php
no changes added to commit (use "git add" and/or "git commit -a")
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 1482, done.
remote: Counting objects: 100% (481/481), done.
remote: Compressing objects: 100% (191/191), done.
remote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)
Receiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.
Resolving deltas: 100% (877/877), completed with 96 local objects.
From github.com:jiminny/app
83b628967a..ad2ce76737 master -> origin/master
1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests
b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
* [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost
* [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc
* [new branch] make-claude-great-again -> origin/make-claude-great-again
* [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507
* [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
Fast-forward
.cursor/rules/frontend-conventions.mdc | 23 ++
.env.production-eu | 2 +-
.env.staging | 2 +-
Makefile | 10 +
app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-
app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-
app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--
app/Component/AskAnything/AskAnythingPromptService.php | 3 +
app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-
app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-
app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-
app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---
app/Component/Twilio/TwilioRepository.php | 27 ++
app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----
app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--
app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++
app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----
app/Console/Commands/Users/SyncToIntercom.php | 4 +-
app/Console/Kernel.php | 3 +-
app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -
app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -
app/Contracts/Repositories/TeamRepository.php | 3 +-
app/Events/Activities/ActivityUpdated.php | 10 +-
app/Events/Activities/Audio/RecordingEvent.php | 6 +-
app/Events/Activities/Softphone/Ended.php | 8 +-
app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-
app/Events/Activities/Softphone/Started.php | 8 +-
app/Http/Controllers/API/ActivityController.php | 17 +-
app/Http/Controllers/API/SoftphoneController.php | 9 +-
app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-
app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-
app/Http/Controllers/Auth/SocialController.php | 6 +-
app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-
app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-
app/Http/Controllers/Kiosk/PartnersController.php | 46 +++
app/Http/Controllers/Kiosk/SearchController.php | 8 +
app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-
app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-
app/Http/Controllers/TeamSetupController.php | 4 +-
app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-
app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-
app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +
app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +
app/Http/Transformers/ActivityTransformer.php | 4 +-
app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-
app/Http/Transformers/PartnerTransformer.php | 1 +
app/Http/Transformers/StageTransformer.php | 6 +-
app/Http/Transformers/UserTransformer.php | 11 +-
app/Interactions/Settings/Teams/CreateTeam.php | 3 +
app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-
app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++
app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++
app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-
app/Jobs/Crm/UpdateStage.php | 3 +
app/Jobs/Team/SyncToIntercom.php | 7 +-
app/Listeners/Teams/SyncIntercomCompany.php | 5 +-
app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-
app/Listeners/Users/SyncIntercom.php | 5 +-
app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++
app/Mail/Reports/ReportNotGenerated.php | 41 +++
app/Models/Activity.php | 25 +-
app/Models/Activity/Question.php | 14 +-
app/Models/Activity/Search.php | 7 +
app/Models/AskAnything/AskAnythingPrompt.php | 6 +
app/Models/AutomatedReport.php | 10 +
app/Models/CoachingFeedback.php | 44 ++-
app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----
app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----
app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --
app/Models/Partner.php | 13 +
app/Models/Playlist/Activity.php | 14 +-
app/Notifications/OwnerInvitedToTrial.php | 14 +-
app/Policies/UserPolicy.php | 16 +-
app/Queue/Worker/Worker.php | 3 +-
app/Repositories/ActivityRepository.php | 13 +-
app/Repositories/AutomatedReportsRepository.php | 42 ++-
app/Repositories/TeamRepository.php | 21 +-
app/Repositories/UserRepository.php | 2 +-
app/Services/Activity/MeetingBotService.php | 8 +-
app/Services/ActivityService.php | 111 ++-----
app/Services/Crm/Hubspot/Service.php | 36 +-
app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-
app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--
app/Services/Kiosk/KioskService.php | 7 +-
app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-
app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++
app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++
app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++
app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----
composer.json | 1 -
composer.lock | 95 +-----
config/secure-headers.php | 5 +-
database/mappings/mapping_activities.json | 16 +
database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++
database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++
database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++
database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++
front-end/package.json | 5 +-
front-end/src/__mocks__/jiminny.js | 4 +-
front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +
front-end/src/__mocks__/setup.js | 1 +
front-end/src/apps/ai-reports-promo.js | 22 ++
front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++
front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++
front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++
front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++
front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++
.../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++
front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-
front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++
front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++
.../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++
front-end/src/components/AiReports/constants.js | 7 +
front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +
front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++
front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +
front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++
front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-
front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-
front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-
front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-
front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++
front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-
front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++
front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++
front-end/src/main.js | 1 +
front-end/src/store/modules/TeamInsights/util.js | 1 +
front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++
front-end/src/store/modules/platform/getters.js | 3 +
front-end/src/utils/index.js | 11 +
front-end/yarn.lock | 21 +-
phpstan-baseline.neon | 60 ----
public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes
public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes
public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes
public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes
public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes
public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes
public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes
public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes
resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++
resources/views/emails/reports/report-not-generated.blade.php | 24 ++
resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-
routes/api.php | 6 +
routes/web.php | 4 +
tests/Feature/Policies/UserPolicyTest.php | 90 ++++-
tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++
tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++
tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++
tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++
tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--
tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-
tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++
tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++
tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++
tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++
tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++
tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-
tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++
tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++
tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-
tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++
tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++
tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-
tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +
tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++
tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-
tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++
tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++
tests/Unit/Models/PartnerTest.php | 28 ++
tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++
tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-
tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++
tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--
tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++
tests/Unit/Services/KioskServiceTest.php | 8 +
tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-
tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++
tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++
tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----
186 files changed, 8538 insertions(+), 1233 deletions(-)
create mode 100644 app/Component/Twilio/TwilioRepository.php
delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php
create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php
delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php
create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php
create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php
create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php
create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php
create mode 100644 app/Mail/Reports/ReportNotGenerated.php
delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php
create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php
create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php
create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php
create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php
create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php
create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php
create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php
create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js
create mode 100644 front-end/src/apps/ai-reports-promo.js
create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html
create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js
create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js
create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js
create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/com/loss-report.pdf
create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf
create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf
create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf
create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php
create mode 100644 resources/views/emails/reports/report-not-generated.blade.php
create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php
create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php
create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php
create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php
create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php
create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php
create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php
create mode 100644 tests/Unit/Models/PartnerTest.php
create mode 100644 tests/Unit/Services/ActivityServiceTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 628, done.
remote: Counting objects: 100% (331/331), done.
remote: Compressing objects: 100% (63/63), done.
remote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)
Receiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.
Resolving deltas: 100% (391/391), completed with 57 local objects.
From github.com:jiminny/app
ad2ce76737..12295204cf master -> origin/master
14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests
f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech
+ ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)
* [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication
* [new branch] desktop-app -> origin/desktop-app
4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again
* [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas
Updating ad2ce76737..12295204cf
Fast-forward
app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-
app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------
app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------
tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------
tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -
11 files changed, 49 insertions(+), 90 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric
Switched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 15, done.
remote: Counting objects: 100% (15/15), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)
Unpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.
From github.com:jiminny/app
c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit
Already up to date.
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 180, done.
remote: Counting objects: 100% (179/179), done.
remote: Compressing objects: 100% (55/55), done.
remote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)
Receiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.
Resolving deltas: 100% (127/127), completed with 47 local objects.
From github.com:jiminny/app
12295204cf..35f036ace6 master -> origin/master
* [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5
5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories
* [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration
+ ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)
8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas
Updating 12295204cf..35f036ace6
Fast-forward
app/Component/Datadog/Constants.php | 1 +
app/Providers/RouteServiceProvider.php | 2 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------
4 files changed, 62 insertions(+), 8 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr
* master
JY-20818-move-AJ-reports-to-separated-datadog-metric
JY-20773-fix-automated-reports-user-pilot-tracking
JY-20157-AJ-report-not-send-notification
JY-20508-notify-before-AJ-report-expiration
JY-20372-ai-reports-promotion-pages
JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
JY-20738-debug-AJ-tracking-UP
a
JY-18909-automated-reports-ask-jiminny
JY-20692-fix-integration-app-[API_KEY]
JY-20553-debug-crm-sync-delays
JY-20698-fix-SF-activity-types-on-new-playbook
JY-20543-AJ-report-tracking
JY-20384-handle-auto-sync-with-no-access-to-event-type
JY-20458-ask-jiminny-user-definitions
JY-19666-fix-import-contacts-account-association
JY-19666-HS-import-contacts-and-accounts-batch-job
JY-20458-Ask-Jiminny-Reports
JY-20200-batch-update-CRM-objects-Salesforce
JY-19666-HS-webhooks-add-contact-and-company
JY-20348-trigger-setup-DI-layout-on-team-creation
JY-20326-refactor-info-message-in-command
JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled
JY-20312-remove-on-update-change-last-synced-at-crm-configurations
JY-20306-SF-skip-auto-sync-for-task-based-playbook
JY-20192-remove-deleted-team-from-saved-search-filters
JY-20197-import-opportunity-batch-job
JY-20293-enable-status-field-for-pipedrive-deals
JY-20191-remove-commands-interactive-prompts
JY-20118-change-default-sync-strategy
JY-20183-add-cache-on-auto-log-delay
JY-20197-add-import-opportunity-batch-job
20118-hs-opportunity-make-webhook-strategy-default
JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based
JY-20196-handle-opportunity-without-note
JY-20118-improve-opportunity-import
JY-20189-handle-activity-search-on-deleted-groups
JY-20160
JY-20145-filter-out-converted-leads-when-matching
JY-20150-skip-push-summary-on-summary-ready-if-autolog
JY-20132-fix-note-encoding
JY-19792-clean-logs
JY-20117-fix-sync-profile-fields-on-empty-fields
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit
Switched to a new branch 'JY-20725-handle-HS-search-rate-limit'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd
docker exec -it docker_lamp_1 bash -c "mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini"
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 supervisorctl restart all
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped
jiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped
jiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped
jiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped
jiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped
worker-analytics:worker-analytics_00: stopped
worker-crm-update:worker-crm-update_00: stopped
worker-download:worker-download_00: stopped
worker-nudges:worker-nudges_00: stopped
artisan-schedule:artisan-schedule_00: stopped
worker-emails:worker-emails_00: stopped
worker:worker_00: stopped
worker-calendar:worker-calendar_00: stopped
jiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped
worker-crm-sync:worker-crm-sync_00: stopped
worker-audio:worker-audio_00: stopped
worker-conferences:worker-conferences_00: stopped
worker-es-update:worker-es-update_00: stopped
artisan-schedule:artisan-schedule_00: started
jiminny-worker-processing-1:jiminny-worker-processing-1_00: started
jiminny-worker-processing-2:jiminny-worker-processing-2_00: started
jiminny-worker-processing-3:jiminny-worker-processing-3_00: started
jiminny-worker-processing-4:jiminny-worker-processing-4_00: started
jiminny-worker-processing-5:jiminny-worker-processing-5_00: started
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started
worker:worker_00: started
worker-analytics:worker-analytics_00: started
worker-audio:worker-audio_00: started
worker-calendar:worker-calendar_00: started
worker-conferences:worker-conferences_00: started
worker-crm-sync:worker-crm-sync_00: started
worker-crm-update:worker-crm-update_00: started
worker-download:worker-download_00: started
worker-emails:worker-emails_00: started
worker-es-update:worker-es-update_00: started
worker-nudges:worker-nudges_00: started
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 php -v
PHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)
Copyright (c) The PHP Group
Zend En...
|
20463
|
NULL
|
NULL
|
NULL
|
|
20493
|
888
|
0
|
2026-05-11T15:39:50.949362+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778513990949_m1.jpg...
|
iTerm2
|
APP (-zsh)
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Last login: Thu May 7 09:44:56 on ttys007
Poetry Last login: Thu May 7 09:44:56 on ttys007
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status
On branch master
Your branch is up to date with 'origin/master'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: .env.local
modified: app/Console/Commands/JiminnyDebugCommand.php
modified: app/Jobs/Team/SyncToIntercom.php
modified: app/Services/PlaybackService.php
modified: config/logging.php
modified: resources/views/partials/crm/push-summary/html-assembly.blade.php
Untracked files:
(use "git add <file>..." to include in what will be committed)
.env.nikilocal
.env.other
WEBHOOK_FILTERING_IMPLEMENTATION.md
app/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php
app/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php
ids.txt
public/favicon.ico
raw_sql_query.sql
tests/Unit/Policies/CanAccessAiReportsTest.php
no changes added to commit (use "git add" and/or "git commit -a")
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 1482, done.
remote: Counting objects: 100% (481/481), done.
remote: Compressing objects: 100% (191/191), done.
remote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)
Receiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.
Resolving deltas: 100% (877/877), completed with 96 local objects.
From github.com:jiminny/app
83b628967a..ad2ce76737 master -> origin/master
1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests
b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
* [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost
* [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc
* [new branch] make-claude-great-again -> origin/make-claude-great-again
* [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507
* [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
Fast-forward
.cursor/rules/frontend-conventions.mdc | 23 ++
.env.production-eu | 2 +-
.env.staging | 2 +-
Makefile | 10 +
app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-
app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-
app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--
app/Component/AskAnything/AskAnythingPromptService.php | 3 +
app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-
app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-
app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-
app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---
app/Component/Twilio/TwilioRepository.php | 27 ++
app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----
app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--
app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++
app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----
app/Console/Commands/Users/SyncToIntercom.php | 4 +-
app/Console/Kernel.php | 3 +-
app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -
app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -
app/Contracts/Repositories/TeamRepository.php | 3 +-
app/Events/Activities/ActivityUpdated.php | 10 +-
app/Events/Activities/Audio/RecordingEvent.php | 6 +-
app/Events/Activities/Softphone/Ended.php | 8 +-
app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-
app/Events/Activities/Softphone/Started.php | 8 +-
app/Http/Controllers/API/ActivityController.php | 17 +-
app/Http/Controllers/API/SoftphoneController.php | 9 +-
app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-
app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-
app/Http/Controllers/Auth/SocialController.php | 6 +-
app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-
app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-
app/Http/Controllers/Kiosk/PartnersController.php | 46 +++
app/Http/Controllers/Kiosk/SearchController.php | 8 +
app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-
app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-
app/Http/Controllers/TeamSetupController.php | 4 +-
app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-
app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-
app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +
app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +
app/Http/Transformers/ActivityTransformer.php | 4 +-
app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-
app/Http/Transformers/PartnerTransformer.php | 1 +
app/Http/Transformers/StageTransformer.php | 6 +-
app/Http/Transformers/UserTransformer.php | 11 +-
app/Interactions/Settings/Teams/CreateTeam.php | 3 +
app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-
app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++
app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++
app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-
app/Jobs/Crm/UpdateStage.php | 3 +
app/Jobs/Team/SyncToIntercom.php | 7 +-
app/Listeners/Teams/SyncIntercomCompany.php | 5 +-
app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-
app/Listeners/Users/SyncIntercom.php | 5 +-
app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++
app/Mail/Reports/ReportNotGenerated.php | 41 +++
app/Models/Activity.php | 25 +-
app/Models/Activity/Question.php | 14 +-
app/Models/Activity/Search.php | 7 +
app/Models/AskAnything/AskAnythingPrompt.php | 6 +
app/Models/AutomatedReport.php | 10 +
app/Models/CoachingFeedback.php | 44 ++-
app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----
app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----
app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --
app/Models/Partner.php | 13 +
app/Models/Playlist/Activity.php | 14 +-
app/Notifications/OwnerInvitedToTrial.php | 14 +-
app/Policies/UserPolicy.php | 16 +-
app/Queue/Worker/Worker.php | 3 +-
app/Repositories/ActivityRepository.php | 13 +-
app/Repositories/AutomatedReportsRepository.php | 42 ++-
app/Repositories/TeamRepository.php | 21 +-
app/Repositories/UserRepository.php | 2 +-
app/Services/Activity/MeetingBotService.php | 8 +-
app/Services/ActivityService.php | 111 ++-----
app/Services/Crm/Hubspot/Service.php | 36 +-
app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-
app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--
app/Services/Kiosk/KioskService.php | 7 +-
app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-
app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++
app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++
app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++
app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----
composer.json | 1 -
composer.lock | 95 +-----
config/secure-headers.php | 5 +-
database/mappings/mapping_activities.json | 16 +
database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++
database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++
database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++
database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++
front-end/package.json | 5 +-
front-end/src/__mocks__/jiminny.js | 4 +-
front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +
front-end/src/__mocks__/setup.js | 1 +
front-end/src/apps/ai-reports-promo.js | 22 ++
front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++
front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++
front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++
front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++
front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++
.../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++
front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-
front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++
front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++
.../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++
front-end/src/components/AiReports/constants.js | 7 +
front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +
front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++
front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +
front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++
front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-
front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-
front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-
front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-
front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++
front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-
front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++
front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++
front-end/src/main.js | 1 +
front-end/src/store/modules/TeamInsights/util.js | 1 +
front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++
front-end/src/store/modules/platform/getters.js | 3 +
front-end/src/utils/index.js | 11 +
front-end/yarn.lock | 21 +-
phpstan-baseline.neon | 60 ----
public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes
public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes
public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes
public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes
public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes
public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes
public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes
public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes
resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++
resources/views/emails/reports/report-not-generated.blade.php | 24 ++
resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-
routes/api.php | 6 +
routes/web.php | 4 +
tests/Feature/Policies/UserPolicyTest.php | 90 ++++-
tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++
tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++
tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++
tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++
tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--
tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-
tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++
tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++
tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++
tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++
tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++
tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-
tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++
tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++
tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-
tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++
tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++
tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-
tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +
tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++
tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-
tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++
tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++
tests/Unit/Models/PartnerTest.php | 28 ++
tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++
tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-
tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++
tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--
tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++
tests/Unit/Services/KioskServiceTest.php | 8 +
tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-
tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++
tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++
tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----
186 files changed, 8538 insertions(+), 1233 deletions(-)
create mode 100644 app/Component/Twilio/TwilioRepository.php
delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php
create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php
delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php
create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php
create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php
create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php
create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php
create mode 100644 app/Mail/Reports/ReportNotGenerated.php
delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php
create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php
create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php
create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php
create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php
create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php
create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php
create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php
create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js
create mode 100644 front-end/src/apps/ai-reports-promo.js
create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html
create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js
create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js
create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js
create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/com/loss-report.pdf
create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf
create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf
create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf
create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php
create mode 100644 resources/views/emails/reports/report-not-generated.blade.php
create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php
create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php
create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php
create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php
create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php
create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php
create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php
create mode 100644 tests/Unit/Models/PartnerTest.php
create mode 100644 tests/Unit/Services/ActivityServiceTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 628, done.
remote: Counting objects: 100% (331/331), done.
remote: Compressing objects: 100% (63/63), done.
remote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)
Receiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.
Resolving deltas: 100% (391/391), completed with 57 local objects.
From github.com:jiminny/app
ad2ce76737..12295204cf master -> origin/master
14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests
f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech
+ ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)
* [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication
* [new branch] desktop-app -> origin/desktop-app
4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again
* [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas
Updating ad2ce76737..12295204cf
Fast-forward
app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-
app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------
app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------
tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------
tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -
11 files changed, 49 insertions(+), 90 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric
Switched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 15, done.
remote: Counting objects: 100% (15/15), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)
Unpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.
From github.com:jiminny/app
c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit
Already up to date.
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 180, done.
remote: Counting objects: 100% (179/179), done.
remote: Compressing objects: 100% (55/55), done.
remote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)
Receiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.
Resolving deltas: 100% (127/127), completed with 47 local objects.
From github.com:jiminny/app
12295204cf..35f036ace6 master -> origin/master
* [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5
5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories
* [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration
+ ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)
8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas
Updating 12295204cf..35f036ace6
Fast-forward
app/Component/Datadog/Constants.php | 1 +
app/Providers/RouteServiceProvider.php | 2 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------
4 files changed, 62 insertions(+), 8 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr
* master
JY-20818-move-AJ-reports-to-separated-datadog-metric
JY-20773-fix-automated-reports-user-pilot-tracking
JY-20157-AJ-report-not-send-notification
JY-20508-notify-before-AJ-report-expiration
JY-20372-ai-reports-promotion-pages
JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
JY-20738-debug-AJ-tracking-UP
a
JY-18909-automated-reports-ask-jiminny
JY-20692-fix-integration-app-[API_KEY]
JY-20553-debug-crm-sync-delays
JY-20698-fix-SF-activity-types-on-new-playbook
JY-20543-AJ-report-tracking
JY-20384-handle-auto-sync-with-no-access-to-event-type
JY-20458-ask-jiminny-user-definitions
JY-19666-fix-import-contacts-account-association
JY-19666-HS-import-contacts-and-accounts-batch-job
JY-20458-Ask-Jiminny-Reports
JY-20200-batch-update-CRM-objects-Salesforce
JY-19666-HS-webhooks-add-contact-and-company
JY-20348-trigger-setup-DI-layout-on-team-creation
JY-20326-refactor-info-message-in-command
JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled
JY-20312-remove-on-update-change-last-synced-at-crm-configurations
JY-20306-SF-skip-auto-sync-for-task-based-playbook
JY-20192-remove-deleted-team-from-saved-search-filters
JY-20197-import-opportunity-batch-job
JY-20293-enable-status-field-for-pipedrive-deals
JY-20191-remove-commands-interactive-prompts
JY-20118-change-default-sync-strategy
JY-20183-add-cache-on-auto-log-delay
JY-20197-add-import-opportunity-batch-job
20118-hs-opportunity-make-webhook-strategy-default
JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based
JY-20196-handle-opportunity-without-note
JY-20118-improve-opportunity-import
JY-20189-handle-activity-search-on-deleted-groups
JY-20160
JY-20145-filter-out-converted-leads-when-matching
JY-20150-skip-push-summary-on-summary-ready-if-autolog
JY-20132-fix-note-encoding
JY-19792-clean-logs
JY-20117-fix-sync-profile-fields-on-empty-fields
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit
Switched to a new branch 'JY-20725-handle-HS-search-rate-limit'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd
docker exec -it docker_lamp_1 bash -c "mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini"
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 supervisorctl restart all
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped
jiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped
jiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped
jiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped
jiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped
worker-analytics:worker-analytics_00: stopped
worker-crm-update:worker-crm-update_00: stopped
worker-download:worker-download_00: stopped
worker-nudges:worker-nudges_00: stopped
artisan-schedule:artisan-schedule_00: stopped
worker-emails:worker-emails_00: stopped
worker:worker_00: stopped
worker-calendar:worker-calendar_00: stopped
jiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped
worker-crm-sync:worker-crm-sync_00: stopped
worker-audio:worker-audio_00: stopped
worker-conferences:worker-conferences_00: stopped
worker-es-update:worker-es-update_00: stopped
artisan-schedule:artisan-schedule_00: started
jiminny-worker-processing-1:jiminny-worker-processing-1_00: started
jiminny-worker-processing-2:jiminny-worker-processing-2_00: started
jiminny-worker-processing-3:jiminny-worker-processing-3_00: started
jiminny-worker-processing-4:jiminny-worker-processing-4_00: started
jiminny-worker-processing-5:jiminny-worker-processing-5_00: started
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started
worker:worker_00: started
worker-analytics:worker-analytics_00: started
worker-audio:worker-audio_00: started
worker-calendar:worker-calendar_00: started
worker-conferences:worker-conferences_00: started
worker-crm-sync:worker-crm-sync_00: started
worker-crm-update:worker-crm-update_00: started
worker-download:worker-download_00: started
worker-emails:worker-emails_00: started
worker-es-update:worker-es-update_00: started
worker-nudges:worker-nudges_00: started
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 php -v
PHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)
Copyright (c) The PHP Group
Zend En...
|
[{"role":"AXTextArea","text [{"role":"AXTextArea","text":"Last login: Thu May 7 09:44:56 on ttys007\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Console/Commands/JiminnyDebugCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Services/PlaybackService.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: config/logging.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: resources/views/partials/crm/push-summary/html-assembly.blade.php\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 1482, done.\nremote: Counting objects: 100% (481/481), done.\nremote: Compressing objects: 100% (191/191), done.\nremote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)\nReceiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.\nResolving deltas: 100% (877/877), completed with 96 local objects.\nFrom github.com:jiminny/app\n 83b628967a..ad2ce76737 master -> origin/master\n 1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n 5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests\n b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n * [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost\n * [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc\n * [new branch] make-claude-great-again -> origin/make-claude-great-again\n * [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507\n * [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nFast-forward\n .cursor/rules/frontend-conventions.mdc | 23 ++\n .env.production-eu | 2 +-\n .env.staging | 2 +-\n Makefile | 10 +\n app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-\n app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-\n app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--\n app/Component/AskAnything/AskAnythingPromptService.php | 3 +\n app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-\n app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-\n app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-\n app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---\n app/Component/Twilio/TwilioRepository.php | 27 ++\n app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----\n app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--\n app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++\n app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----\n app/Console/Commands/Users/SyncToIntercom.php | 4 +-\n app/Console/Kernel.php | 3 +-\n app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -\n app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -\n app/Contracts/Repositories/TeamRepository.php | 3 +-\n app/Events/Activities/ActivityUpdated.php | 10 +-\n app/Events/Activities/Audio/RecordingEvent.php | 6 +-\n app/Events/Activities/Softphone/Ended.php | 8 +-\n app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-\n app/Events/Activities/Softphone/Started.php | 8 +-\n app/Http/Controllers/API/ActivityController.php | 17 +-\n app/Http/Controllers/API/SoftphoneController.php | 9 +-\n app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-\n app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-\n app/Http/Controllers/Auth/SocialController.php | 6 +-\n app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-\n app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-\n app/Http/Controllers/Kiosk/PartnersController.php | 46 +++\n app/Http/Controllers/Kiosk/SearchController.php | 8 +\n app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-\n app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-\n app/Http/Controllers/TeamSetupController.php | 4 +-\n app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-\n app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-\n app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +\n app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +\n app/Http/Transformers/ActivityTransformer.php | 4 +-\n app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-\n app/Http/Transformers/PartnerTransformer.php | 1 +\n app/Http/Transformers/StageTransformer.php | 6 +-\n app/Http/Transformers/UserTransformer.php | 11 +-\n app/Interactions/Settings/Teams/CreateTeam.php | 3 +\n app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-\n app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++\n app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++\n app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-\n app/Jobs/Crm/UpdateStage.php | 3 +\n app/Jobs/Team/SyncToIntercom.php | 7 +-\n app/Listeners/Teams/SyncIntercomCompany.php | 5 +-\n app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-\n app/Listeners/Users/SyncIntercom.php | 5 +-\n app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++\n app/Mail/Reports/ReportNotGenerated.php | 41 +++\n app/Models/Activity.php | 25 +-\n app/Models/Activity/Question.php | 14 +-\n app/Models/Activity/Search.php | 7 +\n app/Models/AskAnything/AskAnythingPrompt.php | 6 +\n app/Models/AutomatedReport.php | 10 +\n app/Models/CoachingFeedback.php | 44 ++-\n app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----\n app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----\n app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --\n app/Models/Partner.php | 13 +\n app/Models/Playlist/Activity.php | 14 +-\n app/Notifications/OwnerInvitedToTrial.php | 14 +-\n app/Policies/UserPolicy.php | 16 +-\n app/Queue/Worker/Worker.php | 3 +-\n app/Repositories/ActivityRepository.php | 13 +-\n app/Repositories/AutomatedReportsRepository.php | 42 ++-\n app/Repositories/TeamRepository.php | 21 +-\n app/Repositories/UserRepository.php | 2 +-\n app/Services/Activity/MeetingBotService.php | 8 +-\n app/Services/ActivityService.php | 111 ++-----\n app/Services/Crm/Hubspot/Service.php | 36 +-\n app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--\n app/Services/Kiosk/KioskService.php | 7 +-\n app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-\n app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++\n app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++\n app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++\n app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----\n composer.json | 1 -\n composer.lock | 95 +-----\n config/secure-headers.php | 5 +-\n database/mappings/mapping_activities.json | 16 +\n database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++\n database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++\n database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++\n database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++\n front-end/package.json | 5 +-\n front-end/src/__mocks__/jiminny.js | 4 +-\n front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +\n front-end/src/__mocks__/setup.js | 1 +\n front-end/src/apps/ai-reports-promo.js | 22 ++\n front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++\n front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++\n .../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++\n front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-\n front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++\n front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++\n .../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++\n front-end/src/components/AiReports/constants.js | 7 +\n front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +\n front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++\n front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +\n front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++\n front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-\n front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-\n front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-\n front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-\n front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++\n front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-\n front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++\n front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++\n front-end/src/main.js | 1 +\n front-end/src/store/modules/TeamInsights/util.js | 1 +\n front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++\n front-end/src/store/modules/platform/getters.js | 3 +\n front-end/src/utils/index.js | 11 +\n front-end/yarn.lock | 21 +-\n phpstan-baseline.neon | 60 ----\n public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes\n public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes\n public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes\n public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes\n public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes\n public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes\n public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes\n public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes\n resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++\n resources/views/emails/reports/report-not-generated.blade.php | 24 ++\n resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-\n routes/api.php | 6 +\n routes/web.php | 4 +\n tests/Feature/Policies/UserPolicyTest.php | 90 ++++-\n tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++\n tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++\n tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++\n tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++\n tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--\n tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-\n tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++\n tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++\n tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++\n tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++\n tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++\n tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-\n tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++\n tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++\n tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-\n tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++\n tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++\n tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-\n tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +\n tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++\n tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-\n tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++\n tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++\n tests/Unit/Models/PartnerTest.php | 28 ++\n tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++\n tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-\n tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++\n tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--\n tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++\n tests/Unit/Services/KioskServiceTest.php | 8 +\n tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-\n tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++\n tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++\n tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----\n 186 files changed, 8538 insertions(+), 1233 deletions(-)\n create mode 100644 app/Component/Twilio/TwilioRepository.php\n delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php\n create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php\n delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php\n create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php\n create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php\n create mode 100644 app/Mail/Reports/ReportNotGenerated.php\n delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php\n create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php\n create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php\n create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php\n create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php\n create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php\n create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php\n create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php\n create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js\n create mode 100644 front-end/src/apps/ai-reports-promo.js\n create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html\n create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js\n create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js\n create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js\n create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/com/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf\n create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf\n create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php\n create mode 100644 resources/views/emails/reports/report-not-generated.blade.php\n create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php\n create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php\n create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php\n create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php\n create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php\n create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php\n create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php\n create mode 100644 tests/Unit/Models/PartnerTest.php\n create mode 100644 tests/Unit/Services/ActivityServiceTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 628, done.\nremote: Counting objects: 100% (331/331), done.\nremote: Compressing objects: 100% (63/63), done.\nremote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)\nReceiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.\nResolving deltas: 100% (391/391), completed with 57 local objects.\nFrom github.com:jiminny/app\n ad2ce76737..12295204cf master -> origin/master\n 14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests\n f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n 1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech\n + ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)\n * [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication\n * [new branch] desktop-app -> origin/desktop-app\n 4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again\n * [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating ad2ce76737..12295204cf\nFast-forward\n app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-\n app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------\n app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------\n tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------\n tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -\n 11 files changed, 49 insertions(+), 90 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric\nSwitched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-calendar:worker-calendar_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker:worker_00: stopped\nworker-emails:worker-emails_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 15, done.\nremote: Counting objects: 100% (15/15), done.\nremote: Compressing objects: 100% (2/2), done.\nremote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)\nUnpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.\nFrom github.com:jiminny/app\n c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit\nAlready up to date.\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 180, done.\nremote: Counting objects: 100% (179/179), done.\nremote: Compressing objects: 100% (55/55), done.\nremote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)\nReceiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.\nResolving deltas: 100% (127/127), completed with 47 local objects.\nFrom github.com:jiminny/app\n 12295204cf..35f036ace6 master -> origin/master\n * [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5\n 5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories\n * [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration\n + ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)\n 8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating 12295204cf..35f036ace6\nFast-forward\n app/Component/Datadog/Constants.php | 1 +\n app/Providers/RouteServiceProvider.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------\n 4 files changed, 62 insertions(+), 8 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr \n* master\n JY-20818-move-AJ-reports-to-separated-datadog-metric\n JY-20773-fix-automated-reports-user-pilot-tracking\n JY-20157-AJ-report-not-send-notification\n JY-20508-notify-before-AJ-report-expiration\n JY-20372-ai-reports-promotion-pages\n JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n JY-20738-debug-AJ-tracking-UP\n a\n JY-18909-automated-reports-ask-jiminny\n JY-20692-fix-integration-app-token-auth-response-change\n JY-20553-debug-crm-sync-delays\n JY-20698-fix-SF-activity-types-on-new-playbook\n JY-20543-AJ-report-tracking\n JY-20384-handle-auto-sync-with-no-access-to-event-type\n JY-20458-ask-jiminny-user-definitions\n JY-19666-fix-import-contacts-account-association\n JY-19666-HS-import-contacts-and-accounts-batch-job\n JY-20458-Ask-Jiminny-Reports\n JY-20200-batch-update-CRM-objects-Salesforce\n JY-19666-HS-webhooks-add-contact-and-company\n JY-20348-trigger-setup-DI-layout-on-team-creation\n JY-20326-refactor-info-message-in-command\n JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled\n JY-20312-remove-on-update-change-last-synced-at-crm-configurations\n JY-20306-SF-skip-auto-sync-for-task-based-playbook\n JY-20192-remove-deleted-team-from-saved-search-filters\n JY-20197-import-opportunity-batch-job\n JY-20293-enable-status-field-for-pipedrive-deals\n JY-20191-remove-commands-interactive-prompts\n JY-20118-change-default-sync-strategy\n JY-20183-add-cache-on-auto-log-delay\n JY-20197-add-import-opportunity-batch-job\n 20118-hs-opportunity-make-webhook-strategy-default\n JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based\n JY-20196-handle-opportunity-without-note\n JY-20118-improve-opportunity-import\n JY-20189-handle-activity-search-on-deleted-groups\n JY-20160\n JY-20145-filter-out-converted-leads-when-matching\n JY-20150-skip-push-summary-on-summary-ready-if-autolog\n JY-20132-fix-note-encoding\n JY-19792-clean-logs\n JY-20117-fix-sync-profile-fields-on-empty-fields\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit \nSwitched to a new branch 'JY-20725-handle-HS-search-rate-limit'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-emails:worker-emails_00: stopped\nworker:worker_00: stopped\nworker-calendar:worker-calendar_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n 1) app/Jobs/Crm/MatchActivityCrmData.php (ordered_class_elements)\n ---------- begin diff ----------\n--- /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n+++ /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n@@ -29,24 +29,14 @@\n use InteractsWithQueue;\n use SerializesModels;\n \n+ private const int RETRY_WINDOW_MINUTES = 120;\n+\n public int $maxExceptions = 3;\n \n- private const int RETRY_WINDOW_MINUTES = 120;\n-\n private int $activityId;\n private ?Configuration $fromConfiguration;\n private bool $remoteSearch;\n \n- public function middleware(): array\n- {\n- return [new HandleHubspotRateLimit()];\n- }\n-\n- public function retryUntil(): \\DateTimeInterface\n- {\n- return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n- }\n-\n public function __construct(\n int $activityId,\n ?Configuration $fromConfiguration = null,\n@@ -57,6 +47,16 @@\n $this->remoteSearch = $remoteSearch;\n \n $this->onQueue(Constants::QUEUE_ANALYTICS_LOW);\n+ }\n+\n+ public function middleware(): array\n+ {\n+ return [new HandleHubspotRateLimit()];\n+ }\n+\n+ public function retryUntil(): \\DateTimeInterface\n+ {\n+ return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n }\n \n public function uniqueId(): string\n\n ----------- end diff -----------\n\n 2) app/Services/Crm/Hubspot/HubspotClientInterface.php (phpdoc_order, phpdoc_separation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n@@ -46,9 +46,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 3) app/Services/Crm/Hubspot/Client.php (phpdoc_order, phpdoc_separation, blank_line_before_statement)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/Client.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/Client.php\n@@ -69,10 +69,12 @@\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- * @return T The result of the API call\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@@ -81,6 +83,7 @@\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@@ -232,9 +235,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 4) app/Console/Commands/JiminnyDebugCommand.php (statement_indentation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n+++ /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n@@ -359,11 +359,11 @@\n $crmService = $crmResolver->prepareCrmService();\n \n for ($i = 0 ; $i < 3; $i++) {\n-// if ($i % 25 === 0) {\n-// $this->info(\"Syncing opportunity {$i}\");\n- $this->info(\"Matching contact {$i}\");\n-// }\n-// $crmService->syncOpportunity('374720564');\n+ // if ($i % 25 === 0) {\n+ // $this->info(\"Syncing opportunity {$i}\");\n+ $this->info(\"Matching contact {$i}\");\n+ // }\n+ // $crmService->syncOpportunity('374720564');\n $crmService->matchByName('Robot');\n }\n }\n\n ----------- end diff -----------\n\n\nFixed 4 of 5666 files in 146.870 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix \ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5666 files in 106.192 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5666 files in 66.457 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $","depth":4,"on_screen":true,"value":"Last login: Thu May 7 09:44:56 on ttys007\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\n\nPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Console/Commands/JiminnyDebugCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: app/Services/PlaybackService.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: config/logging.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: resources/views/partials/crm/push-summary/html-assembly.blade.php\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 1482, done.\nremote: Counting objects: 100% (481/481), done.\nremote: Compressing objects: 100% (191/191), done.\nremote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)\nReceiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.\nResolving deltas: 100% (877/877), completed with 96 local objects.\nFrom github.com:jiminny/app\n 83b628967a..ad2ce76737 master -> origin/master\n 1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n 5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests\n b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n * [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost\n * [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc\n * [new branch] make-claude-great-again -> origin/make-claude-great-again\n * [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507\n * [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tresources/views/partials/crm/push-summary/html-assembly.blade.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nerror: Your local changes to the following files would be overwritten by merge:\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Jobs/Team/SyncToIntercom.php\nPlease commit your changes or stash them before you merge.\nAborting\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nUpdating 83b628967a..ad2ce76737\nFast-forward\n .cursor/rules/frontend-conventions.mdc | 23 ++\n .env.production-eu | 2 +-\n .env.staging | 2 +-\n Makefile | 10 +\n app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-\n app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-\n app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--\n app/Component/AskAnything/AskAnythingPromptService.php | 3 +\n app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-\n app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-\n app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-\n app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---\n app/Component/Twilio/TwilioRepository.php | 27 ++\n app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----\n app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--\n app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++\n app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----\n app/Console/Commands/Users/SyncToIntercom.php | 4 +-\n app/Console/Kernel.php | 3 +-\n app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -\n app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -\n app/Contracts/Repositories/TeamRepository.php | 3 +-\n app/Events/Activities/ActivityUpdated.php | 10 +-\n app/Events/Activities/Audio/RecordingEvent.php | 6 +-\n app/Events/Activities/Softphone/Ended.php | 8 +-\n app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-\n app/Events/Activities/Softphone/Started.php | 8 +-\n app/Http/Controllers/API/ActivityController.php | 17 +-\n app/Http/Controllers/API/SoftphoneController.php | 9 +-\n app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-\n app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-\n app/Http/Controllers/Auth/SocialController.php | 6 +-\n app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-\n app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-\n app/Http/Controllers/Kiosk/PartnersController.php | 46 +++\n app/Http/Controllers/Kiosk/SearchController.php | 8 +\n app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-\n app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-\n app/Http/Controllers/TeamSetupController.php | 4 +-\n app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-\n app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-\n app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +\n app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +\n app/Http/Transformers/ActivityTransformer.php | 4 +-\n app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-\n app/Http/Transformers/PartnerTransformer.php | 1 +\n app/Http/Transformers/StageTransformer.php | 6 +-\n app/Http/Transformers/UserTransformer.php | 11 +-\n app/Interactions/Settings/Teams/CreateTeam.php | 3 +\n app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-\n app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++\n app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++\n app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-\n app/Jobs/Crm/UpdateStage.php | 3 +\n app/Jobs/Team/SyncToIntercom.php | 7 +-\n app/Listeners/Teams/SyncIntercomCompany.php | 5 +-\n app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-\n app/Listeners/Users/SyncIntercom.php | 5 +-\n app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++\n app/Mail/Reports/ReportNotGenerated.php | 41 +++\n app/Models/Activity.php | 25 +-\n app/Models/Activity/Question.php | 14 +-\n app/Models/Activity/Search.php | 7 +\n app/Models/AskAnything/AskAnythingPrompt.php | 6 +\n app/Models/AutomatedReport.php | 10 +\n app/Models/CoachingFeedback.php | 44 ++-\n app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----\n app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----\n app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --\n app/Models/Partner.php | 13 +\n app/Models/Playlist/Activity.php | 14 +-\n app/Notifications/OwnerInvitedToTrial.php | 14 +-\n app/Policies/UserPolicy.php | 16 +-\n app/Queue/Worker/Worker.php | 3 +-\n app/Repositories/ActivityRepository.php | 13 +-\n app/Repositories/AutomatedReportsRepository.php | 42 ++-\n app/Repositories/TeamRepository.php | 21 +-\n app/Repositories/UserRepository.php | 2 +-\n app/Services/Activity/MeetingBotService.php | 8 +-\n app/Services/ActivityService.php | 111 ++-----\n app/Services/Crm/Hubspot/Service.php | 36 +-\n app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--\n app/Services/Kiosk/KioskService.php | 7 +-\n app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-\n app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++\n app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++\n app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++\n app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----\n composer.json | 1 -\n composer.lock | 95 +-----\n config/secure-headers.php | 5 +-\n database/mappings/mapping_activities.json | 16 +\n database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++\n database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++\n database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++\n database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++\n front-end/package.json | 5 +-\n front-end/src/__mocks__/jiminny.js | 4 +-\n front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +\n front-end/src/__mocks__/setup.js | 1 +\n front-end/src/apps/ai-reports-promo.js | 22 ++\n front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++\n front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++\n front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++\n .../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++\n front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-\n front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++\n front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++\n .../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++\n front-end/src/components/AiReports/constants.js | 7 +\n front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +\n front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-\n front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++\n front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +\n front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++\n front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-\n front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-\n front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-\n front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-\n front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++\n front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-\n front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++\n front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++\n front-end/src/main.js | 1 +\n front-end/src/store/modules/TeamInsights/util.js | 1 +\n front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++\n front-end/src/store/modules/platform/getters.js | 3 +\n front-end/src/utils/index.js | 11 +\n front-end/yarn.lock | 21 +-\n phpstan-baseline.neon | 60 ----\n public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes\n public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes\n public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes\n public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes\n public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes\n public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes\n public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes\n public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes\n resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++\n resources/views/emails/reports/report-not-generated.blade.php | 24 ++\n resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-\n routes/api.php | 6 +\n routes/web.php | 4 +\n tests/Feature/Policies/UserPolicyTest.php | 90 ++++-\n tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++\n tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++\n tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++\n tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++\n tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--\n tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-\n tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++\n tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++\n tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++\n tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++\n tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++\n tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-\n tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++\n tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++\n tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-\n tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++\n tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++\n tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-\n tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +\n tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++\n tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-\n tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++\n tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++\n tests/Unit/Models/PartnerTest.php | 28 ++\n tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++\n tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-\n tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++\n tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--\n tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++\n tests/Unit/Services/KioskServiceTest.php | 8 +\n tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-\n tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++\n tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++\n tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----\n 186 files changed, 8538 insertions(+), 1233 deletions(-)\n create mode 100644 app/Component/Twilio/TwilioRepository.php\n delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php\n create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php\n delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php\n create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php\n create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php\n create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php\n create mode 100644 app/Mail/Reports/ReportNotGenerated.php\n delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php\n create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php\n create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php\n create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php\n create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php\n create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php\n create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php\n create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php\n create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js\n create mode 100644 front-end/src/apps/ai-reports-promo.js\n create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js\n create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html\n create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js\n create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js\n create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js\n create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js\n create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/com/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf\n create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf\n create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf\n create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf\n create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf\n create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php\n create mode 100644 resources/views/emails/reports/report-not-generated.blade.php\n create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php\n create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php\n create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php\n create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php\n create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php\n create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php\n create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php\n create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php\n create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php\n create mode 100644 tests/Unit/Models/PartnerTest.php\n create mode 100644 tests/Unit/Services/ActivityServiceTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php\n create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 628, done.\nremote: Counting objects: 100% (331/331), done.\nremote: Compressing objects: 100% (63/63), done.\nremote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)\nReceiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.\nResolving deltas: 100% (391/391), completed with 57 local objects.\nFrom github.com:jiminny/app\n ad2ce76737..12295204cf master -> origin/master\n 14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3\n b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests\n f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import\n * [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n 1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech\n + ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)\n * [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication\n * [new branch] desktop-app -> origin/desktop-app\n 4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again\n * [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating ad2ce76737..12295204cf\nFast-forward\n app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-\n app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------\n app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------\n tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------\n tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -\n tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -\n 11 files changed, 49 insertions(+), 90 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric\nSwitched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-calendar:worker-calendar_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker:worker_00: stopped\nworker-emails:worker-emails_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ git status\nOn branch master\nYour branch is up to date with 'origin/master'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tmodified: .env.local\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.nikilocal\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\t.env.other\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tWEBHOOK_FILTERING_IMPLEMENTATION.md\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tids.txt\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\tpublic/favicon.ico\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\traw_sql_query.sql\n\u0000\u0000\u0000\u0000\u0000\u0000\u0000\ttests/Unit/Policies/CanAccessAiReportsTest.php\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull\nremote: Enumerating objects: 15, done.\nremote: Counting objects: 100% (15/15), done.\nremote: Compressing objects: 100% (2/2), done.\nremote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)\nUnpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.\nFrom github.com:jiminny/app\n c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall\n * [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit\nAlready up to date.\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull \nremote: Enumerating objects: 180, done.\nremote: Counting objects: 100% (179/179), done.\nremote: Compressing objects: 100% (55/55), done.\nremote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)\nReceiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.\nResolving deltas: 100% (127/127), completed with 47 local objects.\nFrom github.com:jiminny/app\n 12295204cf..35f036ace6 master -> origin/master\n * [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5\n 5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering\n f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue\n * [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories\n * [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration\n + ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)\n 8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas\nUpdating 12295204cf..35f036ace6\nFast-forward\n app/Component/Datadog/Constants.php | 1 +\n app/Providers/RouteServiceProvider.php | 2 +-\n app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++\n tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------\n 4 files changed, 62 insertions(+), 8 deletions(-)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr \n* master\n JY-20818-move-AJ-reports-to-separated-datadog-metric\n JY-20773-fix-automated-reports-user-pilot-tracking\n JY-20157-AJ-report-not-send-notification\n JY-20508-notify-before-AJ-report-expiration\n JY-20372-ai-reports-promotion-pages\n JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null\n JY-20738-debug-AJ-tracking-UP\n a\n JY-18909-automated-reports-ask-jiminny\n JY-20692-fix-integration-app-token-auth-response-change\n JY-20553-debug-crm-sync-delays\n JY-20698-fix-SF-activity-types-on-new-playbook\n JY-20543-AJ-report-tracking\n JY-20384-handle-auto-sync-with-no-access-to-event-type\n JY-20458-ask-jiminny-user-definitions\n JY-19666-fix-import-contacts-account-association\n JY-19666-HS-import-contacts-and-accounts-batch-job\n JY-20458-Ask-Jiminny-Reports\n JY-20200-batch-update-CRM-objects-Salesforce\n JY-19666-HS-webhooks-add-contact-and-company\n JY-20348-trigger-setup-DI-layout-on-team-creation\n JY-20326-refactor-info-message-in-command\n JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled\n JY-20312-remove-on-update-change-last-synced-at-crm-configurations\n JY-20306-SF-skip-auto-sync-for-task-based-playbook\n JY-20192-remove-deleted-team-from-saved-search-filters\n JY-20197-import-opportunity-batch-job\n JY-20293-enable-status-field-for-pipedrive-deals\n JY-20191-remove-commands-interactive-prompts\n JY-20118-change-default-sync-strategy\n JY-20183-add-cache-on-auto-log-delay\n JY-20197-add-import-opportunity-batch-job\n 20118-hs-opportunity-make-webhook-strategy-default\n JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based\n JY-20196-handle-opportunity-without-note\n JY-20118-improve-opportunity-import\n JY-20189-handle-activity-search-on-deleted-groups\n JY-20160\n JY-20145-filter-out-converted-leads-when-matching\n JY-20150-skip-push-summary-on-summary-ready-if-autolog\n JY-20132-fix-note-encoding\n JY-19792-clean-logs\n JY-20117-fix-sync-profile-fields-on-empty-fields\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit \nSwitched to a new branch 'JY-20725-handle-HS-search-rate-limit'\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd\ndocker exec -it docker_lamp_1 bash -c \"mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini\"\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 supervisorctl restart all\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped\njiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped\njiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped\njiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped\njiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped\nworker-analytics:worker-analytics_00: stopped\nworker-crm-update:worker-crm-update_00: stopped\nworker-download:worker-download_00: stopped\nworker-nudges:worker-nudges_00: stopped\nartisan-schedule:artisan-schedule_00: stopped\nworker-emails:worker-emails_00: stopped\nworker:worker_00: stopped\nworker-calendar:worker-calendar_00: stopped\njiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped\nworker-crm-sync:worker-crm-sync_00: stopped\nworker-audio:worker-audio_00: stopped\nworker-conferences:worker-conferences_00: stopped\nworker-es-update:worker-es-update_00: stopped\nartisan-schedule:artisan-schedule_00: started\njiminny-worker-processing-1:jiminny-worker-processing-1_00: started\njiminny-worker-processing-2:jiminny-worker-processing-2_00: started\njiminny-worker-processing-3:jiminny-worker-processing-3_00: started\njiminny-worker-processing-4:jiminny-worker-processing-4_00: started\njiminny-worker-processing-5:jiminny-worker-processing-5_00: started\njiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started\nworker:worker_00: started\nworker-analytics:worker-analytics_00: started\nworker-audio:worker-audio_00: started\nworker-calendar:worker-calendar_00: started\nworker-conferences:worker-conferences_00: started\nworker-crm-sync:worker-crm-sync_00: started\nworker-crm-update:worker-crm-update_00: started\nworker-download:worker-download_00: started\nworker-emails:worker-emails_00: started\nworker-es-update:worker-es-update_00: started\nworker-nudges:worker-nudges_00: started\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\ndocker exec -it docker_lamp_1 php -v\nPHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)\nCopyright (c) The PHP Group\nZend Engine v4.3.30, Copyright (c) Zend Technologies\n with Zend OPcache v8.3.30, Copyright (c), by Zend Technologies\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n 1) app/Jobs/Crm/MatchActivityCrmData.php (ordered_class_elements)\n ---------- begin diff ----------\n--- /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n+++ /home/jiminny/app/Jobs/Crm/MatchActivityCrmData.php\n@@ -29,24 +29,14 @@\n use InteractsWithQueue;\n use SerializesModels;\n \n+ private const int RETRY_WINDOW_MINUTES = 120;\n+\n public int $maxExceptions = 3;\n \n- private const int RETRY_WINDOW_MINUTES = 120;\n-\n private int $activityId;\n private ?Configuration $fromConfiguration;\n private bool $remoteSearch;\n \n- public function middleware(): array\n- {\n- return [new HandleHubspotRateLimit()];\n- }\n-\n- public function retryUntil(): \\DateTimeInterface\n- {\n- return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n- }\n-\n public function __construct(\n int $activityId,\n ?Configuration $fromConfiguration = null,\n@@ -57,6 +47,16 @@\n $this->remoteSearch = $remoteSearch;\n \n $this->onQueue(Constants::QUEUE_ANALYTICS_LOW);\n+ }\n+\n+ public function middleware(): array\n+ {\n+ return [new HandleHubspotRateLimit()];\n+ }\n+\n+ public function retryUntil(): \\DateTimeInterface\n+ {\n+ return now()->addMinutes(self::RETRY_WINDOW_MINUTES);\n }\n \n public function uniqueId(): string\n\n ----------- end diff -----------\n\n 2) app/Services/Crm/Hubspot/HubspotClientInterface.php (phpdoc_order, phpdoc_separation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/HubspotClientInterface.php\n@@ -46,9 +46,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 3) app/Services/Crm/Hubspot/Client.php (phpdoc_order, phpdoc_separation, blank_line_before_statement)\n ---------- begin diff ----------\n--- /home/jiminny/app/Services/Crm/Hubspot/Client.php\n+++ /home/jiminny/app/Services/Crm/Hubspot/Client.php\n@@ -69,10 +69,12 @@\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- * @return T The result of the API call\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@@ -81,6 +83,7 @@\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@@ -232,9 +235,11 @@\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- * @return array The search response with 'results', 'total', 'paging' keys\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\n ----------- end diff -----------\n\n 4) app/Console/Commands/JiminnyDebugCommand.php (statement_indentation)\n ---------- begin diff ----------\n--- /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n+++ /home/jiminny/app/Console/Commands/JiminnyDebugCommand.php\n@@ -359,11 +359,11 @@\n $crmService = $crmResolver->prepareCrmService();\n \n for ($i = 0 ; $i < 3; $i++) {\n-// if ($i % 25 === 0) {\n-// $this->info(\"Syncing opportunity {$i}\");\n- $this->info(\"Matching contact {$i}\");\n-// }\n-// $crmService->syncOpportunity('374720564');\n+ // if ($i % 25 === 0) {\n+ // $this->info(\"Syncing opportunity {$i}\");\n+ $this->info(\"Matching contact {$i}\");\n+ // }\n+ // $crmService->syncOpportunity('374720564');\n $crmService->matchByName('Robot');\n }\n }\n\n ----------- end diff -----------\n\n\nFixed 4 of 5666 files in 146.870 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix \ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5666 files in 106.192 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ csfix\ndocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff \nPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.\nPHP runtime: 8.3.30\nRunning analysis on 7 cores with 10 files per process.\nParallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!\nLoaded config default from \".php-cs-fixer.dist.php\".\n 5666/5666 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%\n\n\nFixed 0 of 5666 files in 66.457 seconds, 60.00 MB memory used\n\nWhat's next:\n Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1\n Learn more at https://docs.docker.com/go/debug-cli/\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $","is_focused":true},{"role":"AXRadioButton","text":"DOCKER","depth":2,"bounds":{"left":0.0,"top":0.05888889,"width":0.140625,"height":0.026666667},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.004166667,"top":0.06333333,"width":0.011111111,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"DEV (docker)","depth":2,"bounds":{"left":0.140625,"top":0.05888889,"width":0.140625,"height":0.026666667},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.14479166,"top":0.06333333,"width":0.011111111,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"APP (-zsh)","depth":2,"bounds":{"left":0.28125,"top":0.05888889,"width":0.140625,"height":0.026666667},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.28541666,"top":0.06333333,"width":0.011111111,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"-zsh","depth":2,"bounds":{"left":0.421875,"top":0.05888889,"width":0.140625,"height":0.026666667},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.42604166,"top":0.06333333,"width":0.011111111,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"-zsh","depth":2,"bounds":{"left":0.5625,"top":0.05888889,"width":0.14027777,"height":0.026666667},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.56666666,"top":0.06333333,"width":0.011111111,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"screenpipe\"","depth":2,"bounds":{"left":0.7027778,"top":0.05888889,"width":0.14027777,"height":0.026666667},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.70694447,"top":0.06333333,"width":0.011111111,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"-zsh","depth":2,"bounds":{"left":0.84305555,"top":0.05888889,"width":0.14027777,"height":0.026666667},"on_screen":true,"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.8472222,"top":0.06333333,"width":0.011111111,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"⌥⌘1","depth":1,"bounds":{"left":0.9548611,"top":0.032222223,"width":0.03888889,"height":0.018888889},"on_screen":true,"automation_id":"_NS:8","role_description":"text"},{"role":"AXStaticText","text":"APP (-zsh)","depth":1,"bounds":{"left":0.47430557,"top":0.033333335,"width":0.05138889,"height":0.017777778},"on_screen":true,"role_description":"text"}]...
|
-5826347064466025141
|
-2292063823433160584
|
idle
|
accessibility
|
NULL
|
Last login: Thu May 7 09:44:56 on ttys007
Poetry Last login: Thu May 7 09:44:56 on ttys007
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parents
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20773-fix-automated-reports-user-pilot-tracking) $ git status
On branch master
Your branch is up to date with 'origin/master'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: .env.local
modified: app/Console/Commands/JiminnyDebugCommand.php
modified: app/Jobs/Team/SyncToIntercom.php
modified: app/Services/PlaybackService.php
modified: config/logging.php
modified: resources/views/partials/crm/push-summary/html-assembly.blade.php
Untracked files:
(use "git add <file>..." to include in what will be committed)
.env.nikilocal
.env.other
WEBHOOK_FILTERING_IMPLEMENTATION.md
app/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.php
app/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.php
ids.txt
public/favicon.ico
raw_sql_query.sql
tests/Unit/Policies/CanAccessAiReportsTest.php
no changes added to commit (use "git add" and/or "git commit -a")
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 1482, done.
remote: Counting objects: 100% (481/481), done.
remote: Compressing objects: 100% (191/191), done.
remote: Total 1482 (delta 349), reused 305 (delta 289), pack-reused 1001 (from 4)
Receiving objects: 100% (1482/1482), 1017.97 KiB | 1.44 MiB/s, done.
Resolving deltas: 100% (877/877), completed with 96 local objects.
From github.com:jiminny/app
83b628967a..ad2ce76737 master -> origin/master
1ee8cbcb7b..14f54b5be2 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
5662c3b32f..b167b19973 JY-20289-api-tests -> origin/JY-20289-api-tests
b40408cfad..f23cfee7c3 JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
* [new branch] JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20662-remove-word-boost -> origin/JY-20662-remove-word-boost
* [new branch] JY-20742-mcp-poc -> origin/JY-20742-mcp-poc
* [new branch] make-claude-great-again -> origin/make-claude-great-again
* [new branch] secfix/composer-20260507 -> origin/secfix/composer-20260507
* [new branch] secfix/npm-20260507 -> origin/secfix/npm-20260507
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
resources/views/partials/crm/push-summary/html-assembly.blade.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
error: Your local changes to the following files would be overwritten by merge:
app/Jobs/Team/SyncToIntercom.php
Please commit your changes or stash them before you merge.
Aborting
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
Updating 83b628967a..ad2ce76737
Fast-forward
.cursor/rules/frontend-conventions.mdc | 23 ++
.env.production-eu | 2 +-
.env.staging | 2 +-
Makefile | 10 +
app/Component/ActivityAnalytics/Service/ActivityAnalyticsService.php | 6 +-
app/Component/AiAutomation/Repositories/AiTemplateFieldsRepository.php | 32 +-
app/Component/AiCallScoring/Repositories/AiScorecardRepository.php | 56 ++--
app/Component/AskAnything/AskAnythingPromptService.php | 3 +
app/Component/Transcription/Job/FinishTranscriptionJob.php | 37 ++-
app/Component/Transcription/TranscriptionProcessor/Gong/Gong.php | 18 +-
app/Component/Twilio/Conference/ConferenceManager/SoftPhoneManager.php | 4 +-
app/Component/Twilio/Service/SoftPhoneService.php | 124 ++++---
app/Component/Twilio/TwilioRepository.php | 27 ++
app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php | 59 ----
app/Console/Commands/Reports/AutomatedReportsCommand.php | 122 +++++--
app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php | 200 ++++++++++++
app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php | 60 ----
app/Console/Commands/Users/SyncToIntercom.php | 4 +-
app/Console/Kernel.php | 3 +-
app/Contracts/ES/Events/UpdateMultipleEntities.php | 4 -
app/Contracts/ES/Events/UpdateSingleEntity.php | 4 -
app/Contracts/Repositories/TeamRepository.php | 3 +-
app/Events/Activities/ActivityUpdated.php | 10 +-
app/Events/Activities/Audio/RecordingEvent.php | 6 +-
app/Events/Activities/Softphone/Ended.php | 8 +-
app/Events/Activities/Softphone/SoftphoneEvent.php | 24 +-
app/Events/Activities/Softphone/Started.php | 8 +-
app/Http/Controllers/API/ActivityController.php | 17 +-
app/Http/Controllers/API/SoftphoneController.php | 9 +-
app/Http/Controllers/API/UserAutomatedReports/UserAutomatedReportsController.php | 19 +-
app/Http/Controllers/API/V2/AskAnythingController.php | 2 +-
app/Http/Controllers/Auth/SocialController.php | 6 +-
app/Http/Controllers/Kiosk/AutomatedReportsController.php | 38 ++-
app/Http/Controllers/Kiosk/OrganizationsController.php | 8 +-
app/Http/Controllers/Kiosk/PartnersController.php | 46 +++
app/Http/Controllers/Kiosk/SearchController.php | 8 +
app/Http/Controllers/Kiosk/Teams/OnboardController.php | 24 +-
app/Http/Controllers/Settings/Teams/IntegrationController.php | 6 +-
app/Http/Controllers/TeamSetupController.php | 4 +-
app/Http/Controllers/Telephony/TextMessaging/MessageController.php | 12 +-
app/Http/Controllers/Telephony/TextMessaging/WebhookController.php | 18 +-
app/Http/Requests/Settings/Teams/CreateTeamRequest.php | 1 +
app/Http/Requests/Settings/Teams/EditTeamRequest.php | 1 +
app/Http/Transformers/ActivityTransformer.php | 4 +-
app/Http/Transformers/OnDemandActivitiesTransformer.php | 2 +-
app/Http/Transformers/PartnerTransformer.php | 1 +
app/Http/Transformers/StageTransformer.php | 6 +-
app/Http/Transformers/UserTransformer.php | 11 +-
app/Interactions/Settings/Teams/CreateTeam.php | 3 +
app/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJob.php | 80 ++++-
app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php | 119 +++++++
app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php | 89 +++++
app/Jobs/Crm/Hubspot/ImportBatchJobTrait.php | 12 +-
app/Jobs/Crm/UpdateStage.php | 3 +
app/Jobs/Team/SyncToIntercom.php | 7 +-
app/Listeners/Teams/SyncIntercomCompany.php | 5 +-
app/Listeners/Teams/UpdateSalesforceAccount.php | 8 +-
app/Listeners/Users/SyncIntercom.php | 5 +-
app/Mail/Reports/AskJiminnyReportExpiringMail.php | 40 +++
app/Mail/Reports/ReportNotGenerated.php | 41 +++
app/Models/Activity.php | 25 +-
app/Models/Activity/Question.php | 14 +-
app/Models/Activity/Search.php | 7 +
app/Models/AskAnything/AskAnythingPrompt.php | 6 +
app/Models/AutomatedReport.php | 10 +
app/Models/CoachingFeedback.php | 44 ++-
app/Models/ElasticSearch/ActivityElasticSearchTrait.php | 86 +----
app/Models/ElasticSearch/OpportunityElasticSearchTrait.php | 71 ----
app/Models/ElasticSearch/SharedDocumentDeleteTrait.php | 27 --
app/Models/Partner.php | 13 +
app/Models/Playlist/Activity.php | 14 +-
app/Notifications/OwnerInvitedToTrial.php | 14 +-
app/Policies/UserPolicy.php | 16 +-
app/Queue/Worker/Worker.php | 3 +-
app/Repositories/ActivityRepository.php | 13 +-
app/Repositories/AutomatedReportsRepository.php | 42 ++-
app/Repositories/TeamRepository.php | 21 +-
app/Repositories/UserRepository.php | 2 +-
app/Services/Activity/MeetingBotService.php | 8 +-
app/Services/ActivityService.php | 111 ++-----
app/Services/Crm/Hubspot/Service.php | 36 +-
app/Services/Crm/Hubspot/ServiceTraits/OpportunitySyncTrait.php | 2 +-
app/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityService.php | 5 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsService.php | 49 +--
app/Services/Kiosk/KioskService.php | 7 +-
app/Services/Webhook/Triggers/AiScorecardCompletedTrigger.php | 13 +-
app/UseCases/TeamInsights/ConversationRowMapper.php | 78 +++++
app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php | 68 ++++
app/UseCases/TeamInsights/StrictConsentColumnResolver.php | 45 +++
app/UseCases/TeamInsights/TeamConversationsExport.php | 154 ++++-----
composer.json | 1 -
composer.lock | 95 +-----
config/secure-headers.php | 5 +-
database/mappings/mapping_activities.json | 16 +
database/migrations/2026_04_14_000000_add_rockeed_partner.php | 51 +++
database/migrations/2026_04_22_000000_add_success_email_to_partners.php | 26 ++
database/migrations/2026_04_27_000000_add_label_to_partners.php | 28 ++
database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php | 79 +++++
front-end/package.json | 5 +-
front-end/src/__mocks__/jiminny.js | 4 +-
front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js | 9 +
front-end/src/__mocks__/setup.js | 1 +
front-end/src/apps/ai-reports-promo.js | 22 ++
front-end/src/components/AiReports/AiReportsPromo.vue | 22 ++
front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue | 190 +++++++++++
front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue | 111 +++++++
front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue | 103 ++++++
front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js | 98 ++++++
.../src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html | 283 ++++++++++++++++
front-end/src/components/AiReports/Manage/ManageAiReports.vue | 8 +-
front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue | 228 +++++++++++++
front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js | 71 ++++
.../src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html | 217 ++++++++++++
front-end/src/components/AiReports/constants.js | 7 +
front-end/src/components/Settings/Kiosk/OrganizationSearch/Organizations.vue | 1 +
front-end/src/components/Settings/Kiosk/__mocks__/Jiminny.js | 1 +
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/EditTeamModal.vue | 43 ++-
front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js | 203 ++++++++++++
front-end/src/components/Settings/Kiosk/shared/Navigation/Navigation.vue | 3 +
front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js | 67 ++++
front-end/src/components/TeamInsights/CoachingFrameworks/AICallScoring/aiCallScoringOverTime.ts | 4 +-
front-end/src/components/TeamInsights/CoachingFrameworks/UsersList.vue | 2 +-
front-end/src/components/layout/Sidebar/HelpMenu.vue | 25 +-
front-end/src/components/layout/Sidebar/Sidebar.vue | 27 +-
front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js | 94 ++++++
front-end/src/components/layout/Sidebar/__tests__/__snapshots__/Sidebar.spec.js.snap | 4 +-
front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js | 204 ++++++++++++
front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js | 49 +++
front-end/src/main.js | 1 +
front-end/src/store/modules/TeamInsights/util.js | 1 +
front-end/src/store/modules/platform/__tests__/getters.spec.js | 22 ++
front-end/src/store/modules/platform/getters.js | 3 +
front-end/src/utils/index.js | 11 +
front-end/yarn.lock | 21 +-
phpstan-baseline.neon | 60 ----
public/pdf/exec-reports/com/coaching-profiles.pdf | Bin 0 -> 1531178 bytes
public/pdf/exec-reports/com/exec-summary.pdf | Bin 0 -> 2237381 bytes
public/pdf/exec-reports/com/loss-report.pdf | Bin 0 -> 1955343 bytes
public/pdf/exec-reports/com/product-feedback.pdf | Bin 0 -> 2184417 bytes
public/pdf/exec-reports/eu/coaching-profiles.pdf | Bin 0 -> 1528704 bytes
public/pdf/exec-reports/eu/exec-summary.pdf | Bin 0 -> 2296741 bytes
public/pdf/exec-reports/eu/loss-report.pdf | Bin 0 -> 1955808 bytes
public/pdf/exec-reports/eu/product-feedback.pdf | Bin 0 -> 2184083 bytes
resources/views/emails/reports/ask-jiminny-report-expiring.blade.php | 22 ++
resources/views/emails/reports/report-not-generated.blade.php | 24 ++
resources/views/partials/crm/push-summary/html-assembly.blade.php | 2 +-
routes/api.php | 6 +
routes/web.php | 4 +
tests/Feature/Policies/UserPolicyTest.php | 90 ++++-
tests/Unit/Component/ActivityAnalytics/Service/ActivityAnalyticsServiceTest.php | 40 +++
tests/Unit/Component/AskAnything/AskAnythingPromptServiceTest.php | 26 ++
tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php | 276 ++++++++++++++++
tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php | 375 +++++++++++++++++++++
tests/Unit/Component/Twilio/Service/SoftPhoneServiceTest.php | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--
tests/Unit/Console/Commands/Reports/AutomatedReportsCommandTest.php | 157 ++++++++-
tests/Unit/Events/Activities/Audio/RecordingEventTest.php | 72 ++++
tests/Unit/Events/Activities/Softphone/EndedTest.php | 86 +++++
tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php | 88 +++++
tests/Unit/Events/Activities/Softphone/StartedTest.php | 86 +++++
tests/Unit/Http/Controllers/Kiosk/AutomatedReportsControllerTest.php | 99 ++++++
tests/Unit/Http/Transformers/ActivityTransformerTest.php | 5 +-
tests/Unit/Http/Transformers/PartnerTransformerTest.php | 34 ++
tests/Unit/Interactions/Settings/Teams/CreateTeamTest.php | 49 +++
tests/Unit/Jobs/AutomatedReports/RequestGenerateAskJiminnyReportJobTest.php | 106 +++++-
tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php | 205 ++++++++++++
tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php | 188 +++++++++++
tests/Unit/Jobs/Crm/ImportOpportunityBatchTest.php | 2 +-
tests/Unit/Jobs/Team/SyncToIntercomTest.php | 6 +
tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php | 59 ++++
tests/Unit/Listeners/Teams/UpdateSalesforceAccountTest.php | 11 +-
tests/Unit/Listeners/Users/SyncIntercomTest.php | 59 ++++
tests/Unit/Mail/Reports/ReportNotGeneratedTest.php | 166 ++++++++++
tests/Unit/Models/PartnerTest.php | 28 ++
tests/Unit/Repositories/AutomatedReportsRepositoryTest.php | 68 ++++
tests/Unit/Services/Activity/MeetingBotServiceRequestRecordingToStopTest.php | 14 +-
tests/Unit/Services/ActivityServiceTest.php | 391 ++++++++++++++++++++++
tests/Unit/Services/Crm/Hubspot/ServiceResponseNormalizeTest.php | 68 ++--
tests/Unit/Services/Kiosk/AutomatedReports/AskJiminnyReportActivityServiceTest.php | 48 +--
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceActivitiesCountTest.php | 16 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceReportGenerationTest.php | 24 +-
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.php | 130 ++++++++
tests/Unit/Services/KioskServiceTest.php | 8 +
tests/Unit/Services/Webhook/Triggers/AiScorecardCompletedTriggerTest.php | 6 +-
tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php | 119 +++++++
tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php | 108 ++++++
tests/Unit/UseCases/TeamInsights/TeamConversationsExportTest.php | 342 ++++++++++++++-----
186 files changed, 8538 insertions(+), 1233 deletions(-)
create mode 100644 app/Component/Twilio/TwilioRepository.php
delete mode 100644 app/Console/Commands/CoachingFeedbacksUpdateEsActivities.php
create mode 100644 app/Console/Commands/RunAiCallScoringForUntypedActivitiesCommand.php
delete mode 100644 app/Console/Commands/UpdateActivitiesAverageScoreExcludingFeedbacksNotSetVisibleToAll.php
create mode 100644 app/Http/Controllers/Kiosk/PartnersController.php
create mode 100644 app/Jobs/AutomatedReports/SendReportExpiringSoonMailJob.php
create mode 100644 app/Jobs/AutomatedReports/SendReportNotGeneratedMailJob.php
create mode 100644 app/Mail/Reports/AskJiminnyReportExpiringMail.php
create mode 100644 app/Mail/Reports/ReportNotGenerated.php
delete mode 100644 app/Models/ElasticSearch/SharedDocumentDeleteTrait.php
create mode 100644 app/UseCases/TeamInsights/ConversationRowMapper.php
create mode 100644 app/UseCases/TeamInsights/RecordingOutcomeTextResolver.php
create mode 100644 app/UseCases/TeamInsights/StrictConsentColumnResolver.php
create mode 100644 database/migrations/2026_04_14_000000_add_rockeed_partner.php
create mode 100644 database/migrations/2026_04_22_000000_add_success_email_to_partners.php
create mode 100644 database/migrations/2026_04_27_000000_add_label_to_partners.php
create mode 100644 database/migrations/2026_04_29_105053_move_ask_jiminny_reports_to_grow_tier.php
create mode 100644 front-end/src/__mocks__/kit/endpoints/automated-reports-promo.js
create mode 100644 front-end/src/apps/ai-reports-promo.js
create mode 100644 front-end/src/components/AiReports/AiReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/AutomatedReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/PromoCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/WhyItMattersCard.vue
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/AutomatedReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/AutomatedReportsPromo/__tests__/__snapshots__/automated-reports-promo.output.html
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/PanoramaReportsPromo.vue
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/PanoramaReportsPromo.spec.js
create mode 100644 front-end/src/components/AiReports/PanoramaReportsPromo/__tests__/__snapshots__/panorama-reports-promo.output.html
create mode 100644 front-end/src/components/Settings/Kiosk/modals/EditTeamModal/__tests__/EditTeamModal.spec.js
create mode 100644 front-end/src/components/Settings/Kiosk/shared/Navigation/__tests__/Navigation.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/HelpMenu.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/__tests__/useAiReportsSidebarButton.spec.js
create mode 100644 front-end/src/components/layout/Sidebar/useAiReportsSidebarButton.js
create mode 100644 front-end/src/store/modules/platform/__tests__/getters.spec.js
create mode 100644 public/pdf/exec-reports/com/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/com/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/com/loss-report.pdf
create mode 100644 public/pdf/exec-reports/com/product-feedback.pdf
create mode 100644 public/pdf/exec-reports/eu/coaching-profiles.pdf
create mode 100644 public/pdf/exec-reports/eu/exec-summary.pdf
create mode 100644 public/pdf/exec-reports/eu/loss-report.pdf
create mode 100644 public/pdf/exec-reports/eu/product-feedback.pdf
create mode 100644 resources/views/emails/reports/ask-jiminny-report-expiring.blade.php
create mode 100644 resources/views/emails/reports/report-not-generated.blade.php
create mode 100644 tests/Unit/Component/Transcription/Job/FinishTranscriptionJobTest.php
create mode 100644 tests/Unit/Component/Transcription/TranscriptionProcessor/Gong/GongTest.php
create mode 100644 tests/Unit/Events/Activities/Audio/RecordingEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/EndedTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/SoftphoneEventTest.php
create mode 100644 tests/Unit/Events/Activities/Softphone/StartedTest.php
create mode 100644 tests/Unit/Http/Transformers/PartnerTransformerTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportExpiringSoonMailJobTest.php
create mode 100644 tests/Unit/Jobs/AutomatedReports/SendReportNotGeneratedMailJobTest.php
create mode 100644 tests/Unit/Listeners/Teams/SyncIntercomCompanyTest.php
create mode 100644 tests/Unit/Listeners/Users/SyncIntercomTest.php
create mode 100644 tests/Unit/Mail/Reports/ReportNotGeneratedTest.php
create mode 100644 tests/Unit/Models/PartnerTest.php
create mode 100644 tests/Unit/Services/ActivityServiceTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/RecordingOutcomeTextResolverTest.php
create mode 100644 tests/Unit/UseCases/TeamInsights/StrictConsentColumnResolverTest.php
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 628, done.
remote: Counting objects: 100% (331/331), done.
remote: Compressing objects: 100% (63/63), done.
remote: Total 628 (delta 280), reused 274 (delta 266), pack-reused 297 (from 1)
Receiving objects: 100% (628/628), 186.75 KiB | 1.23 MiB/s, done.
Resolving deltas: 100% (391/391), completed with 57 local objects.
From github.com:jiminny/app
ad2ce76737..12295204cf master -> origin/master
14f54b5be2..5e7646e5f9 JY-17836-participant-speeches-in-s3 -> origin/JY-17836-participant-speeches-in-s3
b167b19973..acba55cf38 JY-20289-api-tests -> origin/JY-20289-api-tests
f23cfee7c3..e5a3ec5dba JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null -> origin/JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
af59d60926..766efba1c5 JY-20395-fix-memory-issue-with-mail-import -> origin/JY-20395-fix-memory-issue-with-mail-import
* [new branch] JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
1737b7c528..c57e71e763 JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20671-anyvan-twilio-s3-recordings-ftech -> origin/JY-20671-anyvan-twilio-s3-recordings-ftech
+ ba181441c6...20a74137b0 JY-20742-mcp-poc -> origin/JY-20742-mcp-poc (forced update)
* [new branch] JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20816-calendar-events-diplication -> origin/JY-20816-calendar-events-diplication
* [new branch] desktop-app -> origin/desktop-app
4c4c974e46..185442c26e make-claude-great-again -> origin/make-claude-great-again
* [new branch] mcp-tools-schemas -> origin/mcp-tools-schemas
Updating ad2ce76737..12295204cf
Fast-forward
app/Component/Encoding/Job/AnalyzeTrackChannelsJob.php | 2 +-
app/Component/Transcription/TranscriptionProcessor/AssemblyAI/Services/SubmitAudioFileService.php | 66 ++----------------------------------------------------------------
app/Console/Commands/Tracks/CleanupActivityTracksCommand.php | 24 +++++++++++++++++-------
tests/Unit/Console/Commands/Tracks/CleanupActivityTracksCommandTest.php | 40 +++++++++++++++++++++++++++++-----------
tests/Unit/fixtures/assembly_ai_transcript_response.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_channel_diarization.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_error.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_not_ready.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_language_post.json | 1 -
tests/Unit/fixtures/assembly_ai_transcript_response_without_utterances.json | 1 -
11 files changed, 49 insertions(+), 90 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20818-move-AJ-reports-to-separated-datadog-metric
Switched to a new branch 'JY-20818-move-AJ-reports-to-separated-datadog-metric'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 83.615 seconds, 67.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20818-move-AJ-reports-to-separated-datadog-metric) $ csfix
docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=no --diff
PHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.30
Running 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!
Loaded config default from ".php-cs-fixer.dist.php".
5663/5663 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Fixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory used
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at [URL_WITH_CREDENTIALS] ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 15, done.
remote: Counting objects: 100% (15/15), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)
Unpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.
From github.com:jiminny/app
c57e71e763..8743fea32e JY-20606-desktop-app-recall -> origin/JY-20606-desktop-app-recall
* [new branch] JY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit
Already up to date.
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ git pull
remote: Enumerating objects: 180, done.
remote: Counting objects: 100% (179/179), done.
remote: Compressing objects: 100% (55/55), done.
remote: Total 180 (delta 127), reused 165 (delta 119), pack-reused 1 (from 1)
Receiving objects: 100% (180/180), 57.50 KiB | 2.74 MiB/s, done.
Resolving deltas: 100% (127/127), completed with 47 local objects.
From github.com:jiminny/app
12295204cf..35f036ace6 master -> origin/master
* [new branch] JY-18091-upgrade-to-php-8-5 -> origin/JY-18091-upgrade-to-php-8-5
5f21965da8..497fbc85c2 JY-20493-smart-instant-nudge-pre-filtering -> origin/JY-20493-smart-instant-nudge-pre-filtering
f029263118..6ed9e2bb13 JY-20808-low-priority-indexing-queue -> origin/JY-20808-low-priority-indexing-queue
* [new branch] JY-20817-fix-deleting-s3-directories -> origin/JY-20817-fix-deleting-s3-directories
* [new branch] JY-20820-es-reindex-stream-model-hydration -> origin/JY-20820-es-reindex-stream-model-hydration
+ ca92730ec5...2c8a1d0856 desktop-app -> origin/desktop-app (forced update)
8425d4e0de..89b45ec8b0 mcp-tools-schemas -> origin/mcp-tools-schemas
Updating 12295204cf..35f036ace6
Fast-forward
app/Component/Datadog/Constants.php | 1 +
app/Providers/RouteServiceProvider.php | 2 +-
app/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackService.php | 13 +++++++++++++
tests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsCallbackServiceTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++-------
4 files changed, 62 insertions(+), 8 deletions(-)
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ gbr
* master
JY-20818-move-AJ-reports-to-separated-datadog-metric
JY-20773-fix-automated-reports-user-pilot-tracking
JY-20157-AJ-report-not-send-notification
JY-20508-notify-before-AJ-report-expiration
JY-20372-ai-reports-promotion-pages
JY-20352-sync-opportunities-without-a-local-owner-user-id-is-null
JY-20738-debug-AJ-tracking-UP
a
JY-18909-automated-reports-ask-jiminny
JY-20692-fix-integration-app-[API_KEY]
JY-20553-debug-crm-sync-delays
JY-20698-fix-SF-activity-types-on-new-playbook
JY-20543-AJ-report-tracking
JY-20384-handle-auto-sync-with-no-access-to-event-type
JY-20458-ask-jiminny-user-definitions
JY-19666-fix-import-contacts-account-association
JY-19666-HS-import-contacts-and-accounts-batch-job
JY-20458-Ask-Jiminny-Reports
JY-20200-batch-update-CRM-objects-Salesforce
JY-19666-HS-webhooks-add-contact-and-company
JY-20348-trigger-setup-DI-layout-on-team-creation
JY-20326-refactor-info-message-in-command
JY-20317-fix-auto-log-delay-issue-on-all-channels-disabled
JY-20312-remove-on-update-change-last-synced-at-crm-configurations
JY-20306-SF-skip-auto-sync-for-task-based-playbook
JY-20192-remove-deleted-team-from-saved-search-filters
JY-20197-import-opportunity-batch-job
JY-20293-enable-status-field-for-pipedrive-deals
JY-20191-remove-commands-interactive-prompts
JY-20118-change-default-sync-strategy
JY-20183-add-cache-on-auto-log-delay
JY-20197-add-import-opportunity-batch-job
20118-hs-opportunity-make-webhook-strategy-default
JY-20118-make-default-hs-opportunity-sync-strategy-webhook-based
JY-20196-handle-opportunity-without-note
JY-20118-improve-opportunity-import
JY-20189-handle-activity-search-on-deleted-groups
JY-20160
JY-20145-filter-out-converted-leads-when-matching
JY-20150-skip-push-summary-on-summary-ready-if-autolog
JY-20132-fix-note-encoding
JY-19792-clean-logs
JY-20117-fix-sync-profile-fields-on-empty-fields
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limit
Switched to a new branch 'JY-20725-handle-HS-search-rate-limit'
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ ;xd
docker exec -it docker_lamp_1 bash -c "mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.ini"
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 supervisorctl restart all
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: stopped
jiminny-worker-processing-2:jiminny-worker-processing-2_00: stopped
jiminny-worker-processing-3:jiminny-worker-processing-3_00: stopped
jiminny-worker-processing-4:jiminny-worker-processing-4_00: stopped
jiminny-worker-processing-5:jiminny-worker-processing-5_00: stopped
worker-analytics:worker-analytics_00: stopped
worker-crm-update:worker-crm-update_00: stopped
worker-download:worker-download_00: stopped
worker-nudges:worker-nudges_00: stopped
artisan-schedule:artisan-schedule_00: stopped
worker-emails:worker-emails_00: stopped
worker:worker_00: stopped
worker-calendar:worker-calendar_00: stopped
jiminny-worker-processing-1:jiminny-worker-processing-1_00: stopped
worker-crm-sync:worker-crm-sync_00: stopped
worker-audio:worker-audio_00: stopped
worker-conferences:worker-conferences_00: stopped
worker-es-update:worker-es-update_00: stopped
artisan-schedule:artisan-schedule_00: started
jiminny-worker-processing-1:jiminny-worker-processing-1_00: started
jiminny-worker-processing-2:jiminny-worker-processing-2_00: started
jiminny-worker-processing-3:jiminny-worker-processing-3_00: started
jiminny-worker-processing-4:jiminny-worker-processing-4_00: started
jiminny-worker-processing-5:jiminny-worker-processing-5_00: started
jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: started
worker:worker_00: started
worker-analytics:worker-analytics_00: started
worker-audio:worker-audio_00: started
worker-calendar:worker-calendar_00: started
worker-conferences:worker-conferences_00: started
worker-crm-sync:worker-crm-sync_00: started
worker-crm-update:worker-crm-update_00: started
worker-download:worker-download_00: started
worker-emails:worker-emails_00: started
worker-es-update:worker-es-update_00: started
worker-nudges:worker-nudges_00: started
What's next:
Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1
Learn more at https://docs.docker.com/go/debug-cli/
docker exec -it docker_lamp_1 php -v
PHP 8.3.30 (cli) (built: Mar 16 2026 22:32:32) (NTS)
Copyright (c) The PHP Group
Zend En...
|
20462
|
NULL
|
NULL
|
NULL
|
|
20515
|
NULL
|
0
|
2026-05-11T15:44:23.579155+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778514263579_m2.jpg...
|
PhpStorm
|
faVsco.js – ClientTest.php
|
True
|
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
|
idle
|
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...
|
20509
|
NULL
|
NULL
|
NULL
|
|
20516
|
NULL
|
0
|
2026-05-11T15:44:33.068113+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778514273068_m1.jpg...
|
PhpStorm
|
faVsco.js – ClientTest.php
|
True
|
NULL
|
monitor_1
|
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,"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,"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,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ClientTest","depth":6,"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,"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,"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,"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,"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,"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,"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.088194445,"height":0.027777778},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"144","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"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,"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.088194445,"height":0.027777778},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"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,"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,"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
4682894321548166980
|
4446428687123393012
|
idle
|
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...
|
20508
|
NULL
|
NULL
|
NULL
|
|
20517
|
891
|
0
|
2026-05-11T15:44:53.961383+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778514293961_m2.jpg...
|
PhpStorm
|
faVsco.js – ClientTest.php
|
True
|
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
|
idle
|
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...
|
20509
|
NULL
|
NULL
|
NULL
|
|
20518
|
890
|
0
|
2026-05-11T15:45:03.409811+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778514303409_m1.jpg...
|
PhpStorm
|
faVsco.js – ClientTest.php
|
True
|
NULL
|
monitor_1
|
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,"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,"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,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ClientTest","depth":6,"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,"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,"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,"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,"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,"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,"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.088194445,"height":0.027777778},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"144","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"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,"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.088194445,"height":0.027777778},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"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,"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,"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
4682894321548166980
|
4446428687123393012
|
idle
|
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...
|
20508
|
NULL
|
NULL
|
NULL
|
|
20549
|
NULL
|
0
|
2026-05-11T15:49:26.548095+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778514566548_m1.jpg...
|
PhpStorm
|
faVsco.js – ClientTest.php
|
True
|
NULL
|
monitor_1
|
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,"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,"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,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ClientTest","depth":6,"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,"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,"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,"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,"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,"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,"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.088194445,"height":0.027777778},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"144","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"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,"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.088194445,"height":0.027777778},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"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,"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,"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
4682894321548166980
|
4446428687123393012
|
click
|
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
|
|
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
|
True
|
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
|
|
20551
|
892
|
0
|
2026-05-11T15:49:56.891002+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778514596891_m1.jpg...
|
PhpStorm
|
faVsco.js – ClientTest.php
|
True
|
NULL
|
monitor_1
|
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,"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,"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,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ClientTest","depth":6,"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,"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,"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,"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,"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,"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,"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,"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.088194445,"height":0.027777778},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"144","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"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,"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":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.088194445,"height":0.027777778},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"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,"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,"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-8016712486324069616
|
4446428687123393012
|
idle
|
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...
|
20549
|
NULL
|
NULL
|
NULL
|
|
20552
|
893
|
0
|
2026-05-11T15:50:01.357221+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778514601357_m2.jpg...
|
PhpStorm
|
faVsco.js – ClientTest.php
|
True
|
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
|
idle
|
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...
|
20550
|
NULL
|
NULL
|
NULL
|
|
20579
|
NULL
|
0
|
2026-05-11T15:54:48.175730+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778514888175_m1.jpg...
|
PhpStorm
|
faVsco.js – HandleHubspotRateLimitTest.php
|
True
|
NULL
|
monitor_1
|
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,"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,"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,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"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,"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,"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,"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,"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,"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,"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.088194445,"height":0.027777778},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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,"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.088194445,"height":0.027777778},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"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,"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,"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-434899306801221671
|
-7141563512795342200
|
idle
|
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...
|
20577
|
NULL
|
NULL
|
NULL
|
|
20580
|
NULL
|
0
|
2026-05-11T15:54:51.510454+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778514891510_m2.jpg...
|
PhpStorm
|
faVsco.js – HandleHubspotRateLimitTest.php
|
True
|
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
|
idle
|
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...
|
20578
|
NULL
|
NULL
|
NULL
|
|
20581
|
894
|
0
|
2026-05-11T15:55:18.533189+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778514918533_m1.jpg...
|
PhpStorm
|
faVsco.js – HandleHubspotRateLimitTest.php
|
True
|
NULL
|
monitor_1
|
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,"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,"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,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"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,"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,"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,"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,"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,"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,"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.088194445,"height":0.027777778},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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,"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.088194445,"height":0.027777778},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"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,"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,"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-434899306801221671
|
-7141563512795342200
|
idle
|
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
|
|
20582
|
895
|
0
|
2026-05-11T15:55:21.875591+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778514921875_m2.jpg...
|
PhpStorm
|
faVsco.js – HandleHubspotRateLimitTest.php
|
True
|
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
|
idle
|
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
|
|
20613
|
NULL
|
0
|
2026-05-11T15:59:52.164811+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515192164_m1.jpg...
|
PhpStorm
|
Commit Changes
|
True
|
NULL
|
monitor_1
|
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
5 modified
Analyzing…
JY-20725 code review suggestions
Commit Message
Commit Message History
Git
Author:
Author:
Amend commit
Sign-off commit
Create extra commit with file movements
Commit Checks
Update copyright
Reformat code
Rearrange code
Optimize imports
Cleanup
Choose profile
Check malicious dependencies
Run external formatter
Configure
Perform SonarQube for IDE analysis
Find unused view files
Run Git hooks
Advanced Commit Checks
Analyze code
Choose profile
Check TODO
Configure
After Commit
Upload files to:
Upload files to:
<None>
Always use selected server or group of servers
Previous Difference
Next Difference
Jump to Source
Compare Previous File
Compare Next File
Go to Changed File…
Side-by-side viewer
Do not ignore
Highlight words
Collapse Unchanged Fragments
Synchronize Scrolling
Disable Editing
Settings
Help
4 differences
02d5214b
<?php
declare(strict_types=1);
namespace Jiminny\Exceptions;
use Throwable;
class RateLimitException extends RuntimeException
{
public function __construct(
string $message = '',
private readonly int $retryAfter = 1,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
public function getRetryAfter(): int
{
return max($this->retryAfter, 1);
}
}
Current version
<?php
declare(strict_types=1);
namespace Jiminny\Exceptions;
use Throwable;
class RateLimitException extends RuntimeException
{
private readonly int $retryAfter;
public function __construct(
string $message = '',
int $retryAfter = 1,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
$this->retryAfter = max(1, $retryAfter);
}
public function getRetryAfter(): int
{
return $this->retryAfter;
}
}
Help
Cancel
Commit
Commit
Commit Changes...
|
[{"role":"AXStaticText","text& [{"role":"AXStaticText","text":"Diff","depth":1,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Changelist:","depth":1,"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"Changelist:","depth":1,"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,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Rollback...","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Refresh","depth":2,"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,"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,"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,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5 modified","depth":1,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Analyzing…","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.041666668,"height":0.02},"on_screen":false,"role_description":"text"},{"role":"AXTextArea","text":"JY-20725 code review suggestions","depth":2,"on_screen":true,"value":"JY-20725 code review suggestions","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Commit Message","depth":1,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Commit Message History","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Git","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Author:","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"Author:","depth":3,"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,"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,"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,"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"Update copyright","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Reformat code","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Rearrange code","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Optimize imports","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Cleanup","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Choose profile","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Check malicious dependencies","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Run external formatter","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Configure","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Perform SonarQube for IDE analysis","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Find unused view files","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Run Git hooks","depth":2,"on_screen":false,"help_text":"If unchecked, Git hooks will be skipped with the '--no-verify' option for the upcoming commit","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Advanced Commit Checks","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"Analyze code","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Choose profile","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Check TODO","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Configure","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"After Commit","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Upload files to:","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"Upload files to:","depth":2,"on_screen":true,"role_description":"pop up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"<None>","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXCheckBox","text":"Always use selected server or group of servers","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Previous Difference","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Difference","depth":2,"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,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Compare Previous File","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Compare Next File","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Go to Changed File…","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Side-by-side viewer","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Do not ignore","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Highlight words","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse Unchanged Fragments","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Synchronize Scrolling","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Disable Editing","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Settings","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Help","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"4 differences","depth":1,"on_screen":true,"role_description":"text"},{"role":"AXTextField","text":"02d5214b","depth":1,"on_screen":true,"value":"02d5214b","help_text":"text/plain","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Exceptions;\n\nuse Throwable;\n\nclass RateLimitException extends RuntimeException\n{\n public function __construct(\n string $message = '',\n private readonly int $retryAfter = 1,\n ?Throwable $previous = null,\n ) {\n parent::__construct($message, 0, $previous);\n }\n\n public function getRetryAfter(): int\n {\n return max($this->retryAfter, 1);\n }\n}","depth":2,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Exceptions;\n\nuse Throwable;\n\nclass RateLimitException extends RuntimeException\n{\n public function __construct(\n string $message = '',\n private readonly int $retryAfter = 1,\n ?Throwable $previous = null,\n ) {\n parent::__construct($message, 0, $previous);\n }\n\n public function getRetryAfter(): int\n {\n return max($this->retryAfter, 1);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Current version","depth":1,"on_screen":true,"value":"Current version","help_text":"text/plain","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Exceptions;\n\nuse Throwable;\n\nclass RateLimitException extends RuntimeException\n{\n private readonly int $retryAfter;\n\n public function __construct(\n string $message = '',\n int $retryAfter = 1,\n ?Throwable $previous = null,\n ) {\n parent::__construct($message, 0, $previous);\n $this->retryAfter = max(1, $retryAfter);\n }\n\n public function getRetryAfter(): int\n {\n return $this->retryAfter;\n }\n}","depth":2,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Exceptions;\n\nuse Throwable;\n\nclass RateLimitException extends RuntimeException\n{\n private readonly int $retryAfter;\n\n public function __construct(\n string $message = '',\n int $retryAfter = 1,\n ?Throwable $previous = null,\n ) {\n parent::__construct($message, 0, $previous);\n $this->retryAfter = max(1, $retryAfter);\n }\n\n public function getRetryAfter(): int\n {\n return $this->retryAfter;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Help","depth":1,"on_screen":true,"help_text":"Show help contents","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel","depth":1,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Commit","depth":1,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Commit","depth":2,"on_screen":true,"help_text":"Show drop-down menu (⌥⇧⏎)","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Commit Changes","depth":1,"on_screen":true,"role_description":"text"}]...
|
-654605678890122259
|
-2543036966578944697
|
idle
|
accessibility
|
NULL
|
Diff
Changelist:
Changelist:
Changes
Show Diff
Rol Diff
Changelist:
Changelist:
Changes
Show Diff
Rollback...
Refresh
Group By
Expand All
Collapse All
5 modified
Analyzing…
JY-20725 code review suggestions
Commit Message
Commit Message History
Git
Author:
Author:
Amend commit
Sign-off commit
Create extra commit with file movements
Commit Checks
Update copyright
Reformat code
Rearrange code
Optimize imports
Cleanup
Choose profile
Check malicious dependencies
Run external formatter
Configure
Perform SonarQube for IDE analysis
Find unused view files
Run Git hooks
Advanced Commit Checks
Analyze code
Choose profile
Check TODO
Configure
After Commit
Upload files to:
Upload files to:
<None>
Always use selected server or group of servers
Previous Difference
Next Difference
Jump to Source
Compare Previous File
Compare Next File
Go to Changed File…
Side-by-side viewer
Do not ignore
Highlight words
Collapse Unchanged Fragments
Synchronize Scrolling
Disable Editing
Settings
Help
4 differences
02d5214b
<?php
declare(strict_types=1);
namespace Jiminny\Exceptions;
use Throwable;
class RateLimitException extends RuntimeException
{
public function __construct(
string $message = '',
private readonly int $retryAfter = 1,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
public function getRetryAfter(): int
{
return max($this->retryAfter, 1);
}
}
Current version
<?php
declare(strict_types=1);
namespace Jiminny\Exceptions;
use Throwable;
class RateLimitException extends RuntimeException
{
private readonly int $retryAfter;
public function __construct(
string $message = '',
int $retryAfter = 1,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
$this->retryAfter = max(1, $retryAfter);
}
public function getRetryAfter(): int
{
return $this->retryAfter;
}
}
Help
Cancel
Commit
Commit
Commit Changes...
|
20609
|
NULL
|
NULL
|
NULL
|
|
20614
|
NULL
|
0
|
2026-05-11T15:59:52.420141+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515192420_m2.jpg...
|
PhpStorm
|
Commit Changes
|
True
|
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
5 modified
Analyzing…
JY-20725 code review suggestions
Commit Message
Commit Message History
Git
Author:
Author:
Amend commit
Sign-off commit
Create extra commit with file movements
Commit Checks
Update copyright
Reformat code
Rearrange code
Optimize imports
Cleanup
Choose profile
Check malicious dependencies
Run external formatter
Configure
Perform SonarQube for IDE analysis
Find unused view files
Run Git hooks
Advanced Commit Checks
Analyze code
Choose profile
Check TODO
Configure
After Commit
Upload files to:
Upload files to:
<None>
Always use selected server or group of servers
Previous Difference
Next Difference
Jump to Source
Compare Previous File
Compare Next File
Go to Changed File…
Side-by-side viewer
Do not ignore
Highlight words
Collapse Unchanged Fragments
Synchronize Scrolling
Disable Editing
Settings
Help
4 differences
02d5214b
<?php
declare(strict_types=1);
namespace Jiminny\Exceptions;
use Throwable;
class RateLimitException extends RuntimeException
{
public function __construct(
string $message = '',
private readonly int $retryAfter = 1,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
public function getRetryAfter(): int
{
return max($this->retryAfter, 1);
}
}
Current version
<?php
declare(strict_types=1);
namespace Jiminny\Exceptions;
use Throwable;
class RateLimitException extends RuntimeException
{
private readonly int $retryAfter;
public function __construct(
string $message = '',
int $retryAfter = 1,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
$this->retryAfter = max(1, $retryAfter);
}
public function getRetryAfter(): int
{
return $this->retryAfter;
}
}
Help
Cancel
Commit
Commit
Commit Changes...
|
[{"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":"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 code review suggestions","depth":2,"bounds":{"left":0.2556516,"top":0.3008779,"width":0.19281915,"height":0.087789305},"on_screen":true,"value":"JY-20725 code review suggestions","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},{"role":"AXCheckBox","text":"Reformat code","depth":2,"bounds":{"left":0.4587766,"top":0.31923383,"width":0.03956117,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Rearrange code","depth":2,"bounds":{"left":0.4587766,"top":0.3423783,"width":0.041888297,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Optimize imports","depth":2,"bounds":{"left":0.4587766,"top":0.36552274,"width":0.044215426,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Cleanup","depth":2,"bounds":{"left":0.4587766,"top":0.3886672,"width":0.026263298,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Choose profile","depth":2,"bounds":{"left":0.48902926,"top":0.39185953,"width":0.029920213,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Check malicious dependencies","depth":2,"bounds":{"left":0.4587766,"top":0.41181165,"width":0.07247341,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Run external formatter","depth":2,"bounds":{"left":0.4587766,"top":0.4349561,"width":0.05518617,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Configure","depth":2,"bounds":{"left":0.51795214,"top":0.43814844,"width":0.019946808,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Perform SonarQube for IDE analysis","depth":2,"bounds":{"left":0.4587766,"top":0.45810056,"width":0.1200133,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Find unused view files","depth":2,"bounds":{"left":0.4587766,"top":0.481245,"width":0.054521278,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Run Git hooks","depth":2,"on_screen":false,"help_text":"If unchecked, Git hooks will be skipped with the '--no-verify' option for the upcoming commit","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Advanced Commit Checks","depth":2,"bounds":{"left":0.45212767,"top":0.5235435,"width":0.05418883,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"Analyze code","depth":2,"bounds":{"left":0.4587766,"top":0.5434956,"width":0.036901597,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Choose profile","depth":2,"bounds":{"left":0.49966756,"top":0.54668796,"width":0.029920213,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Check TODO","depth":2,"bounds":{"left":0.4587766,"top":0.5666401,"width":0.035904255,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Configure","depth":2,"bounds":{"left":0.49867022,"top":0.5698324,"width":0.019946808,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"After Commit","depth":2,"bounds":{"left":0.45212767,"top":0.6089386,"width":0.027260639,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Upload files to:","depth":2,"bounds":{"left":0.46010637,"top":0.6360734,"width":0.030585106,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"Upload files to:","depth":2,"bounds":{"left":0.49168882,"top":0.62968874,"width":0.07679521,"height":0.027134877},"on_screen":true,"role_description":"pop up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"<None>","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXCheckBox","text":"Always use selected server or group of servers","depth":2,"bounds":{"left":0.46675533,"top":0.66081405,"width":0.10538564,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Previous Difference","depth":2,"bounds":{"left":0.25664893,"top":0.41340783,"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":"Next Difference","depth":2,"bounds":{"left":0.26529256,"top":0.41340783,"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.27393618,"top":0.41340783,"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":"Compare Previous File","depth":2,"bounds":{"left":0.28490692,"top":0.41340783,"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":"Compare Next File","depth":2,"bounds":{"left":0.29355052,"top":0.41340783,"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":"Go to Changed File…","depth":2,"bounds":{"left":0.30219415,"top":0.41340783,"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":"Side-by-side viewer","depth":2,"bounds":{"left":0.3118351,"top":0.41340783,"width":0.04720745,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Do not ignore","depth":2,"bounds":{"left":0.36336437,"top":0.41340783,"width":0.03557181,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Highlight words","depth":2,"bounds":{"left":0.40093085,"top":0.41340783,"width":0.03956117,"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 Unchanged Fragments","depth":2,"bounds":{"left":0.44148937,"top":0.41340783,"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":"Synchronize Scrolling","depth":2,"bounds":{"left":0.45013297,"top":0.41340783,"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":"Disable Editing","depth":2,"bounds":{"left":0.4587766,"top":0.41340783,"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":"Settings","depth":2,"bounds":{"left":0.46742022,"top":0.41340783,"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":"Help","depth":2,"bounds":{"left":0.47839096,"top":0.41340783,"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":"4 differences","depth":1,"bounds":{"left":0.5511968,"top":0.41181165,"width":0.027260639,"height":0.022346368},"on_screen":true,"role_description":"text"},{"role":"AXTextField","text":"02d5214b","depth":1,"bounds":{"left":0.26196808,"top":0.43415803,"width":0.023603724,"height":0.013567438},"on_screen":true,"value":"02d5214b","help_text":"text/plain","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Exceptions;\n\nuse Throwable;\n\nclass RateLimitException extends RuntimeException\n{\n public function __construct(\n string $message = '',\n private readonly int $retryAfter = 1,\n ?Throwable $previous = null,\n ) {\n parent::__construct($message, 0, $previous);\n }\n\n public function getRetryAfter(): int\n {\n return max($this->retryAfter, 1);\n }\n}","depth":2,"bounds":{"left":0.25531915,"top":0.42697525,"width":0.14827128,"height":0.42138866},"on_screen":true,"lines":[{"char_start":0,"char_count":6,"bounds":{"left":0.2613032,"top":0.1452514,"width":0.0026595744,"height":0.014365523}},{"char_start":7,"char_count":25,"bounds":{"left":0.2613032,"top":0.1452514,"width":0.0026595744,"height":0.014365523}},{"char_start":33,"char_count":30,"bounds":{"left":0.2613032,"top":0.1452514,"width":0.0026595744,"height":0.014365523}},{"char_start":64,"char_count":15,"bounds":{"left":0.2613032,"top":0.16280925,"width":0.036236703,"height":0.014365523}},{"char_start":80,"char_count":50,"bounds":{"left":0.2613032,"top":0.19792499,"width":0.12699468,"height":0.014365523}},{"char_start":130,"char_count":2,"bounds":{"left":0.2613032,"top":0.21548285,"width":0.0026595744,"height":0.014365523}},{"char_start":132,"char_count":33,"bounds":{"left":0.2613032,"top":0.2330407,"width":0.08277926,"height":0.014365523}},{"char_start":165,"char_count":30,"bounds":{"left":0.2613032,"top":0.25059855,"width":0.07513298,"height":0.014365523}},{"char_start":195,"char_count":46,"bounds":{"left":0.2613032,"top":0.26815644,"width":0.11635638,"height":0.014365523}},{"char_start":241,"char_count":37,"bounds":{"left":0.2613032,"top":0.2857143,"width":0.0930851,"height":0.014365523}},{"char_start":278,"char_count":8,"bounds":{"left":0.2613032,"top":0.30327216,"width":0.017952127,"height":0.014365523}},{"char_start":286,"char_count":53,"bounds":{"left":0.2613032,"top":0.32083002,"width":0.13464096,"height":0.014365523}},{"char_start":339,"char_count":6,"bounds":{"left":0.2613032,"top":0.33838788,"width":0.012965426,"height":0.014365523}},{"char_start":346,"char_count":41,"bounds":{"left":0.2613032,"top":0.3735036,"width":0.10372341,"height":0.014365523}},{"char_start":387,"char_count":6,"bounds":{"left":0.2613032,"top":0.39106146,"width":0.012965426,"height":0.014365523}},{"char_start":393,"char_count":42,"bounds":{"left":0.2613032,"top":0.4086193,"width":0.10605053,"height":0.014365523}},{"char_start":435,"char_count":6,"bounds":{"left":0.2613032,"top":0.42617717,"width":0.012965426,"height":0.014365523}},{"char_start":441,"char_count":1,"bounds":{"left":0.2613032,"top":0.44373503,"width":0.0026595744,"height":0.014365523}}],"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Exceptions;\n\nuse Throwable;\n\nclass RateLimitException extends RuntimeException\n{\n public function __construct(\n string $message = '',\n private readonly int $retryAfter = 1,\n ?Throwable $previous = null,\n ) {\n parent::__construct($message, 0, $previous);\n }\n\n public function getRetryAfter(): int\n {\n return max($this->retryAfter, 1);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Current version","depth":1,"bounds":{"left":0.4348404,"top":0.43415803,"width":0.14361702,"height":0.013567438},"on_screen":true,"value":"Current version","help_text":"text/plain","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Exceptions;\n\nuse Throwable;\n\nclass RateLimitException extends RuntimeException\n{\n private readonly int $retryAfter;\n\n public function __construct(\n string $message = '',\n int $retryAfter = 1,\n ?Throwable $previous = null,\n ) {\n parent::__construct($message, 0, $previous);\n $this->retryAfter = max(1, $retryAfter);\n }\n\n public function getRetryAfter(): int\n {\n return $this->retryAfter;\n }\n}","depth":2,"bounds":{"left":0.43949467,"top":0.42697525,"width":0.14228724,"height":0.47406226},"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Exceptions;\n\nuse Throwable;\n\nclass RateLimitException extends RuntimeException\n{\n private readonly int $retryAfter;\n\n public function __construct(\n string $message = '',\n int $retryAfter = 1,\n ?Throwable $previous = null,\n ) {\n parent::__construct($message, 0, $previous);\n $this->retryAfter = max(1, $retryAfter);\n }\n\n public function getRetryAfter(): int\n {\n return $this->retryAfter;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Help","depth":1,"bounds":{"left":0.25531915,"top":0.65363127,"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":"AXButton","text":"Cancel","depth":1,"bounds":{"left":0.5142952,"top":0.65363127,"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":"Commit","depth":1,"bounds":{"left":0.5422208,"top":0.65363127,"width":0.03756649,"height":0.027134877},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Commit","depth":2,"bounds":{"left":0.5422208,"top":0.65363127,"width":0.027260639,"height":0.027134877},"on_screen":true,"help_text":"Show drop-down menu (⌥⇧⏎)","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Commit Changes","depth":1,"bounds":{"left":0.39827126,"top":0.13248204,"width":0.038231384,"height":0.012769354},"on_screen":true,"role_description":"text"}]...
|
-654605678890122259
|
-2543036966578944697
|
idle
|
accessibility
|
NULL
|
Diff
Changelist:
Changelist:
Changes
Show Diff
Rol Diff
Changelist:
Changelist:
Changes
Show Diff
Rollback...
Refresh
Group By
Expand All
Collapse All
5 modified
Analyzing…
JY-20725 code review suggestions
Commit Message
Commit Message History
Git
Author:
Author:
Amend commit
Sign-off commit
Create extra commit with file movements
Commit Checks
Update copyright
Reformat code
Rearrange code
Optimize imports
Cleanup
Choose profile
Check malicious dependencies
Run external formatter
Configure
Perform SonarQube for IDE analysis
Find unused view files
Run Git hooks
Advanced Commit Checks
Analyze code
Choose profile
Check TODO
Configure
After Commit
Upload files to:
Upload files to:
<None>
Always use selected server or group of servers
Previous Difference
Next Difference
Jump to Source
Compare Previous File
Compare Next File
Go to Changed File…
Side-by-side viewer
Do not ignore
Highlight words
Collapse Unchanged Fragments
Synchronize Scrolling
Disable Editing
Settings
Help
4 differences
02d5214b
<?php
declare(strict_types=1);
namespace Jiminny\Exceptions;
use Throwable;
class RateLimitException extends RuntimeException
{
public function __construct(
string $message = '',
private readonly int $retryAfter = 1,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
public function getRetryAfter(): int
{
return max($this->retryAfter, 1);
}
}
Current version
<?php
declare(strict_types=1);
namespace Jiminny\Exceptions;
use Throwable;
class RateLimitException extends RuntimeException
{
private readonly int $retryAfter;
public function __construct(
string $message = '',
int $retryAfter = 1,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
$this->retryAfter = max(1, $retryAfter);
}
public function getRetryAfter(): int
{
return $this->retryAfter;
}
}
Help
Cancel
Commit
Commit
Commit Changes...
|
20612
|
NULL
|
NULL
|
NULL
|
|
20615
|
896
|
0
|
2026-05-11T16:00:22.342469+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515222342_m1.jpg...
|
PhpStorm
|
Commit Changes
|
True
|
NULL
|
monitor_1
|
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
5 modified
Analyzing…
JY-20725 code review suggestions
Commit Message
Commit Message History
Git
Author:
Author:
Amend commit
Sign-off commit
Create extra commit with file movements
Commit Checks
Update copyright
Reformat code
Rearrange code
Optimize imports
Cleanup
Choose profile
Check malicious dependencies
Run external formatter
Configure
Perform SonarQube for IDE analysis
Find unused view files
Run Git hooks
Advanced Commit Checks
Analyze code
Choose profile
Check TODO
Configure
After Commit
Upload files to:
Upload files to:
<None>
Always use selected server or group of servers
Previous Difference
Next Difference
Jump to Source
Compare Previous File
Compare Next File
Go to Changed File…
Side-by-side viewer
Do not ignore
Highlight words
Collapse Unchanged Fragments
Synchronize Scrolling
Disable Editing
Settings
Help
4 differences
02d5214b
<?php
declare(strict_types=1);
namespace Jiminny\Exceptions;
use Throwable;
class RateLimitException extends RuntimeException
{
public function __construct(
string $message = '',
private readonly int $retryAfter = 1,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
public function getRetryAfter(): int
{
return max($this->retryAfter, 1);
}
}
Current version
<?php
declare(strict_types=1);
namespace Jiminny\Exceptions;
use Throwable;
class RateLimitException extends RuntimeException
{
private readonly int $retryAfter;
public function __construct(
string $message = '',
int $retryAfter = 1,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
$this->retryAfter = max(1, $retryAfter);
}
public function getRetryAfter(): int
{
return $this->retryAfter;
}
}
Help
Cancel
Commit
Commit
Commit Changes...
|
[{"role":"AXStaticText","text& [{"role":"AXStaticText","text":"Diff","depth":1,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Changelist:","depth":1,"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"Changelist:","depth":1,"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,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Rollback...","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Refresh","depth":2,"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,"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,"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,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5 modified","depth":1,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Analyzing…","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.041666668,"height":0.02},"on_screen":false,"role_description":"text"},{"role":"AXTextArea","text":"JY-20725 code review suggestions","depth":2,"on_screen":true,"value":"JY-20725 code review suggestions","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Commit Message","depth":1,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Commit Message History","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Git","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Author:","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"Author:","depth":3,"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,"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,"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,"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"Update copyright","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Reformat code","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Rearrange code","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Optimize imports","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Cleanup","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Choose profile","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Check malicious dependencies","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Run external formatter","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Configure","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Perform SonarQube for IDE analysis","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Find unused view files","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Run Git hooks","depth":2,"on_screen":false,"help_text":"If unchecked, Git hooks will be skipped with the '--no-verify' option for the upcoming commit","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Advanced Commit Checks","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"Analyze code","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Choose profile","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Check TODO","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Configure","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"After Commit","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Upload files to:","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"Upload files to:","depth":2,"on_screen":true,"role_description":"pop up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"<None>","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXCheckBox","text":"Always use selected server or group of servers","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Previous Difference","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Difference","depth":2,"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,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Compare Previous File","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Compare Next File","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Go to Changed File…","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Side-by-side viewer","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Do not ignore","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Highlight words","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse Unchanged Fragments","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Synchronize Scrolling","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Disable Editing","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Settings","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Help","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"4 differences","depth":1,"on_screen":true,"role_description":"text"},{"role":"AXTextField","text":"02d5214b","depth":1,"on_screen":true,"value":"02d5214b","help_text":"text/plain","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Exceptions;\n\nuse Throwable;\n\nclass RateLimitException extends RuntimeException\n{\n public function __construct(\n string $message = '',\n private readonly int $retryAfter = 1,\n ?Throwable $previous = null,\n ) {\n parent::__construct($message, 0, $previous);\n }\n\n public function getRetryAfter(): int\n {\n return max($this->retryAfter, 1);\n }\n}","depth":2,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Exceptions;\n\nuse Throwable;\n\nclass RateLimitException extends RuntimeException\n{\n public function __construct(\n string $message = '',\n private readonly int $retryAfter = 1,\n ?Throwable $previous = null,\n ) {\n parent::__construct($message, 0, $previous);\n }\n\n public function getRetryAfter(): int\n {\n return max($this->retryAfter, 1);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Current version","depth":1,"on_screen":true,"value":"Current version","help_text":"text/plain","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Exceptions;\n\nuse Throwable;\n\nclass RateLimitException extends RuntimeException\n{\n private readonly int $retryAfter;\n\n public function __construct(\n string $message = '',\n int $retryAfter = 1,\n ?Throwable $previous = null,\n ) {\n parent::__construct($message, 0, $previous);\n $this->retryAfter = max(1, $retryAfter);\n }\n\n public function getRetryAfter(): int\n {\n return $this->retryAfter;\n }\n}","depth":2,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Exceptions;\n\nuse Throwable;\n\nclass RateLimitException extends RuntimeException\n{\n private readonly int $retryAfter;\n\n public function __construct(\n string $message = '',\n int $retryAfter = 1,\n ?Throwable $previous = null,\n ) {\n parent::__construct($message, 0, $previous);\n $this->retryAfter = max(1, $retryAfter);\n }\n\n public function getRetryAfter(): int\n {\n return $this->retryAfter;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Help","depth":1,"on_screen":true,"help_text":"Show help contents","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel","depth":1,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Commit","depth":1,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Commit","depth":2,"on_screen":true,"help_text":"Show drop-down menu (⌥⇧⏎)","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Commit Changes","depth":1,"on_screen":true,"role_description":"text"}]...
|
-654605678890122259
|
-2543036966578944697
|
idle
|
accessibility
|
NULL
|
Diff
Changelist:
Changelist:
Changes
Show Diff
Rol Diff
Changelist:
Changelist:
Changes
Show Diff
Rollback...
Refresh
Group By
Expand All
Collapse All
5 modified
Analyzing…
JY-20725 code review suggestions
Commit Message
Commit Message History
Git
Author:
Author:
Amend commit
Sign-off commit
Create extra commit with file movements
Commit Checks
Update copyright
Reformat code
Rearrange code
Optimize imports
Cleanup
Choose profile
Check malicious dependencies
Run external formatter
Configure
Perform SonarQube for IDE analysis
Find unused view files
Run Git hooks
Advanced Commit Checks
Analyze code
Choose profile
Check TODO
Configure
After Commit
Upload files to:
Upload files to:
<None>
Always use selected server or group of servers
Previous Difference
Next Difference
Jump to Source
Compare Previous File
Compare Next File
Go to Changed File…
Side-by-side viewer
Do not ignore
Highlight words
Collapse Unchanged Fragments
Synchronize Scrolling
Disable Editing
Settings
Help
4 differences
02d5214b
<?php
declare(strict_types=1);
namespace Jiminny\Exceptions;
use Throwable;
class RateLimitException extends RuntimeException
{
public function __construct(
string $message = '',
private readonly int $retryAfter = 1,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
public function getRetryAfter(): int
{
return max($this->retryAfter, 1);
}
}
Current version
<?php
declare(strict_types=1);
namespace Jiminny\Exceptions;
use Throwable;
class RateLimitException extends RuntimeException
{
private readonly int $retryAfter;
public function __construct(
string $message = '',
int $retryAfter = 1,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
$this->retryAfter = max(1, $retryAfter);
}
public function getRetryAfter(): int
{
return $this->retryAfter;
}
}
Help
Cancel
Commit
Commit
Commit Changes...
|
20609
|
NULL
|
NULL
|
NULL
|
|
20616
|
897
|
0
|
2026-05-11T16:00:22.717316+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515222717_m2.jpg...
|
PhpStorm
|
Commit Changes
|
True
|
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
5 modified
Analyzing…
JY-20725 code review suggestions
Commit Message
Commit Message History
Git
Author:
Author:
Amend commit
Sign-off commit
Create extra commit with file movements
Commit Checks
Update copyright
Reformat code
Rearrange code
Optimize imports
Cleanup
Choose profile
Check malicious dependencies
Run external formatter
Configure
Perform SonarQube for IDE analysis
Find unused view files
Run Git hooks
Advanced Commit Checks
Analyze code
Choose profile
Check TODO
Configure
After Commit
Upload files to:
Upload files to:
<None>
Always use selected server or group of servers
Previous Difference
Next Difference
Jump to Source
Compare Previous File
Compare Next File
Go to Changed File…
Side-by-side viewer
Do not ignore
Highlight words
Collapse Unchanged Fragments
Synchronize Scrolling
Disable Editing
Settings
Help
4 differences
02d5214b
<?php
declare(strict_types=1);
namespace Jiminny\Exceptions;
use Throwable;
class RateLimitException extends RuntimeException
{
public function __construct(
string $message = '',
private readonly int $retryAfter = 1,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
public function getRetryAfter(): int
{
return max($this->retryAfter, 1);
}
}
Current version
<?php
declare(strict_types=1);
namespace Jiminny\Exceptions;
use Throwable;
class RateLimitException extends RuntimeException
{
private readonly int $retryAfter;
public function __construct(
string $message = '',
int $retryAfter = 1,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
$this->retryAfter = max(1, $retryAfter);
}
public function getRetryAfter(): int
{
return $this->retryAfter;
}
}
Help
Cancel
Commit
Commit
Commit Changes...
|
[{"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":"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 code review suggestions","depth":2,"bounds":{"left":0.2556516,"top":0.3008779,"width":0.19281915,"height":0.087789305},"on_screen":true,"value":"JY-20725 code review suggestions","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},{"role":"AXCheckBox","text":"Reformat code","depth":2,"bounds":{"left":0.4587766,"top":0.31923383,"width":0.03956117,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Rearrange code","depth":2,"bounds":{"left":0.4587766,"top":0.3423783,"width":0.041888297,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Optimize imports","depth":2,"bounds":{"left":0.4587766,"top":0.36552274,"width":0.044215426,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Cleanup","depth":2,"bounds":{"left":0.4587766,"top":0.3886672,"width":0.026263298,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Choose profile","depth":2,"bounds":{"left":0.48902926,"top":0.39185953,"width":0.029920213,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Check malicious dependencies","depth":2,"bounds":{"left":0.4587766,"top":0.41181165,"width":0.07247341,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Run external formatter","depth":2,"bounds":{"left":0.4587766,"top":0.4349561,"width":0.05518617,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Configure","depth":2,"bounds":{"left":0.51795214,"top":0.43814844,"width":0.019946808,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Perform SonarQube for IDE analysis","depth":2,"bounds":{"left":0.4587766,"top":0.45810056,"width":0.1200133,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Find unused view files","depth":2,"bounds":{"left":0.4587766,"top":0.481245,"width":0.054521278,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Run Git hooks","depth":2,"on_screen":false,"help_text":"If unchecked, Git hooks will be skipped with the '--no-verify' option for the upcoming commit","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Advanced Commit Checks","depth":2,"bounds":{"left":0.45212767,"top":0.5235435,"width":0.05418883,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"Analyze code","depth":2,"bounds":{"left":0.4587766,"top":0.5434956,"width":0.036901597,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Choose profile","depth":2,"bounds":{"left":0.49966756,"top":0.54668796,"width":0.029920213,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Check TODO","depth":2,"bounds":{"left":0.4587766,"top":0.5666401,"width":0.035904255,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Configure","depth":2,"bounds":{"left":0.49867022,"top":0.5698324,"width":0.019946808,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"After Commit","depth":2,"bounds":{"left":0.45212767,"top":0.6089386,"width":0.027260639,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Upload files to:","depth":2,"bounds":{"left":0.46010637,"top":0.6360734,"width":0.030585106,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"Upload files to:","depth":2,"bounds":{"left":0.49168882,"top":0.62968874,"width":0.07679521,"height":0.027134877},"on_screen":true,"role_description":"pop up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"<None>","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXCheckBox","text":"Always use selected server or group of servers","depth":2,"bounds":{"left":0.46675533,"top":0.66081405,"width":0.10538564,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Previous Difference","depth":2,"bounds":{"left":0.25664893,"top":0.41340783,"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":"Next Difference","depth":2,"bounds":{"left":0.26529256,"top":0.41340783,"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.27393618,"top":0.41340783,"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":"Compare Previous File","depth":2,"bounds":{"left":0.28490692,"top":0.41340783,"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":"Compare Next File","depth":2,"bounds":{"left":0.29355052,"top":0.41340783,"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":"Go to Changed File…","depth":2,"bounds":{"left":0.30219415,"top":0.41340783,"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":"Side-by-side viewer","depth":2,"bounds":{"left":0.3118351,"top":0.41340783,"width":0.04720745,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Do not ignore","depth":2,"bounds":{"left":0.36336437,"top":0.41340783,"width":0.03557181,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Highlight words","depth":2,"bounds":{"left":0.40093085,"top":0.41340783,"width":0.03956117,"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 Unchanged Fragments","depth":2,"bounds":{"left":0.44148937,"top":0.41340783,"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":"Synchronize Scrolling","depth":2,"bounds":{"left":0.45013297,"top":0.41340783,"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":"Disable Editing","depth":2,"bounds":{"left":0.4587766,"top":0.41340783,"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":"Settings","depth":2,"bounds":{"left":0.46742022,"top":0.41340783,"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":"Help","depth":2,"bounds":{"left":0.47839096,"top":0.41340783,"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":"4 differences","depth":1,"bounds":{"left":0.5511968,"top":0.41181165,"width":0.027260639,"height":0.022346368},"on_screen":true,"role_description":"text"},{"role":"AXTextField","text":"02d5214b","depth":1,"bounds":{"left":0.26196808,"top":0.43415803,"width":0.023603724,"height":0.013567438},"on_screen":true,"value":"02d5214b","help_text":"text/plain","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Exceptions;\n\nuse Throwable;\n\nclass RateLimitException extends RuntimeException\n{\n public function __construct(\n string $message = '',\n private readonly int $retryAfter = 1,\n ?Throwable $previous = null,\n ) {\n parent::__construct($message, 0, $previous);\n }\n\n public function getRetryAfter(): int\n {\n return max($this->retryAfter, 1);\n }\n}","depth":2,"bounds":{"left":0.25531915,"top":0.42697525,"width":0.14827128,"height":0.42138866},"on_screen":true,"lines":[{"char_start":0,"char_count":6,"bounds":{"left":0.2613032,"top":0.1452514,"width":0.0026595744,"height":0.014365523}},{"char_start":7,"char_count":25,"bounds":{"left":0.2613032,"top":0.1452514,"width":0.0026595744,"height":0.014365523}},{"char_start":33,"char_count":30,"bounds":{"left":0.2613032,"top":0.1452514,"width":0.0026595744,"height":0.014365523}},{"char_start":64,"char_count":15,"bounds":{"left":0.2613032,"top":0.16280925,"width":0.036236703,"height":0.014365523}},{"char_start":80,"char_count":50,"bounds":{"left":0.2613032,"top":0.19792499,"width":0.12699468,"height":0.014365523}},{"char_start":130,"char_count":2,"bounds":{"left":0.2613032,"top":0.21548285,"width":0.0026595744,"height":0.014365523}},{"char_start":132,"char_count":33,"bounds":{"left":0.2613032,"top":0.2330407,"width":0.08277926,"height":0.014365523}},{"char_start":165,"char_count":30,"bounds":{"left":0.2613032,"top":0.25059855,"width":0.07513298,"height":0.014365523}},{"char_start":195,"char_count":46,"bounds":{"left":0.2613032,"top":0.26815644,"width":0.11635638,"height":0.014365523}},{"char_start":241,"char_count":37,"bounds":{"left":0.2613032,"top":0.2857143,"width":0.0930851,"height":0.014365523}},{"char_start":278,"char_count":8,"bounds":{"left":0.2613032,"top":0.30327216,"width":0.017952127,"height":0.014365523}},{"char_start":286,"char_count":53,"bounds":{"left":0.2613032,"top":0.32083002,"width":0.13464096,"height":0.014365523}},{"char_start":339,"char_count":6,"bounds":{"left":0.2613032,"top":0.33838788,"width":0.012965426,"height":0.014365523}},{"char_start":346,"char_count":41,"bounds":{"left":0.2613032,"top":0.3735036,"width":0.10372341,"height":0.014365523}},{"char_start":387,"char_count":6,"bounds":{"left":0.2613032,"top":0.39106146,"width":0.012965426,"height":0.014365523}},{"char_start":393,"char_count":42,"bounds":{"left":0.2613032,"top":0.4086193,"width":0.10605053,"height":0.014365523}},{"char_start":435,"char_count":6,"bounds":{"left":0.2613032,"top":0.42617717,"width":0.012965426,"height":0.014365523}},{"char_start":441,"char_count":1,"bounds":{"left":0.2613032,"top":0.44373503,"width":0.0026595744,"height":0.014365523}}],"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Exceptions;\n\nuse Throwable;\n\nclass RateLimitException extends RuntimeException\n{\n public function __construct(\n string $message = '',\n private readonly int $retryAfter = 1,\n ?Throwable $previous = null,\n ) {\n parent::__construct($message, 0, $previous);\n }\n\n public function getRetryAfter(): int\n {\n return max($this->retryAfter, 1);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Current version","depth":1,"bounds":{"left":0.4348404,"top":0.43415803,"width":0.14361702,"height":0.013567438},"on_screen":true,"value":"Current version","help_text":"text/plain","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Exceptions;\n\nuse Throwable;\n\nclass RateLimitException extends RuntimeException\n{\n private readonly int $retryAfter;\n\n public function __construct(\n string $message = '',\n int $retryAfter = 1,\n ?Throwable $previous = null,\n ) {\n parent::__construct($message, 0, $previous);\n $this->retryAfter = max(1, $retryAfter);\n }\n\n public function getRetryAfter(): int\n {\n return $this->retryAfter;\n }\n}","depth":2,"bounds":{"left":0.43949467,"top":0.42697525,"width":0.14228724,"height":0.47406226},"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Exceptions;\n\nuse Throwable;\n\nclass RateLimitException extends RuntimeException\n{\n private readonly int $retryAfter;\n\n public function __construct(\n string $message = '',\n int $retryAfter = 1,\n ?Throwable $previous = null,\n ) {\n parent::__construct($message, 0, $previous);\n $this->retryAfter = max(1, $retryAfter);\n }\n\n public function getRetryAfter(): int\n {\n return $this->retryAfter;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Help","depth":1,"bounds":{"left":0.25531915,"top":0.65363127,"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":"AXButton","text":"Cancel","depth":1,"bounds":{"left":0.5142952,"top":0.65363127,"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":"Commit","depth":1,"bounds":{"left":0.5422208,"top":0.65363127,"width":0.03756649,"height":0.027134877},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Commit","depth":2,"bounds":{"left":0.5422208,"top":0.65363127,"width":0.027260639,"height":0.027134877},"on_screen":true,"help_text":"Show drop-down menu (⌥⇧⏎)","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Commit Changes","depth":1,"bounds":{"left":0.39827126,"top":0.13248204,"width":0.038231384,"height":0.012769354},"on_screen":true,"role_description":"text"}]...
|
-654605678890122259
|
-2543036966578944697
|
idle
|
accessibility
|
NULL
|
Diff
Changelist:
Changelist:
Changes
Show Diff
Rol Diff
Changelist:
Changelist:
Changes
Show Diff
Rollback...
Refresh
Group By
Expand All
Collapse All
5 modified
Analyzing…
JY-20725 code review suggestions
Commit Message
Commit Message History
Git
Author:
Author:
Amend commit
Sign-off commit
Create extra commit with file movements
Commit Checks
Update copyright
Reformat code
Rearrange code
Optimize imports
Cleanup
Choose profile
Check malicious dependencies
Run external formatter
Configure
Perform SonarQube for IDE analysis
Find unused view files
Run Git hooks
Advanced Commit Checks
Analyze code
Choose profile
Check TODO
Configure
After Commit
Upload files to:
Upload files to:
<None>
Always use selected server or group of servers
Previous Difference
Next Difference
Jump to Source
Compare Previous File
Compare Next File
Go to Changed File…
Side-by-side viewer
Do not ignore
Highlight words
Collapse Unchanged Fragments
Synchronize Scrolling
Disable Editing
Settings
Help
4 differences
02d5214b
<?php
declare(strict_types=1);
namespace Jiminny\Exceptions;
use Throwable;
class RateLimitException extends RuntimeException
{
public function __construct(
string $message = '',
private readonly int $retryAfter = 1,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
public function getRetryAfter(): int
{
return max($this->retryAfter, 1);
}
}
Current version
<?php
declare(strict_types=1);
namespace Jiminny\Exceptions;
use Throwable;
class RateLimitException extends RuntimeException
{
private readonly int $retryAfter;
public function __construct(
string $message = '',
int $retryAfter = 1,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
$this->retryAfter = max(1, $retryAfter);
}
public function getRetryAfter(): int
{
return $this->retryAfter;
}
}
Help
Cancel
Commit
Commit
Commit Changes...
|
20612
|
NULL
|
NULL
|
NULL
|
|
20642
|
NULL
|
0
|
2026-05-11T16:04:53.729775+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515493729_m1.jpg...
|
PhpStorm
|
faVsco.js – HandleHubspotRateLimitTest.php
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Pushed 1 commit to origin/JY-20725-handle-HS-searc Pushed 1 commit to origin/JY-20725-handle-HS-search-rate-limit
text/html
text/html
text/html
View pull request
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":"AXTextField","text [{"role":"AXTextField","text":"Pushed 1 commit to origin/JY-20725-handle-HS-search-rate-limit","depth":3,"on_screen":true,"value":"Pushed 1 commit to origin/JY-20725-handle-HS-search-rate-limit","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,"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":"View pull request","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5 files committed","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXTextField","text":"JY-20725 code review suggestions","depth":3,"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,"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,"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,"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,"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,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"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,"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,"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,"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,"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,"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,"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.088194445,"height":0.027777778},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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,"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.088194445,"height":0.027777778},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"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,"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,"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-3148113193980076478
|
-7141493143111640438
|
idle
|
accessibility
|
NULL
|
Pushed 1 commit to origin/JY-20725-handle-HS-searc Pushed 1 commit to origin/JY-20725-handle-HS-search-rate-limit
text/html
text/html
text/html
View pull request
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...
|
20640
|
NULL
|
NULL
|
NULL
|
|
20643
|
NULL
|
0
|
2026-05-11T16:04:57.113395+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515497113_m2.jpg...
|
PhpStorm
|
faVsco.js – HandleHubspotRateLimitTest.php
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Pushed 1 commit to origin/JY-20725-handle-HS-searc Pushed 1 commit to origin/JY-20725-handle-HS-search-rate-limit
text/html
text/html
text/html
View pull request
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":"AXTextField","text [{"role":"AXTextField","text":"Pushed 1 commit to origin/JY-20725-handle-HS-search-rate-limit","depth":3,"bounds":{"left":0.8753325,"top":0.8387869,"width":0.11037234,"height":0.027134877},"on_screen":true,"value":"Pushed 1 commit to origin/JY-20725-handle-HS-search-rate-limit","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.8387869,"width":0.096409574,"height":0.027134877},"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":"View pull request","depth":2,"bounds":{"left":0.8753325,"top":0.87150836,"width":0.03523936,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"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.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}]...
|
-3148113193980076478
|
-7141493143111640438
|
idle
|
accessibility
|
NULL
|
Pushed 1 commit to origin/JY-20725-handle-HS-searc Pushed 1 commit to origin/JY-20725-handle-HS-search-rate-limit
text/html
text/html
text/html
View pull request
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...
|
20639
|
NULL
|
NULL
|
NULL
|
|
20644
|
898
|
0
|
2026-05-11T16:05:24.057468+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515524057_m1.jpg...
|
PhpStorm
|
faVsco.js – HandleHubspotRateLimitTest.php
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Pushed 1 commit to origin/JY-20725-handle-HS-searc Pushed 1 commit to origin/JY-20725-handle-HS-search-rate-limit
text/html
text/html
text/html
View pull request
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":"AXTextField","text [{"role":"AXTextField","text":"Pushed 1 commit to origin/JY-20725-handle-HS-search-rate-limit","depth":3,"on_screen":true,"value":"Pushed 1 commit to origin/JY-20725-handle-HS-search-rate-limit","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,"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":"View pull request","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5 files committed","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXTextField","text":"JY-20725 code review suggestions","depth":3,"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,"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,"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,"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,"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,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"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,"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,"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,"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,"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,"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,"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.088194445,"height":0.027777778},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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,"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.088194445,"height":0.027777778},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"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,"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,"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"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.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-3148113193980076478
|
-7141493143111640438
|
idle
|
accessibility
|
NULL
|
Pushed 1 commit to origin/JY-20725-handle-HS-searc Pushed 1 commit to origin/JY-20725-handle-HS-search-rate-limit
text/html
text/html
text/html
View pull request
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...
|
20640
|
NULL
|
NULL
|
NULL
|
|
20645
|
899
|
0
|
2026-05-11T16:05:27.517070+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515527517_m2.jpg...
|
PhpStorm
|
faVsco.js – HandleHubspotRateLimitTest.php
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Pushed 1 commit to origin/JY-20725-handle-HS-searc Pushed 1 commit to origin/JY-20725-handle-HS-search-rate-limit
text/html
text/html
text/html
View pull request
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":"AXTextField","text [{"role":"AXTextField","text":"Pushed 1 commit to origin/JY-20725-handle-HS-search-rate-limit","depth":3,"bounds":{"left":0.8753325,"top":0.8387869,"width":0.11037234,"height":0.027134877},"on_screen":true,"value":"Pushed 1 commit to origin/JY-20725-handle-HS-search-rate-limit","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.8387869,"width":0.096409574,"height":0.027134877},"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":"View pull request","depth":2,"bounds":{"left":0.8753325,"top":0.87150836,"width":0.03523936,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"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.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}]...
|
-3148113193980076478
|
-7141493143111640438
|
idle
|
accessibility
|
NULL
|
Pushed 1 commit to origin/JY-20725-handle-HS-searc Pushed 1 commit to origin/JY-20725-handle-HS-search-rate-limit
text/html
text/html
text/html
View pull request
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...
|
20639
|
NULL
|
NULL
|
NULL
|
|
20673
|
NULL
|
0
|
2026-05-11T16:09:41.430722+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515781430_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...
|
True
|
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...
|
[{"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":"AXRadioButton","text":"Pull requests · 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":"Pull requests · 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":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"}]...
|
6711915039997182059
|
7828258842381432772
|
idle
|
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...
|
20671
|
NULL
|
NULL
|
NULL
|
|
20674
|
NULL
|
0
|
2026-05-11T16:09:46.833896+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515786833_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...
|
True
|
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()...
|
[{"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},{"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"}]...
|
-923086997451797241
|
7828118104893077444
|
idle
|
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()...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20675
|
900
|
0
|
2026-05-11T16:10:11.745769+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515811745_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...
|
True
|
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...
|
[{"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":"AXRadioButton","text":"Pull requests · 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":"Pull requests · 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":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"}]...
|
5239517378854622493
|
7539887728741360516
|
idle
|
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...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
20676
|
901
|
0
|
2026-05-11T16:10:17.208165+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778515817208_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...
|
True
|
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...
|
[{"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},{"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"},{"role":"AXStaticText","text":",","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"MIN_RETRY_DELAY = 1","depth":18,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", and","depth":17,"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"}]...
|
4926664024588914868
|
-1395114242445363324
|
idle
|
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...
|
20674
|
NULL
|
NULL
|
NULL
|
|
20743
|
NULL
|
0
|
2026-05-11T16:15:01.942835+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778516101942_m1.jpg...
|
Claude
|
Claude
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Skip to content
Skip to content
Click to collapse
Skip to content
Skip to content
Click to collapse
⌘B
Drag to resize
Collapse sidebar
Search
Chat
Cowork
Code
New chat ⌘N
New chat
⌘N
Projects
Artifacts
Customize
Pinned
Bulgarian citizenship application process for EU residents
More options for Bulgarian citizenship application process for EU residents
Dawarich location tracking project
More options for Dawarich location tracking project
Recents
View all
Hubspot BadRequest headers debugging
More options for Hubspot BadRequest headers debugging
Screenpipe sync script failing after recent migrations
More options for Screenpipe sync script failing after recent migrations
Monthly expense tracking
More options for Monthly expense tracking
Exporting transaction data from Notion to finance hub
More options for Exporting transaction data from Notion to finance hub
💬 How much have I spent for groc…
More options for 💬 How much have I spent for groc…
April 2026 spending by category
More options for April 2026 spending by category
Code diff review
More options for Code diff review
HubSpot rate limit implementation strategy
More options for HubSpot rate limit implementation strategy
Screenpipe retention policy code location
More options for Screenpipe retention policy code location
Viewing retention policy in screenpipe
More options for Viewing retention policy in screenpipe
Clean shot x video recording termination issue
More options for Clean shot x video recording termination issue
HubSpot rate limit handling with executeRequest
More options for HubSpot rate limit handling with executeRequest
Untitled
More options
💬 Screen pipe. Is there ability…
More options for 💬 Screen pipe. Is there ability…
SMB mount access inconsistency between Finder and iTerm
More options for SMB mount access inconsistency between Finder and iTerm
💬 What is the best switch I can…
More options for 💬 What is the best switch I can…
Permission denied on screenpipe volume
More options for Permission denied on screenpipe volume
Screenpipe sync database attachment error
More options for Screenpipe sync database attachment error
Last swimming outing with Dani
More options for Last swimming outing with Dani
Definition of incarcerated
More options for Definition of incarcerated
Lukas Pro
Get apps and extensions
Screenpipe sync script failing after recent migrations, rename chat
Screenpipe sync script failing after recent migrations
More options for Screenpipe sync script failing after recent migrations
Open sidebar
Share chat
Claude finished the response
You said: after recent updated in screenpipe (find out what are these) I am unable to run script.
You said: after recent updated in screenpipe (find out what are these) I am unable to run script.
Pasted Text, pasted, 353 lines
#!/bin/bash # screenpipe_sync.sh # Syncs Screenpipe SQLite data to a NAS archive database (append-only, no deletions). # Also copies the day's video/frame data folder to the NAS. # # Usage: # ./screenpipe_sync.sh # syncs yesterday (default) # ./screenpipe_sync.sh 2026-04-15 # sync
PASTED
after recent updated in screenpipe (find out what are these) I am unable to run script. (pasted) "lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07
[2026-05-10 11:50:45] ========================================
[2026-05-10 11:50:45] Screenpipe sync starting for: 2026-05-07
[2026-05-10 11:50:45] ========================================
[+00m00s] ▶ Preflight checks
Source DB: OK (2.2G)
NAS mount: OK /Volumes/screenpipe
Archive DB: exists ( 10G)
Data dir: OK (266 files, 292M)
[+00m05s] ▶ Counting source rows for 2026-05-07
frames: 6262
elements: 623002
ui_events: 7412
ocr_text: 1670
meetings: 2
[+00m05s] ▶ Initialising tables, indexes, FTS
creating tables ✓ 0m00s
creating indexes ✓ 0m01s
creating FTS tables ✓ 0m00s
[+00m06s] ▶ Syncing data for 2026-05-07
video_chunks ✓ 0m01s
frames (6262 rows) ⠋ Parse error near line 3: table nas.frames has 24 columns but 30 values were supplied" There were some recent changes in migrations. Here are migrations form the begining of march (approx after I installed irt first time) 20260301000000 create elements table 2026-05-06 17:27:34 True 736637f38c6e0b5547f23c870ebbc3e87ef2d8d33b22ce73f7 ... 1302167
20260301100000 fts external content 2026-05-06 17:27:34 True 44ca0e5fc3b23c19aa09d7ac3fea48de604032d5feced2615c ... 2102875
20260301200000 drop ui monitoring 2026-05-06 17:27:34 True 9ab8a4d8c0d602b491ef1a6ff36076fd7b7c12c05848201682 ... 620375
20260306000000 delete empty transcriptions 2026-05-06 17:27:34 True 5f991a21d663157a2bce5cb9f0729f02181eef817aaef5a0b8 ... 166792
20260309000000 add cloud blob id 2026-05-06 17:27:34 True e1588e32884ec5660d11bbaa995d767fb2172bb9732ad22319 ... 1450542
20260310000000 create memories 2026-05-06 17:27:34 True 4fd07e878de1dd5b8d184e7bca9ee4e6b2480bbf39e5a68ff7 ... 1135416
20260311000000 drop unused tables 2026-05-06 17:27:34 True 3d9eb9d327a61c4055b31e22082cd045e00bd7a875cbdee86b ... 547625
20260312000000 consolidate search to frames full text 2026-05-06 17:27:34 True 5a7a31a359e9e93978d46ab4759fc8cd43898c0fd325d001b7 ... 3038250
20260312000001 drop dead fts tables 2026-05-06 17:27:34 True dd8264b96b4427f40b06ac60b813b77b6d055b24dd727212c5 ... 297250
20260312000002 drop accessibility tags 2026-05-06 17:27:34 True 672b2661f7e0fc8026f2eb6cc5d24935a15db4ed4982aeb973 ... 260167
20260315000000 add frame id to memories 2026-05-06 17:27:34 True f324ec7981134e647b6497126a2b6a7467e94d271d140d0d25 ... 642250
20260316000000 add elements activity summary index 2026-05-06 17:27:34 True 5b3f99a0d58fc73d62f240319d0718963364fdee1e3a7c4866 ... 265834
20260317000000 add elements automation props 2026-05-06 17:27:34 True 4bd132d263de143c7bb0dcf2e3b8074606c58c0f79e6091d13 ... 537750
20260318000000 add elements ref frame id 2026-05-06 17:27:34 True 33282b2c342e4743f096d1e3093146e243d97f392fe4df2cb5 ... 525250
20260319000000 add sync id indexes 2026-05-06 17:27:34 True 22c7a18c918cfcc458f05fdbfe2a0b2bb65a67ae9daeec6028 ... 407083
20260320000000 add note to meetings 2026-05-06 17:27:34 True cfa45b4c98e300c40cd36942839aa20528f47ae3e7b9c86751 ... 519625
20260324000000 drop ocr text delete trigger 2026-05-06 17:27:34 True 99f445308168fc88f993c43f8e884cc4dc7e41411c86b4d3e7 ... 182209
20260326000000 add session path to pipe executions 2026-05-06 17:27:34 True 5aa266dfcd7b741a18dd3ffb6b0ca3caf2e569959074cbc3ff ... 549583
20260411000000 add elements ref frame id index 2026-05-06 17:27:34 True 378589322920e74980ea48c6b44c916cd488e47a8f6172161e ... 232083
20260415000000 frames fts external content 2026-05-06 17:27:34 True 3fdac70da2fd44bca3c3717768b57c4cbee743451b73f13de9 ... 1259250
20260423000000 add speaker id indexes 2026-05-06 17:27:34 True 80e8e0cf144ee620c81b3796196ca22b8e34f9c152ca2ffe8f ... 398709
20260429000000 add evicted at to chunks 2026-05-06 17:27:34 True 180b1d44305df17620929a69ecce60fa3ce0148905adf19ff7 ... 963833
20260502000000 add elements on screen 2026-05-06 17:27:34 True 2cf51a077a75ef32e4e1d722092053ce919bdeee151d3d91fb ... 644500
20260504120000 add document path column 2026-05-06 17:27:34 True d84f7127e1647984eeb1f89e8dffd0918525f128e4608d323f ... 593042
20260506120000 add memories sync columns 2026-05-07 06:45:22 True 800beaaa228c7fbd88fe367dc5b357adb72a3a0635da5a30f4 ... 1777417
20260501000000 add redacted columns 2026-05-08 06:25:45 True 2a4cd6714a2322ccf8f2af6084af5a05e40f460c44c1aaf874 ... 795120667
20260503000000 add frames image redacted 2026-05-08 06:25:45 True . It worked up to the point where I attemtped to remove it and install again since the retention policy doesn't clear old data and it was to big. I am basically moving data to nas and query it form there. mac is for capture only or lets say one week query using claude code. THe main consumer and interface is on nas. How to make it work so new changes won't messup my setup. Seems like I removed the whole folder it was working find runnign with @letest even I may be wrong.
Show more
10 May
Retry
Edit
Copy
Claude responded: Looking at this carefully, the error tells the whole story:
Claude responded: Looking at this carefully, the error tells the whole story:
Pinpointing schema mismatches from recent PII redaction migrations
Pinpointing schema mismatches from recent PII redaction migrations
Looking at this carefully, the error tells the whole story:
Copy to clipboard
table nas.frames has 24 columns but 30 values were supplied
Your source DB got 6 new columns from recent migrations, but the NAS archive was created earlier with the old schema.
CREATE TABLE IF NOT EXISTS
is a no-op when the table exists, so the NAS schema is frozen at whatever it was when first created. Then
INSERT ... SELECT *
does positional mapping, which blows up the moment column counts diverge.
Let me verify the recent screenpipe changes and then fix this properly.
Diagnosed schema drift, architected resilient migration strategy
Diagnosed schema drift, architected resilient migration strategy
What's happening
What's happening
The error is schema drift, not a screenpipe bug. Your source DB grew 6 columns from these migrations (most landed late April / early May with the PII work):
20260429000000 add evicted at to chunks
→
video_chunks.evicted_at...
|
[{"role":"AXLink","text":& [{"role":"AXLink","text":"Skip to content","depth":14,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Skip to content","depth":15,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Click to collapse","depth":16,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"⌘B","depth":16,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Drag to resize","depth":16,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Collapse sidebar","depth":15,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search","depth":15,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Chat","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cowork","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New chat ⌘N","depth":15,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"New chat","depth":16,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"⌘N","depth":17,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Projects","depth":15,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Artifacts","depth":15,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Customize","depth":15,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Pinned","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXButton","text":"Bulgarian citizenship application process for EU residents","depth":18,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for Bulgarian citizenship application process for EU residents","depth":19,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Dawarich location tracking project","depth":18,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for Dawarich location tracking project","depth":19,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Recents","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXButton","text":"View all","depth":16,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hubspot BadRequest headers debugging","depth":18,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for Hubspot BadRequest headers debugging","depth":19,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Screenpipe sync script failing after recent migrations","depth":18,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for Screenpipe sync script failing after recent migrations","depth":19,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Monthly expense tracking","depth":18,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for Monthly expense tracking","depth":19,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Exporting transaction data from Notion to finance hub","depth":18,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for Exporting transaction data from Notion to finance hub","depth":19,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"💬 How much have I spent for groc…","depth":18,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for 💬 How much have I spent for groc…","depth":19,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"April 2026 spending by category","depth":18,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for April 2026 spending by category","depth":19,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code diff review","depth":18,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for Code diff review","depth":19,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HubSpot rate limit implementation strategy","depth":18,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for HubSpot rate limit implementation strategy","depth":19,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Screenpipe retention policy code location","depth":18,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for Screenpipe retention policy code location","depth":19,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Viewing retention policy in screenpipe","depth":18,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for Viewing retention policy in screenpipe","depth":19,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Clean shot x video recording termination issue","depth":18,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for Clean shot x video recording termination issue","depth":19,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HubSpot rate limit handling with executeRequest","depth":18,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for HubSpot rate limit handling with executeRequest","depth":19,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Untitled","depth":18,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options","depth":19,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"💬 Screen pipe. Is there ability…","depth":18,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for 💬 Screen pipe. Is there ability…","depth":19,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"SMB mount access inconsistency between Finder and iTerm","depth":18,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for SMB mount access inconsistency between Finder and iTerm","depth":19,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"💬 What is the best switch I can…","depth":18,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for 💬 What is the best switch I can…","depth":19,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Permission denied on screenpipe volume","depth":18,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for Permission denied on screenpipe volume","depth":19,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Screenpipe sync database attachment error","depth":18,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for Screenpipe sync database attachment error","depth":19,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Last swimming outing with Dani","depth":18,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for Last swimming outing with Dani","depth":19,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Definition of incarcerated","depth":18,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for Definition of incarcerated","depth":19,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"Lukas Pro","depth":15,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Get apps and extensions","depth":15,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Screenpipe sync script failing after recent migrations, rename chat","depth":19,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Screenpipe sync script failing after recent migrations","depth":21,"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"More options for Screenpipe sync script failing after recent migrations","depth":19,"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open sidebar","depth":21,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Share chat","depth":21,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Claude finished the response","depth":21,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"You said: after recent updated in screenpipe (find out what are these) I am unable to run script.","depth":20,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"You said: after recent updated in screenpipe (find out what are these) I am unable to run script.","depth":21,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Pasted Text, pasted, 353 lines","depth":21,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"#!/bin/bash # screenpipe_sync.sh # Syncs Screenpipe SQLite data to a NAS archive database (append-only, no deletions). # Also copies the day's video/frame data folder to the NAS. # # Usage: # ./screenpipe_sync.sh # syncs yesterday (default) # ./screenpipe_sync.sh 2026-04-15 # sync","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PASTED","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"after recent updated in screenpipe (find out what are these) I am unable to run script. (pasted) \"lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07\n[2026-05-10 11:50:45] ========================================\n[2026-05-10 11:50:45] Screenpipe sync starting for: 2026-05-07\n[2026-05-10 11:50:45] ========================================\n[+00m00s] ▶ Preflight checks\n Source DB: OK (2.2G)\n NAS mount: OK /Volumes/screenpipe\n Archive DB: exists ( 10G)\n Data dir: OK (266 files, 292M)\n[+00m05s] ▶ Counting source rows for 2026-05-07\n frames: 6262\n elements: 623002\n ui_events: 7412\n ocr_text: 1670\n meetings: 2\n[+00m05s] ▶ Initialising tables, indexes, FTS\n creating tables ✓ 0m00s\n creating indexes ✓ 0m01s\n creating FTS tables ✓ 0m00s\n[+00m06s] ▶ Syncing data for 2026-05-07\n video_chunks ✓ 0m01s\n frames (6262 rows) ⠋ Parse error near line 3: table nas.frames has 24 columns but 30 values were supplied\" There were some recent changes in migrations. Here are migrations form the begining of march (approx after I installed irt first time) 20260301000000 create elements table 2026-05-06 17:27:34 True 736637f38c6e0b5547f23c870ebbc3e87ef2d8d33b22ce73f7 ... 1302167\n20260301100000 fts external content 2026-05-06 17:27:34 True 44ca0e5fc3b23c19aa09d7ac3fea48de604032d5feced2615c ... 2102875\n20260301200000 drop ui monitoring 2026-05-06 17:27:34 True 9ab8a4d8c0d602b491ef1a6ff36076fd7b7c12c05848201682 ... 620375\n20260306000000 delete empty transcriptions 2026-05-06 17:27:34 True 5f991a21d663157a2bce5cb9f0729f02181eef817aaef5a0b8 ... 166792\n20260309000000 add cloud blob id 2026-05-06 17:27:34 True e1588e32884ec5660d11bbaa995d767fb2172bb9732ad22319 ... 1450542\n20260310000000 create memories 2026-05-06 17:27:34 True 4fd07e878de1dd5b8d184e7bca9ee4e6b2480bbf39e5a68ff7 ... 1135416\n20260311000000 drop unused tables 2026-05-06 17:27:34 True 3d9eb9d327a61c4055b31e22082cd045e00bd7a875cbdee86b ... 547625\n20260312000000 consolidate search to frames full text 2026-05-06 17:27:34 True 5a7a31a359e9e93978d46ab4759fc8cd43898c0fd325d001b7 ... 3038250\n20260312000001 drop dead fts tables 2026-05-06 17:27:34 True dd8264b96b4427f40b06ac60b813b77b6d055b24dd727212c5 ... 297250\n20260312000002 drop accessibility tags 2026-05-06 17:27:34 True 672b2661f7e0fc8026f2eb6cc5d24935a15db4ed4982aeb973 ... 260167\n20260315000000 add frame id to memories 2026-05-06 17:27:34 True f324ec7981134e647b6497126a2b6a7467e94d271d140d0d25 ... 642250\n20260316000000 add elements activity summary index 2026-05-06 17:27:34 True 5b3f99a0d58fc73d62f240319d0718963364fdee1e3a7c4866 ... 265834\n20260317000000 add elements automation props 2026-05-06 17:27:34 True 4bd132d263de143c7bb0dcf2e3b8074606c58c0f79e6091d13 ... 537750\n20260318000000 add elements ref frame id 2026-05-06 17:27:34 True 33282b2c342e4743f096d1e3093146e243d97f392fe4df2cb5 ... 525250\n20260319000000 add sync id indexes 2026-05-06 17:27:34 True 22c7a18c918cfcc458f05fdbfe2a0b2bb65a67ae9daeec6028 ... 407083\n20260320000000 add note to meetings 2026-05-06 17:27:34 True cfa45b4c98e300c40cd36942839aa20528f47ae3e7b9c86751 ... 519625\n20260324000000 drop ocr text delete trigger 2026-05-06 17:27:34 True 99f445308168fc88f993c43f8e884cc4dc7e41411c86b4d3e7 ... 182209\n20260326000000 add session path to pipe executions 2026-05-06 17:27:34 True 5aa266dfcd7b741a18dd3ffb6b0ca3caf2e569959074cbc3ff ... 549583\n20260411000000 add elements ref frame id index 2026-05-06 17:27:34 True 378589322920e74980ea48c6b44c916cd488e47a8f6172161e ... 232083\n20260415000000 frames fts external content 2026-05-06 17:27:34 True 3fdac70da2fd44bca3c3717768b57c4cbee743451b73f13de9 ... 1259250\n20260423000000 add speaker id indexes 2026-05-06 17:27:34 True 80e8e0cf144ee620c81b3796196ca22b8e34f9c152ca2ffe8f ... 398709\n20260429000000 add evicted at to chunks 2026-05-06 17:27:34 True 180b1d44305df17620929a69ecce60fa3ce0148905adf19ff7 ... 963833\n20260502000000 add elements on screen 2026-05-06 17:27:34 True 2cf51a077a75ef32e4e1d722092053ce919bdeee151d3d91fb ... 644500\n20260504120000 add document path column 2026-05-06 17:27:34 True d84f7127e1647984eeb1f89e8dffd0918525f128e4608d323f ... 593042\n20260506120000 add memories sync columns 2026-05-07 06:45:22 True 800beaaa228c7fbd88fe367dc5b357adb72a3a0635da5a30f4 ... 1777417\n20260501000000 add redacted columns 2026-05-08 06:25:45 True 2a4cd6714a2322ccf8f2af6084af5a05e40f460c44c1aaf874 ... 795120667\n20260503000000 add frames image redacted 2026-05-08 06:25:45 True . It worked up to the point where I attemtped to remove it and install again since the retention policy doesn't clear old data and it was to big. I am basically moving data to nas and query it form there. mac is for capture only or lets say one week query using claude code. THe main consumer and interface is on nas. How to make it work so new changes won't messup my setup. Seems like I removed the whole folder it was working find runnign with @letest even I may be wrong.","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Show more","depth":22,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"10 May","depth":22,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Retry","depth":22,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Edit","depth":22,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Copy","depth":22,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"Claude responded: Looking at this carefully, the error tells the whole story:","depth":21,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Claude responded: Looking at this carefully, the error tells the whole story:","depth":22,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Pinpointing schema mismatches from recent PII redaction migrations","depth":22,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Pinpointing schema mismatches from recent PII redaction migrations","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Looking at this carefully, the error tells the whole story:","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy to clipboard","depth":27,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"table nas.frames has 24 columns but 30 values were supplied","depth":27,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Your source DB got 6 new columns from recent migrations, but the NAS archive was created earlier with the old schema.","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"CREATE TABLE IF NOT EXISTS","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is a no-op when the table exists, so the NAS schema is frozen at whatever it was when first created. Then","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"INSERT ... SELECT *","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"does positional mapping, which blows up the moment column counts diverge.","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Let me verify the recent screenpipe changes and then fix this properly.","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Diagnosed schema drift, architected resilient migration strategy","depth":22,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Diagnosed schema drift, architected resilient migration strategy","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"What's happening","depth":24,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"What's happening","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The error is schema drift, not a screenpipe bug. Your source DB grew 6 columns from these migrations (most landed late April / early May with the PII work):","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"20260429000000 add evicted at to chunks","depth":27,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"→","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"video_chunks.evicted_at","depth":27,"on_screen":false,"role_description":"text"}]...
|
-8747510796170426240
|
5048482103229465682
|
click
|
accessibility
|
NULL
|
Skip to content
Skip to content
Click to collapse
Skip to content
Skip to content
Click to collapse
⌘B
Drag to resize
Collapse sidebar
Search
Chat
Cowork
Code
New chat ⌘N
New chat
⌘N
Projects
Artifacts
Customize
Pinned
Bulgarian citizenship application process for EU residents
More options for Bulgarian citizenship application process for EU residents
Dawarich location tracking project
More options for Dawarich location tracking project
Recents
View all
Hubspot BadRequest headers debugging
More options for Hubspot BadRequest headers debugging
Screenpipe sync script failing after recent migrations
More options for Screenpipe sync script failing after recent migrations
Monthly expense tracking
More options for Monthly expense tracking
Exporting transaction data from Notion to finance hub
More options for Exporting transaction data from Notion to finance hub
💬 How much have I spent for groc…
More options for 💬 How much have I spent for groc…
April 2026 spending by category
More options for April 2026 spending by category
Code diff review
More options for Code diff review
HubSpot rate limit implementation strategy
More options for HubSpot rate limit implementation strategy
Screenpipe retention policy code location
More options for Screenpipe retention policy code location
Viewing retention policy in screenpipe
More options for Viewing retention policy in screenpipe
Clean shot x video recording termination issue
More options for Clean shot x video recording termination issue
HubSpot rate limit handling with executeRequest
More options for HubSpot rate limit handling with executeRequest
Untitled
More options
💬 Screen pipe. Is there ability…
More options for 💬 Screen pipe. Is there ability…
SMB mount access inconsistency between Finder and iTerm
More options for SMB mount access inconsistency between Finder and iTerm
💬 What is the best switch I can…
More options for 💬 What is the best switch I can…
Permission denied on screenpipe volume
More options for Permission denied on screenpipe volume
Screenpipe sync database attachment error
More options for Screenpipe sync database attachment error
Last swimming outing with Dani
More options for Last swimming outing with Dani
Definition of incarcerated
More options for Definition of incarcerated
Lukas Pro
Get apps and extensions
Screenpipe sync script failing after recent migrations, rename chat
Screenpipe sync script failing after recent migrations
More options for Screenpipe sync script failing after recent migrations
Open sidebar
Share chat
Claude finished the response
You said: after recent updated in screenpipe (find out what are these) I am unable to run script.
You said: after recent updated in screenpipe (find out what are these) I am unable to run script.
Pasted Text, pasted, 353 lines
#!/bin/bash # screenpipe_sync.sh # Syncs Screenpipe SQLite data to a NAS archive database (append-only, no deletions). # Also copies the day's video/frame data folder to the NAS. # # Usage: # ./screenpipe_sync.sh # syncs yesterday (default) # ./screenpipe_sync.sh 2026-04-15 # sync
PASTED
after recent updated in screenpipe (find out what are these) I am unable to run script. (pasted) "lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07
[2026-05-10 11:50:45] ========================================
[2026-05-10 11:50:45] Screenpipe sync starting for: 2026-05-07
[2026-05-10 11:50:45] ========================================
[+00m00s] ▶ Preflight checks
Source DB: OK (2.2G)
NAS mount: OK /Volumes/screenpipe
Archive DB: exists ( 10G)
Data dir: OK (266 files, 292M)
[+00m05s] ▶ Counting source rows for 2026-05-07
frames: 6262
elements: 623002
ui_events: 7412
ocr_text: 1670
meetings: 2
[+00m05s] ▶ Initialising tables, indexes, FTS
creating tables ✓ 0m00s
creating indexes ✓ 0m01s
creating FTS tables ✓ 0m00s
[+00m06s] ▶ Syncing data for 2026-05-07
video_chunks ✓ 0m01s
frames (6262 rows) ⠋ Parse error near line 3: table nas.frames has 24 columns but 30 values were supplied" There were some recent changes in migrations. Here are migrations form the begining of march (approx after I installed irt first time) 20260301000000 create elements table 2026-05-06 17:27:34 True 736637f38c6e0b5547f23c870ebbc3e87ef2d8d33b22ce73f7 ... 1302167
20260301100000 fts external content 2026-05-06 17:27:34 True 44ca0e5fc3b23c19aa09d7ac3fea48de604032d5feced2615c ... 2102875
20260301200000 drop ui monitoring 2026-05-06 17:27:34 True 9ab8a4d8c0d602b491ef1a6ff36076fd7b7c12c05848201682 ... 620375
20260306000000 delete empty transcriptions 2026-05-06 17:27:34 True 5f991a21d663157a2bce5cb9f0729f02181eef817aaef5a0b8 ... 166792
20260309000000 add cloud blob id 2026-05-06 17:27:34 True e1588e32884ec5660d11bbaa995d767fb2172bb9732ad22319 ... 1450542
20260310000000 create memories 2026-05-06 17:27:34 True 4fd07e878de1dd5b8d184e7bca9ee4e6b2480bbf39e5a68ff7 ... 1135416
20260311000000 drop unused tables 2026-05-06 17:27:34 True 3d9eb9d327a61c4055b31e22082cd045e00bd7a875cbdee86b ... 547625
20260312000000 consolidate search to frames full text 2026-05-06 17:27:34 True 5a7a31a359e9e93978d46ab4759fc8cd43898c0fd325d001b7 ... 3038250
20260312000001 drop dead fts tables 2026-05-06 17:27:34 True dd8264b96b4427f40b06ac60b813b77b6d055b24dd727212c5 ... 297250
20260312000002 drop accessibility tags 2026-05-06 17:27:34 True 672b2661f7e0fc8026f2eb6cc5d24935a15db4ed4982aeb973 ... 260167
20260315000000 add frame id to memories 2026-05-06 17:27:34 True f324ec7981134e647b6497126a2b6a7467e94d271d140d0d25 ... 642250
20260316000000 add elements activity summary index 2026-05-06 17:27:34 True 5b3f99a0d58fc73d62f240319d0718963364fdee1e3a7c4866 ... 265834
20260317000000 add elements automation props 2026-05-06 17:27:34 True 4bd132d263de143c7bb0dcf2e3b8074606c58c0f79e6091d13 ... 537750
20260318000000 add elements ref frame id 2026-05-06 17:27:34 True 33282b2c342e4743f096d1e3093146e243d97f392fe4df2cb5 ... 525250
20260319000000 add sync id indexes 2026-05-06 17:27:34 True 22c7a18c918cfcc458f05fdbfe2a0b2bb65a67ae9daeec6028 ... 407083
20260320000000 add note to meetings 2026-05-06 17:27:34 True cfa45b4c98e300c40cd36942839aa20528f47ae3e7b9c86751 ... 519625
20260324000000 drop ocr text delete trigger 2026-05-06 17:27:34 True 99f445308168fc88f993c43f8e884cc4dc7e41411c86b4d3e7 ... 182209
20260326000000 add session path to pipe executions 2026-05-06 17:27:34 True 5aa266dfcd7b741a18dd3ffb6b0ca3caf2e569959074cbc3ff ... 549583
20260411000000 add elements ref frame id index 2026-05-06 17:27:34 True 378589322920e74980ea48c6b44c916cd488e47a8f6172161e ... 232083
20260415000000 frames fts external content 2026-05-06 17:27:34 True 3fdac70da2fd44bca3c3717768b57c4cbee743451b73f13de9 ... 1259250
20260423000000 add speaker id indexes 2026-05-06 17:27:34 True 80e8e0cf144ee620c81b3796196ca22b8e34f9c152ca2ffe8f ... 398709
20260429000000 add evicted at to chunks 2026-05-06 17:27:34 True 180b1d44305df17620929a69ecce60fa3ce0148905adf19ff7 ... 963833
20260502000000 add elements on screen 2026-05-06 17:27:34 True 2cf51a077a75ef32e4e1d722092053ce919bdeee151d3d91fb ... 644500
20260504120000 add document path column 2026-05-06 17:27:34 True d84f7127e1647984eeb1f89e8dffd0918525f128e4608d323f ... 593042
20260506120000 add memories sync columns 2026-05-07 06:45:22 True 800beaaa228c7fbd88fe367dc5b357adb72a3a0635da5a30f4 ... 1777417
20260501000000 add redacted columns 2026-05-08 06:25:45 True 2a4cd6714a2322ccf8f2af6084af5a05e40f460c44c1aaf874 ... 795120667
20260503000000 add frames image redacted 2026-05-08 06:25:45 True . It worked up to the point where I attemtped to remove it and install again since the retention policy doesn't clear old data and it was to big. I am basically moving data to nas and query it form there. mac is for capture only or lets say one week query using claude code. THe main consumer and interface is on nas. How to make it work so new changes won't messup my setup. Seems like I removed the whole folder it was working find runnign with @letest even I may be wrong.
Show more
10 May
Retry
Edit
Copy
Claude responded: Looking at this carefully, the error tells the whole story:
Claude responded: Looking at this carefully, the error tells the whole story:
Pinpointing schema mismatches from recent PII redaction migrations
Pinpointing schema mismatches from recent PII redaction migrations
Looking at this carefully, the error tells the whole story:
Copy to clipboard
table nas.frames has 24 columns but 30 values were supplied
Your source DB got 6 new columns from recent migrations, but the NAS archive was created earlier with the old schema.
CREATE TABLE IF NOT EXISTS
is a no-op when the table exists, so the NAS schema is frozen at whatever it was when first created. Then
INSERT ... SELECT *
does positional mapping, which blows up the moment column counts diverge.
Let me verify the recent screenpipe changes and then fix this properly.
Diagnosed schema drift, architected resilient migration strategy
Diagnosed schema drift, architected resilient migration strategy
What's happening
What's happening
The error is schema drift, not a screenpipe bug. Your source DB grew 6 columns from these migrations (most landed late April / early May with the PII work):
20260429000000 add evicted at to chunks
→
video_chunks.evicted_at...
|
20741
|
NULL
|
NULL
|
NULL
|
|
20744
|
NULL
|
0
|
2026-05-11T16:15:01.953981+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-11/1778 /Users/lukas/.screenpipe/data/data/2026-05-11/1778516101953_m2.jpg...
|
Claude
|
Claude
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Skip to content
Skip to content
Click to collapse
Skip to content
Skip to content
Click to collapse
⌘B
Drag to resize
Collapse sidebar
Search
Chat
Cowork
Code
New chat ⌘N
New chat
⌘N
Projects
Artifacts
Customize
Pinned
Bulgarian citizenship application process for EU residents
More options for Bulgarian citizenship application process for EU residents
Dawarich location tracking project
More options for Dawarich location tracking project
Recents
View all
Hubspot BadRequest headers debugging
More options for Hubspot BadRequest headers debugging
Screenpipe sync script failing after recent migrations
More options for Screenpipe sync script failing after recent migrations
Monthly expense tracking
More options for Monthly expense tracking
Exporting transaction data from Notion to finance hub
More options for Exporting transaction data from Notion to finance hub
💬 How much have I spent for groc…
More options for 💬 How much have I spent for groc…
April 2026 spending by category
More options for April 2026 spending by category
Code diff review
More options for Code diff review
HubSpot rate limit implementation strategy
More options for HubSpot rate limit implementation strategy
Screenpipe retention policy code location
More options for Screenpipe retention policy code location
Viewing retention policy in screenpipe
More options for Viewing retention policy in screenpipe
Clean shot x video recording termination issue
More options for Clean shot x video recording termination issue
HubSpot rate limit handling with executeRequest
More options for HubSpot rate limit handling with executeRequest
Untitled
More options
💬 Screen pipe. Is there ability…
More options for 💬 Screen pipe. Is there ability…
SMB mount access inconsistency between Finder and iTerm
More options for SMB mount access inconsistency between Finder and iTerm
💬 What is the best switch I can…
More options for 💬 What is the best switch I can…
Permission denied on screenpipe volume
More options for Permission denied on screenpipe volume
Screenpipe sync database attachment error
More options for Screenpipe sync database attachment error
Last swimming outing with Dani
More options for Last swimming outing with Dani
Definition of incarcerated
More options for Definition of incarcerated
Lukas Pro
Get apps and extensions
Screenpipe sync script failing after recent migrations, rename chat
Screenpipe sync script failing after recent migrations
More options for Screenpipe sync script failing after recent migrations
Open sidebar
Share chat
Claude finished the response
You said: after recent updated in screenpipe (find out what are these) I am unable to run script.
You said: after recent updated in screenpipe (find out what are these) I am unable to run script.
Pasted Text, pasted, 353 lines
#!/bin/bash # screenpipe_sync.sh # Syncs Screenpipe SQLite data to a NAS archive database (append-only, no deletions). # Also copies the day's video/frame data folder to the NAS. # # Usage: # ./screenpipe_sync.sh # syncs yesterday (default) # ./screenpipe_sync.sh 2026-04-15 # sync
PASTED
after recent updated in screenpipe (find out what are these) I am unable to run script. (pasted) "lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07
[2026-05-10 11:50:45] ========================================
[2026-05-10 11:50:45] Screenpipe sync starting for: 2026-05-07
[2026-05-10 11:50:45] ========================================
[+00m00s] ▶ Preflight checks
Source DB: OK (2.2G)
NAS mount: OK /Volumes/screenpipe
Archive DB: exists ( 10G)
Data dir: OK (266 files, 292M)
[+00m05s] ▶ Counting source rows for 2026-05-07
frames: 6262
elements: 623002
ui_events: 7412
ocr_text: 1670
meetings: 2
[+00m05s] ▶ Initialising tables, indexes, FTS
creating tables ✓ 0m00s
creating indexes ✓ 0m01s
creating FTS tables ✓ 0m00s
[+00m06s] ▶ Syncing data for 2026-05-07
video_chunks ✓ 0m01s
frames (6262 rows) ⠋ Parse error near line 3: table nas.frames has 24 columns but 30 values were supplied" There were some recent changes in migrations. Here are migrations form the begining of march (approx after I installed irt first time) 20260301000000 create elements table 2026-05-06 17:27:34 True 736637f38c6e0b5547f23c870ebbc3e87ef2d8d33b22ce73f7 ... 1302167
20260301100000 fts external content 2026-05-06 17:27:34 True 44ca0e5fc3b23c19aa09d7ac3fea48de604032d5feced2615c ... 2102875
20260301200000 drop ui monitoring 2026-05-06 17:27:34 True 9ab8a4d8c0d602b491ef1a6ff36076fd7b7c12c05848201682 ... 620375
20260306000000 delete empty transcriptions 2026-05-06 17:27:34 True 5f991a21d663157a2bce5cb9f0729f02181eef817aaef5a0b8 ... 166792
20260309000000 add cloud blob id 2026-05-06 17:27:34 True e1588e32884ec5660d11bbaa995d767fb2172bb9732ad22319 ... 1450542
20260310000000 create memories 2026-05-06 17:27:34 True 4fd07e878de1dd5b8d184e7bca9ee4e6b2480bbf39e5a68ff7 ... 1135416
20260311000000 drop unused tables 2026-05-06 17:27:34 True 3d9eb9d327a61c4055b31e22082cd045e00bd7a875cbdee86b ... 547625
20260312000000 consolidate search to frames full text 2026-05-06 17:27:34 True 5a7a31a359e9e93978d46ab4759fc8cd43898c0fd325d001b7 ... 3038250
20260312000001 drop dead fts tables 2026-05-06 17:27:34 True dd8264b96b4427f40b06ac60b813b77b6d055b24dd727212c5 ... 297250
20260312000002 drop accessibility tags 2026-05-06 17:27:34 True 672b2661f7e0fc8026f2eb6cc5d24935a15db4ed4982aeb973 ... 260167
20260315000000 add frame id to memories 2026-05-06 17:27:34 True f324ec7981134e647b6497126a2b6a7467e94d271d140d0d25 ... 642250
20260316000000 add elements activity summary index 2026-05-06 17:27:34 True 5b3f99a0d58fc73d62f240319d0718963364fdee1e3a7c4866 ... 265834
20260317000000 add elements automation props 2026-05-06 17:27:34 True 4bd132d263de143c7bb0dcf2e3b8074606c58c0f79e6091d13 ... 537750
20260318000000 add elements ref frame id 2026-05-06 17:27:34 True 33282b2c342e4743f096d1e3093146e243d97f392fe4df2cb5 ... 525250
20260319000000 add sync id indexes 2026-05-06 17:27:34 True 22c7a18c918cfcc458f05fdbfe2a0b2bb65a67ae9daeec6028 ... 407083
20260320000000 add note to meetings 2026-05-06 17:27:34 True cfa45b4c98e300c40cd36942839aa20528f47ae3e7b9c86751 ... 519625
20260324000000 drop ocr text delete trigger 2026-05-06 17:27:34 True 99f445308168fc88f993c43f8e884cc4dc7e41411c86b4d3e7 ... 182209
20260326000000 add session path to pipe executions 2026-05-06 17:27:34 True 5aa266dfcd7b741a18dd3ffb6b0ca3caf2e569959074cbc3ff ... 549583
20260411000000 add elements ref frame id index 2026-05-06 17:27:34 True 378589322920e74980ea48c6b44c916cd488e47a8f6172161e ... 232083
20260415000000 frames fts external content 2026-05-06 17:27:34 True 3fdac70da2fd44bca3c3717768b57c4cbee743451b73f13de9 ... 1259250
20260423000000 add speaker id indexes 2026-05-06 17:27:34 True 80e8e0cf144ee620c81b3796196ca22b8e34f9c152ca2ffe8f ... 398709
20260429000000 add evicted at to chunks 2026-05-06 17:27:34 True 180b1d44305df17620929a69ecce60fa3ce0148905adf19ff7 ... 963833
20260502000000 add elements on screen 2026-05-06 17:27:34 True 2cf51a077a75ef32e4e1d722092053ce919bdeee151d3d91fb ... 644500
20260504120000 add document path column 2026-05-06 17:27:34 True d84f7127e1647984eeb1f89e8dffd0918525f128e4608d323f ... 593042
20260506120000 add memories sync columns 2026-05-07 06:45:22 True 800beaaa228c7fbd88fe367dc5b357adb72a3a0635da5a30f4 ... 1777417
20260501000000 add redacted columns 2026-05-08 06:25:45 True 2a4cd6714a2322ccf8f2af6084af5a05e40f460c44c1aaf874 ... 795120667
20260503000000 add frames image redacted 2026-05-08 06:25:45 True . It worked up to the point where I attemtped to remove it and install again since the retention policy doesn't clear old data and it was to big. I am basically moving data to nas and query it form there. mac is for capture only or lets say one week query using claude code. THe main consumer and interface is on nas. How to make it work so new changes won't messup my setup. Seems like I removed the whole folder it was working find runnign with @letest even I may be wrong.
Show more
10 May
Retry
Edit
Copy
Claude responded: Looking at this carefully, the error tells the whole story:
Claude responded: Looking at this carefully, the error tells the whole story:
Pinpointing schema mismatches from recent PII redaction migrations
Pinpointing schema mismatches from recent PII redaction migrations
Looking at this carefully, the error tells the whole story:
Copy to clipboard
table nas.frames has 24 columns but 30 values were supplied
Your source DB got 6 new columns from recent migrations, but the NAS archive was created earlier with the old schema.
CREATE TABLE IF NOT EXISTS
is a no-op when the table exists, so the NAS schema is frozen at whatever it was when first created. Then
INSERT ... SELECT *
does positional mapping, which blows up the moment column counts diverge.
Let me verify the recent screenpipe changes and then fix this properly.
Diagnosed schema drift, architected resilient migration strategy
Diagnosed schema drift, architected resilient migration strategy
What's happening
What's happening
The error is schema drift, not a screenpipe bug. Your source DB grew 6 columns from these migrations (most landed late April / early May with the PII work):
20260429000000 add evicted at to chunks
→
video_chunks.evicted_at
20260502000000 add elements on screen
→
elements.on_screen
20260504120000 add document path column
→
frames.document_path
20260501000000 add redacted columns
→ redacted columns on frames/ocr_text/elements (this is the big one — accompanies the new
screenpipe/pii-redactor
model)
20260503000000 add frames image redacted
→
frames.image_redacted_path
20260506120000 add memories sync columns
→ memories table
Your NAS
archive.db
was created before these landed.
CREATE TABLE IF NOT EXISTS
is a no-op on existing tables, so the NAS schema froze. Then
INSERT INTO nas.frames SELECT * FROM main.frames
does
positional
mapping → 30 source values into a 24-column NAS table → parse error.
You weren't wrong about
npx screenpipe@latest
working: removing the folder gave you a fresh local DB with the new schema, and querying still works because everything is on one schema. The mismatch only surfaces when you try to merge old + new schemas via the sync.
The fix
The fix
Two changes make this future-proof:
ensure_columns
— before each insert, diff source vs NAS columns and run
ALTER TABLE ADD COLUMN
for any new ones. SQLite handles this as a metadata-only op, so it's instant even on your 10G archive.
Explicit column lists in INSERT
— never
SELECT *
again. Build the column list dynamically from
PRAGMA table_info(<table>)
and use...
|
[{"role":"AXLink","text":& [{"role":"AXLink","text":"Skip to content","depth":14,"bounds":{"left":0.0023271276,"top":0.025538707,"width":0.0003324468,"height":0.0007980846},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Skip to content","depth":15,"bounds":{"left":0.0023271276,"top":0.026336791,"width":0.030917553,"height":0.013567438},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.0023271276,"top":0.027134877,"width":0.0026595744,"height":0.012769354}},{"char_start":1,"char_count":14,"bounds":{"left":0.004986702,"top":0.027134877,"width":0.02825798,"height":0.012769354}}],"role_description":"text"},{"role":"AXStaticText","text":"Click to collapse","depth":16,"bounds":{"left":0.10239362,"top":0.02952913,"width":0.030585106,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.10239362,"top":0.02952913,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":16,"bounds":{"left":0.10538564,"top":0.02952913,"width":0.027925532,"height":0.011971269}}],"role_description":"text"},{"role":"AXStaticText","text":"⌘B","depth":16,"bounds":{"left":0.1349734,"top":0.02952913,"width":0.0063164895,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Drag to resize","depth":16,"bounds":{"left":0.10239362,"top":0.0415004,"width":0.025930852,"height":0.011971269},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.10239362,"top":0.0415004,"width":0.0029920214,"height":0.011971269}},{"char_start":1,"char_count":13,"bounds":{"left":0.10538564,"top":0.0415004,"width":0.022938829,"height":0.011971269}}],"role_description":"text"},{"role":"AXButton","text":"Collapse sidebar","depth":15,"bounds":{"left":0.030585106,"top":0.02952913,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search","depth":15,"bounds":{"left":0.03856383,"top":0.02952913,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Chat","depth":16,"bounds":{"left":0.005984043,"top":0.06304868,"width":0.026263298,"height":0.022346368},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cowork","depth":16,"bounds":{"left":0.032579787,"top":0.06304868,"width":0.031914894,"height":0.022346368},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code","depth":16,"bounds":{"left":0.065159574,"top":0.06304868,"width":0.027260639,"height":0.022346368},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New chat ⌘N","depth":15,"bounds":{"left":0.005319149,"top":0.096568234,"width":0.087765954,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"New chat","depth":16,"bounds":{"left":0.015292553,"top":0.09976058,"width":0.019281914,"height":0.013567438},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.015292553,"top":0.10055866,"width":0.0033244682,"height":0.013567438}},{"char_start":1,"char_count":7,"bounds":{"left":0.01861702,"top":0.10055866,"width":0.015957447,"height":0.013567438}}],"role_description":"text"},{"role":"AXStaticText","text":"⌘N","depth":17,"bounds":{"left":0.084109046,"top":0.10055866,"width":0.006981383,"height":0.011971269},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Projects","depth":15,"bounds":{"left":0.005319149,"top":0.11731844,"width":0.087765954,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Artifacts","depth":15,"bounds":{"left":0.005319149,"top":0.13806863,"width":0.087765954,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Customize","depth":15,"bounds":{"left":0.005319149,"top":0.15881884,"width":0.087765954,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Pinned","depth":16,"bounds":{"left":0.00731383,"top":0.19872306,"width":0.08510638,"height":0.012769354},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXButton","text":"Bulgarian citizenship application process for EU residents","depth":18,"bounds":{"left":0.005319149,"top":0.21548285,"width":0.087765954,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for Bulgarian citizenship application process for EU residents","depth":19,"bounds":{"left":0.08577128,"top":0.21867518,"width":0.005984043,"height":0.014365523},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Dawarich location tracking project","depth":18,"bounds":{"left":0.005319149,"top":0.23703113,"width":0.087765954,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for Dawarich location tracking project","depth":19,"bounds":{"left":0.08577128,"top":0.24022347,"width":0.005984043,"height":0.014365523},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Recents","depth":16,"bounds":{"left":0.00731383,"top":0.26735833,"width":0.064494684,"height":0.012769354},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXButton","text":"View all","depth":16,"bounds":{"left":0.0731383,"top":0.26735833,"width":0.019281914,"height":0.012769354},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hubspot BadRequest headers debugging","depth":18,"bounds":{"left":0.005319149,"top":0.28411812,"width":0.087765954,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for Hubspot BadRequest headers debugging","depth":19,"bounds":{"left":0.08577128,"top":0.28731045,"width":0.005984043,"height":0.014365523},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Screenpipe sync script failing after recent migrations","depth":18,"bounds":{"left":0.005319149,"top":0.3056664,"width":0.087765954,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for Screenpipe sync script failing after recent migrations","depth":19,"bounds":{"left":0.08577128,"top":0.30885875,"width":0.005984043,"height":0.014365523},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Monthly expense tracking","depth":18,"bounds":{"left":0.005319149,"top":0.3272147,"width":0.087765954,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for Monthly expense tracking","depth":19,"bounds":{"left":0.08577128,"top":0.33040702,"width":0.005984043,"height":0.014365523},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Exporting transaction data from Notion to finance hub","depth":18,"bounds":{"left":0.005319149,"top":0.34876296,"width":0.087765954,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for Exporting transaction data from Notion to finance hub","depth":19,"bounds":{"left":0.08577128,"top":0.3519553,"width":0.005984043,"height":0.014365523},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"💬 How much have I spent for groc…","depth":18,"bounds":{"left":0.005319149,"top":0.37031126,"width":0.087765954,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for 💬 How much have I spent for groc…","depth":19,"bounds":{"left":0.08577128,"top":0.3735036,"width":0.005984043,"height":0.014365523},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"April 2026 spending by category","depth":18,"bounds":{"left":0.005319149,"top":0.39185953,"width":0.087765954,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for April 2026 spending by category","depth":19,"bounds":{"left":0.08577128,"top":0.39505187,"width":0.005984043,"height":0.014365523},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code diff review","depth":18,"bounds":{"left":0.005319149,"top":0.41340783,"width":0.087765954,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for Code diff review","depth":19,"bounds":{"left":0.08577128,"top":0.41660017,"width":0.005984043,"height":0.014365523},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HubSpot rate limit implementation strategy","depth":18,"bounds":{"left":0.005319149,"top":0.4349561,"width":0.087765954,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for HubSpot rate limit implementation strategy","depth":19,"bounds":{"left":0.08577128,"top":0.43814844,"width":0.005984043,"height":0.014365523},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Screenpipe retention policy code location","depth":18,"bounds":{"left":0.005319149,"top":0.45650437,"width":0.087765954,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for Screenpipe retention policy code location","depth":19,"bounds":{"left":0.08577128,"top":0.45969674,"width":0.005984043,"height":0.014365523},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Viewing retention policy in screenpipe","depth":18,"bounds":{"left":0.005319149,"top":0.47805268,"width":0.087765954,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for Viewing retention policy in screenpipe","depth":19,"bounds":{"left":0.08577128,"top":0.481245,"width":0.005984043,"height":0.014365523},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Clean shot x video recording termination issue","depth":18,"bounds":{"left":0.005319149,"top":0.49960095,"width":0.087765954,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for Clean shot x video recording termination issue","depth":19,"bounds":{"left":0.08577128,"top":0.5027933,"width":0.005984043,"height":0.014365523},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HubSpot rate limit handling with executeRequest","depth":18,"bounds":{"left":0.005319149,"top":0.5211492,"width":0.087765954,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for HubSpot rate limit handling with executeRequest","depth":19,"bounds":{"left":0.08577128,"top":0.5243416,"width":0.005984043,"height":0.014365523},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Untitled","depth":18,"bounds":{"left":0.005319149,"top":0.54269755,"width":0.087765954,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options","depth":19,"bounds":{"left":0.08577128,"top":0.54588985,"width":0.005984043,"height":0.014365523},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"💬 Screen pipe. Is there ability…","depth":18,"bounds":{"left":0.005319149,"top":0.5642458,"width":0.087765954,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for 💬 Screen pipe. Is there ability…","depth":19,"bounds":{"left":0.08577128,"top":0.5674381,"width":0.005984043,"height":0.014365523},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"SMB mount access inconsistency between Finder and iTerm","depth":18,"bounds":{"left":0.005319149,"top":0.5857941,"width":0.087765954,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for SMB mount access inconsistency between Finder and iTerm","depth":19,"bounds":{"left":0.08577128,"top":0.58898646,"width":0.005984043,"height":0.014365523},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"💬 What is the best switch I can…","depth":18,"bounds":{"left":0.005319149,"top":0.60734236,"width":0.087765954,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for 💬 What is the best switch I can…","depth":19,"bounds":{"left":0.08577128,"top":0.6105347,"width":0.005984043,"height":0.014365523},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Permission denied on screenpipe volume","depth":18,"bounds":{"left":0.005319149,"top":0.62889063,"width":0.087765954,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for Permission denied on screenpipe volume","depth":19,"bounds":{"left":0.08577128,"top":0.632083,"width":0.005984043,"height":0.014365523},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Screenpipe sync database attachment error","depth":18,"bounds":{"left":0.005319149,"top":0.65043896,"width":0.087765954,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for Screenpipe sync database attachment error","depth":19,"bounds":{"left":0.08577128,"top":0.65363127,"width":0.005984043,"height":0.014365523},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Last swimming outing with Dani","depth":18,"bounds":{"left":0.005319149,"top":0.67198724,"width":0.087765954,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for Last swimming outing with Dani","depth":19,"bounds":{"left":0.08577128,"top":0.67517954,"width":0.005984043,"height":0.014365523},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Definition of incarcerated","depth":18,"bounds":{"left":0.005319149,"top":0.6935355,"width":0.087765954,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More options for Definition of incarcerated","depth":19,"bounds":{"left":0.08577128,"top":0.6967279,"width":0.005984043,"height":0.014365523},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"Lukas Pro","depth":15,"bounds":{"left":0.005319149,"top":0.9696728,"width":0.038231384,"height":0.01915403},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Get apps and extensions","depth":15,"bounds":{"left":0.08510638,"top":0.9696728,"width":0.007978723,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Screenpipe sync script failing after recent migrations, rename chat","depth":19,"bounds":{"left":0.10239362,"top":0.02793296,"width":0.119015954,"height":0.022346368},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Screenpipe sync script failing after recent migrations","depth":21,"bounds":{"left":0.10372341,"top":0.031923383,"width":0.11635638,"height":0.014365523},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.10372341,"top":0.031923383,"width":0.0029920214,"height":0.014365523}},{"char_start":1,"char_count":53,"bounds":{"left":0.106715426,"top":0.031923383,"width":0.113696806,"height":0.014365523}}],"role_description":"text"},{"role":"AXPopUpButton","text":"More options for Screenpipe sync script failing after recent migrations","depth":19,"bounds":{"left":0.22174202,"top":0.02793296,"width":0.006981383,"height":0.022346368},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open sidebar","depth":21,"bounds":{"left":0.47340426,"top":0.026336791,"width":0.010638298,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Share chat","depth":21,"bounds":{"left":0.48537233,"top":0.026336791,"width":0.010638298,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Claude finished the response","depth":21,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"You said: after recent updated in screenpipe (find out what are these) I am unable to run script.","depth":20,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"You said: after recent updated in screenpipe (find out what are these) I am unable to run script.","depth":21,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Pasted Text, pasted, 353 lines","depth":21,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"#!/bin/bash # screenpipe_sync.sh # Syncs Screenpipe SQLite data to a NAS archive database (append-only, no deletions). # Also copies the day's video/frame data folder to the NAS. # # Usage: # ./screenpipe_sync.sh # syncs yesterday (default) # ./screenpipe_sync.sh 2026-04-15 # sync","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PASTED","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"after recent updated in screenpipe (find out what are these) I am unable to run script. (pasted) \"lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07\n[2026-05-10 11:50:45] ========================================\n[2026-05-10 11:50:45] Screenpipe sync starting for: 2026-05-07\n[2026-05-10 11:50:45] ========================================\n[+00m00s] ▶ Preflight checks\n Source DB: OK (2.2G)\n NAS mount: OK /Volumes/screenpipe\n Archive DB: exists ( 10G)\n Data dir: OK (266 files, 292M)\n[+00m05s] ▶ Counting source rows for 2026-05-07\n frames: 6262\n elements: 623002\n ui_events: 7412\n ocr_text: 1670\n meetings: 2\n[+00m05s] ▶ Initialising tables, indexes, FTS\n creating tables ✓ 0m00s\n creating indexes ✓ 0m01s\n creating FTS tables ✓ 0m00s\n[+00m06s] ▶ Syncing data for 2026-05-07\n video_chunks ✓ 0m01s\n frames (6262 rows) ⠋ Parse error near line 3: table nas.frames has 24 columns but 30 values were supplied\" There were some recent changes in migrations. Here are migrations form the begining of march (approx after I installed irt first time) 20260301000000 create elements table 2026-05-06 17:27:34 True 736637f38c6e0b5547f23c870ebbc3e87ef2d8d33b22ce73f7 ... 1302167\n20260301100000 fts external content 2026-05-06 17:27:34 True 44ca0e5fc3b23c19aa09d7ac3fea48de604032d5feced2615c ... 2102875\n20260301200000 drop ui monitoring 2026-05-06 17:27:34 True 9ab8a4d8c0d602b491ef1a6ff36076fd7b7c12c05848201682 ... 620375\n20260306000000 delete empty transcriptions 2026-05-06 17:27:34 True 5f991a21d663157a2bce5cb9f0729f02181eef817aaef5a0b8 ... 166792\n20260309000000 add cloud blob id 2026-05-06 17:27:34 True e1588e32884ec5660d11bbaa995d767fb2172bb9732ad22319 ... 1450542\n20260310000000 create memories 2026-05-06 17:27:34 True 4fd07e878de1dd5b8d184e7bca9ee4e6b2480bbf39e5a68ff7 ... 1135416\n20260311000000 drop unused tables 2026-05-06 17:27:34 True 3d9eb9d327a61c4055b31e22082cd045e00bd7a875cbdee86b ... 547625\n20260312000000 consolidate search to frames full text 2026-05-06 17:27:34 True 5a7a31a359e9e93978d46ab4759fc8cd43898c0fd325d001b7 ... 3038250\n20260312000001 drop dead fts tables 2026-05-06 17:27:34 True dd8264b96b4427f40b06ac60b813b77b6d055b24dd727212c5 ... 297250\n20260312000002 drop accessibility tags 2026-05-06 17:27:34 True 672b2661f7e0fc8026f2eb6cc5d24935a15db4ed4982aeb973 ... 260167\n20260315000000 add frame id to memories 2026-05-06 17:27:34 True f324ec7981134e647b6497126a2b6a7467e94d271d140d0d25 ... 642250\n20260316000000 add elements activity summary index 2026-05-06 17:27:34 True 5b3f99a0d58fc73d62f240319d0718963364fdee1e3a7c4866 ... 265834\n20260317000000 add elements automation props 2026-05-06 17:27:34 True 4bd132d263de143c7bb0dcf2e3b8074606c58c0f79e6091d13 ... 537750\n20260318000000 add elements ref frame id 2026-05-06 17:27:34 True 33282b2c342e4743f096d1e3093146e243d97f392fe4df2cb5 ... 525250\n20260319000000 add sync id indexes 2026-05-06 17:27:34 True 22c7a18c918cfcc458f05fdbfe2a0b2bb65a67ae9daeec6028 ... 407083\n20260320000000 add note to meetings 2026-05-06 17:27:34 True cfa45b4c98e300c40cd36942839aa20528f47ae3e7b9c86751 ... 519625\n20260324000000 drop ocr text delete trigger 2026-05-06 17:27:34 True 99f445308168fc88f993c43f8e884cc4dc7e41411c86b4d3e7 ... 182209\n20260326000000 add session path to pipe executions 2026-05-06 17:27:34 True 5aa266dfcd7b741a18dd3ffb6b0ca3caf2e569959074cbc3ff ... 549583\n20260411000000 add elements ref frame id index 2026-05-06 17:27:34 True 378589322920e74980ea48c6b44c916cd488e47a8f6172161e ... 232083\n20260415000000 frames fts external content 2026-05-06 17:27:34 True 3fdac70da2fd44bca3c3717768b57c4cbee743451b73f13de9 ... 1259250\n20260423000000 add speaker id indexes 2026-05-06 17:27:34 True 80e8e0cf144ee620c81b3796196ca22b8e34f9c152ca2ffe8f ... 398709\n20260429000000 add evicted at to chunks 2026-05-06 17:27:34 True 180b1d44305df17620929a69ecce60fa3ce0148905adf19ff7 ... 963833\n20260502000000 add elements on screen 2026-05-06 17:27:34 True 2cf51a077a75ef32e4e1d722092053ce919bdeee151d3d91fb ... 644500\n20260504120000 add document path column 2026-05-06 17:27:34 True d84f7127e1647984eeb1f89e8dffd0918525f128e4608d323f ... 593042\n20260506120000 add memories sync columns 2026-05-07 06:45:22 True 800beaaa228c7fbd88fe367dc5b357adb72a3a0635da5a30f4 ... 1777417\n20260501000000 add redacted columns 2026-05-08 06:25:45 True 2a4cd6714a2322ccf8f2af6084af5a05e40f460c44c1aaf874 ... 795120667\n20260503000000 add frames image redacted 2026-05-08 06:25:45 True . It worked up to the point where I attemtped to remove it and install again since the retention policy doesn't clear old data and it was to big. I am basically moving data to nas and query it form there. mac is for capture only or lets say one week query using claude code. THe main consumer and interface is on nas. How to make it work so new changes won't messup my setup. Seems like I removed the whole folder it was working find runnign with @letest even I may be wrong.","depth":24,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Show more","depth":22,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"10 May","depth":22,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Retry","depth":22,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Edit","depth":22,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Copy","depth":22,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"Claude responded: Looking at this carefully, the error tells the whole story:","depth":21,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"Claude responded: Looking at this carefully, the error tells the whole story:","depth":22,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Pinpointing schema mismatches from recent PII redaction migrations","depth":22,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Pinpointing schema mismatches from recent PII redaction migrations","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Looking at this carefully, the error tells the whole story:","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Copy to clipboard","depth":27,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"table nas.frames has 24 columns but 30 values were supplied","depth":27,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Your source DB got 6 new columns from recent migrations, but the NAS archive was created earlier with the old schema.","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"CREATE TABLE IF NOT EXISTS","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is a no-op when the table exists, so the NAS schema is frozen at whatever it was when first created. Then","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"INSERT ... SELECT *","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"does positional mapping, which blows up the moment column counts diverge.","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Let me verify the recent screenpipe changes and then fix this properly.","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Diagnosed schema drift, architected resilient migration strategy","depth":22,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Diagnosed schema drift, architected resilient migration strategy","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"What's happening","depth":24,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"What's happening","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"The error is schema drift, not a screenpipe bug. Your source DB grew 6 columns from these migrations (most landed late April / early May with the PII work):","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"20260429000000 add evicted at to chunks","depth":27,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"→","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"video_chunks.evicted_at","depth":27,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"20260502000000 add elements on screen","depth":27,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"→","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"elements.on_screen","depth":27,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"20260504120000 add document path column","depth":27,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"→","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"frames.document_path","depth":27,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"20260501000000 add redacted columns","depth":27,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"→ redacted columns on frames/ocr_text/elements (this is the big one — accompanies the new","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"screenpipe/pii-redactor","depth":27,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"model)","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"20260503000000 add frames image redacted","depth":27,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"→","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"frames.image_redacted_path","depth":27,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"20260506120000 add memories sync columns","depth":27,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"→ memories table","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Your NAS","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"archive.db","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"was created before these landed.","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"CREATE TABLE IF NOT EXISTS","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"is a no-op on existing tables, so the NAS schema froze. Then","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"INSERT INTO nas.frames SELECT * FROM main.frames","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"does","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"positional","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"mapping → 30 source values into a 24-column NAS table → parse error.","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"You weren't wrong about","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"npx screenpipe@latest","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"working: removing the folder gave you a fresh local DB with the new schema, and querying still works because everything is on one schema. The mismatch only surfaces when you try to merge old + new schemas via the sync.","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"The fix","depth":24,"on_screen":false,"role_description":"heading"},{"role":"AXStaticText","text":"The fix","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Two changes make this future-proof:","depth":25,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ensure_columns","depth":28,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"— before each insert, diff source vs NAS columns and run","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ALTER TABLE ADD COLUMN","depth":27,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"for any new ones. SQLite handles this as a metadata-only op, so it's instant even on your 10G archive.","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Explicit column lists in INSERT","depth":27,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"— never","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SELECT *","depth":27,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"again. Build the column list dynamically from","depth":26,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PRAGMA table_info(<table>)","depth":27,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"and use","depth":26,"on_screen":false,"role_description":"text"}]...
|
9066662467923524097
|
5048482103229531218
|
click
|
accessibility
|
NULL
|
Skip to content
Skip to content
Click to collapse
Skip to content
Skip to content
Click to collapse
⌘B
Drag to resize
Collapse sidebar
Search
Chat
Cowork
Code
New chat ⌘N
New chat
⌘N
Projects
Artifacts
Customize
Pinned
Bulgarian citizenship application process for EU residents
More options for Bulgarian citizenship application process for EU residents
Dawarich location tracking project
More options for Dawarich location tracking project
Recents
View all
Hubspot BadRequest headers debugging
More options for Hubspot BadRequest headers debugging
Screenpipe sync script failing after recent migrations
More options for Screenpipe sync script failing after recent migrations
Monthly expense tracking
More options for Monthly expense tracking
Exporting transaction data from Notion to finance hub
More options for Exporting transaction data from Notion to finance hub
💬 How much have I spent for groc…
More options for 💬 How much have I spent for groc…
April 2026 spending by category
More options for April 2026 spending by category
Code diff review
More options for Code diff review
HubSpot rate limit implementation strategy
More options for HubSpot rate limit implementation strategy
Screenpipe retention policy code location
More options for Screenpipe retention policy code location
Viewing retention policy in screenpipe
More options for Viewing retention policy in screenpipe
Clean shot x video recording termination issue
More options for Clean shot x video recording termination issue
HubSpot rate limit handling with executeRequest
More options for HubSpot rate limit handling with executeRequest
Untitled
More options
💬 Screen pipe. Is there ability…
More options for 💬 Screen pipe. Is there ability…
SMB mount access inconsistency between Finder and iTerm
More options for SMB mount access inconsistency between Finder and iTerm
💬 What is the best switch I can…
More options for 💬 What is the best switch I can…
Permission denied on screenpipe volume
More options for Permission denied on screenpipe volume
Screenpipe sync database attachment error
More options for Screenpipe sync database attachment error
Last swimming outing with Dani
More options for Last swimming outing with Dani
Definition of incarcerated
More options for Definition of incarcerated
Lukas Pro
Get apps and extensions
Screenpipe sync script failing after recent migrations, rename chat
Screenpipe sync script failing after recent migrations
More options for Screenpipe sync script failing after recent migrations
Open sidebar
Share chat
Claude finished the response
You said: after recent updated in screenpipe (find out what are these) I am unable to run script.
You said: after recent updated in screenpipe (find out what are these) I am unable to run script.
Pasted Text, pasted, 353 lines
#!/bin/bash # screenpipe_sync.sh # Syncs Screenpipe SQLite data to a NAS archive database (append-only, no deletions). # Also copies the day's video/frame data folder to the NAS. # # Usage: # ./screenpipe_sync.sh # syncs yesterday (default) # ./screenpipe_sync.sh 2026-04-15 # sync
PASTED
after recent updated in screenpipe (find out what are these) I am unable to run script. (pasted) "lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07
[2026-05-10 11:50:45] ========================================
[2026-05-10 11:50:45] Screenpipe sync starting for: 2026-05-07
[2026-05-10 11:50:45] ========================================
[+00m00s] ▶ Preflight checks
Source DB: OK (2.2G)
NAS mount: OK /Volumes/screenpipe
Archive DB: exists ( 10G)
Data dir: OK (266 files, 292M)
[+00m05s] ▶ Counting source rows for 2026-05-07
frames: 6262
elements: 623002
ui_events: 7412
ocr_text: 1670
meetings: 2
[+00m05s] ▶ Initialising tables, indexes, FTS
creating tables ✓ 0m00s
creating indexes ✓ 0m01s
creating FTS tables ✓ 0m00s
[+00m06s] ▶ Syncing data for 2026-05-07
video_chunks ✓ 0m01s
frames (6262 rows) ⠋ Parse error near line 3: table nas.frames has 24 columns but 30 values were supplied" There were some recent changes in migrations. Here are migrations form the begining of march (approx after I installed irt first time) 20260301000000 create elements table 2026-05-06 17:27:34 True 736637f38c6e0b5547f23c870ebbc3e87ef2d8d33b22ce73f7 ... 1302167
20260301100000 fts external content 2026-05-06 17:27:34 True 44ca0e5fc3b23c19aa09d7ac3fea48de604032d5feced2615c ... 2102875
20260301200000 drop ui monitoring 2026-05-06 17:27:34 True 9ab8a4d8c0d602b491ef1a6ff36076fd7b7c12c05848201682 ... 620375
20260306000000 delete empty transcriptions 2026-05-06 17:27:34 True 5f991a21d663157a2bce5cb9f0729f02181eef817aaef5a0b8 ... 166792
20260309000000 add cloud blob id 2026-05-06 17:27:34 True e1588e32884ec5660d11bbaa995d767fb2172bb9732ad22319 ... 1450542
20260310000000 create memories 2026-05-06 17:27:34 True 4fd07e878de1dd5b8d184e7bca9ee4e6b2480bbf39e5a68ff7 ... 1135416
20260311000000 drop unused tables 2026-05-06 17:27:34 True 3d9eb9d327a61c4055b31e22082cd045e00bd7a875cbdee86b ... 547625
20260312000000 consolidate search to frames full text 2026-05-06 17:27:34 True 5a7a31a359e9e93978d46ab4759fc8cd43898c0fd325d001b7 ... 3038250
20260312000001 drop dead fts tables 2026-05-06 17:27:34 True dd8264b96b4427f40b06ac60b813b77b6d055b24dd727212c5 ... 297250
20260312000002 drop accessibility tags 2026-05-06 17:27:34 True 672b2661f7e0fc8026f2eb6cc5d24935a15db4ed4982aeb973 ... 260167
20260315000000 add frame id to memories 2026-05-06 17:27:34 True f324ec7981134e647b6497126a2b6a7467e94d271d140d0d25 ... 642250
20260316000000 add elements activity summary index 2026-05-06 17:27:34 True 5b3f99a0d58fc73d62f240319d0718963364fdee1e3a7c4866 ... 265834
20260317000000 add elements automation props 2026-05-06 17:27:34 True 4bd132d263de143c7bb0dcf2e3b8074606c58c0f79e6091d13 ... 537750
20260318000000 add elements ref frame id 2026-05-06 17:27:34 True 33282b2c342e4743f096d1e3093146e243d97f392fe4df2cb5 ... 525250
20260319000000 add sync id indexes 2026-05-06 17:27:34 True 22c7a18c918cfcc458f05fdbfe2a0b2bb65a67ae9daeec6028 ... 407083
20260320000000 add note to meetings 2026-05-06 17:27:34 True cfa45b4c98e300c40cd36942839aa20528f47ae3e7b9c86751 ... 519625
20260324000000 drop ocr text delete trigger 2026-05-06 17:27:34 True 99f445308168fc88f993c43f8e884cc4dc7e41411c86b4d3e7 ... 182209
20260326000000 add session path to pipe executions 2026-05-06 17:27:34 True 5aa266dfcd7b741a18dd3ffb6b0ca3caf2e569959074cbc3ff ... 549583
20260411000000 add elements ref frame id index 2026-05-06 17:27:34 True 378589322920e74980ea48c6b44c916cd488e47a8f6172161e ... 232083
20260415000000 frames fts external content 2026-05-06 17:27:34 True 3fdac70da2fd44bca3c3717768b57c4cbee743451b73f13de9 ... 1259250
20260423000000 add speaker id indexes 2026-05-06 17:27:34 True 80e8e0cf144ee620c81b3796196ca22b8e34f9c152ca2ffe8f ... 398709
20260429000000 add evicted at to chunks 2026-05-06 17:27:34 True 180b1d44305df17620929a69ecce60fa3ce0148905adf19ff7 ... 963833
20260502000000 add elements on screen 2026-05-06 17:27:34 True 2cf51a077a75ef32e4e1d722092053ce919bdeee151d3d91fb ... 644500
20260504120000 add document path column 2026-05-06 17:27:34 True d84f7127e1647984eeb1f89e8dffd0918525f128e4608d323f ... 593042
20260506120000 add memories sync columns 2026-05-07 06:45:22 True 800beaaa228c7fbd88fe367dc5b357adb72a3a0635da5a30f4 ... 1777417
20260501000000 add redacted columns 2026-05-08 06:25:45 True 2a4cd6714a2322ccf8f2af6084af5a05e40f460c44c1aaf874 ... 795120667
20260503000000 add frames image redacted 2026-05-08 06:25:45 True . It worked up to the point where I attemtped to remove it and install again since the retention policy doesn't clear old data and it was to big. I am basically moving data to nas and query it form there. mac is for capture only or lets say one week query using claude code. THe main consumer and interface is on nas. How to make it work so new changes won't messup my setup. Seems like I removed the whole folder it was working find runnign with @letest even I may be wrong.
Show more
10 May
Retry
Edit
Copy
Claude responded: Looking at this carefully, the error tells the whole story:
Claude responded: Looking at this carefully, the error tells the whole story:
Pinpointing schema mismatches from recent PII redaction migrations
Pinpointing schema mismatches from recent PII redaction migrations
Looking at this carefully, the error tells the whole story:
Copy to clipboard
table nas.frames has 24 columns but 30 values were supplied
Your source DB got 6 new columns from recent migrations, but the NAS archive was created earlier with the old schema.
CREATE TABLE IF NOT EXISTS
is a no-op when the table exists, so the NAS schema is frozen at whatever it was when first created. Then
INSERT ... SELECT *
does positional mapping, which blows up the moment column counts diverge.
Let me verify the recent screenpipe changes and then fix this properly.
Diagnosed schema drift, architected resilient migration strategy
Diagnosed schema drift, architected resilient migration strategy
What's happening
What's happening
The error is schema drift, not a screenpipe bug. Your source DB grew 6 columns from these migrations (most landed late April / early May with the PII work):
20260429000000 add evicted at to chunks
→
video_chunks.evicted_at
20260502000000 add elements on screen
→
elements.on_screen
20260504120000 add document path column
→
frames.document_path
20260501000000 add redacted columns
→ redacted columns on frames/ocr_text/elements (this is the big one — accompanies the new
screenpipe/pii-redactor
model)
20260503000000 add frames image redacted
→
frames.image_redacted_path
20260506120000 add memories sync columns
→ memories table
Your NAS
archive.db
was created before these landed.
CREATE TABLE IF NOT EXISTS
is a no-op on existing tables, so the NAS schema froze. Then
INSERT INTO nas.frames SELECT * FROM main.frames
does
positional
mapping → 30 source values into a 24-column NAS table → parse error.
You weren't wrong about
npx screenpipe@latest
working: removing the folder gave you a fresh local DB with the new schema, and querying still works because everything is on one schema. The mismatch only surfaces when you try to merge old + new schemas via the sync.
The fix
The fix
Two changes make this future-proof:
ensure_columns
— before each insert, diff source vs NAS columns and run
ALTER TABLE ADD COLUMN
for any new ones. SQLite handles this as a metadata-only op, so it's instant even on your 10G archive.
Explicit column lists in INSERT
— never
SELECT *
again. Build the column list dynamically from
PRAGMA table_info(<table>)
and use...
|
NULL
|
NULL
|
NULL
|
NULL
|