Files
Investidor-V2/tests/Feature/SuiteCrm/SuiteCrmClientTest.php
T
nala 250ac35fd1
tests / ci (push) Canceled after 0s
first commit
2026-09-28 10:01:09 +02:00

280 lines
13 KiB
PHP

<?php
namespace Tests\Feature\SuiteCrm;
use App\Integrations\SuiteCrm\SuiteCrmClient;
use App\Integrations\SuiteCrm\SuiteCrmException;
use Carbon\CarbonInterval;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Sleep;
use Tests\TestCase;
class SuiteCrmClientTest extends TestCase
{
private SuiteCrmClient $client;
protected function setUp(): void
{
parent::setUp();
config(['suitecrm.enabled' => true, 'suitecrm.base_url' => 'https://crm.example.invalid/legacy',
'suitecrm.client_id' => 'test-client', 'suitecrm.client_secret' => 'secret-not-for-logs',
'suitecrm.cache_store' => 'array', 'suitecrm.retry_delay_ms' => 0]);
Cache::store('array')->flush();
Http::preventStrayRequests();
Log::spy();
Sleep::fake();
$this->client = new SuiteCrmClient;
}
private function tokenResponse(string $token = 'test-token'): array
{
return ['access_token' => $token, 'token_type' => 'Bearer', 'expires_in' => 3600];
}
private function tokenKey(): string
{
return 'suitecrm:'.hash('sha256', 'https://crm.example.invalid/legacy|test-client|secret-not-for-logs').':token';
}
public function test_disabled_client_and_insecure_configuration_never_send_requests(): void
{
Http::fake();
config(['suitecrm.enabled' => false]);
try {
$this->client->find('Contacts', 'contact-1');
$this->fail('Expected disabled client');
} catch (SuiteCrmException $e) {
$this->assertSame('disabled', $e->reason);
}
config(['suitecrm.enabled' => true]);
foreach (['http://crm.example.invalid', 'https://user:pass@crm.example.invalid', 'https://crm.example.invalid?token=secret'] as $url) {
config(['suitecrm.base_url' => $url]);
try {
$this->client->find('Contacts', 'contact-1');
$this->fail('Expected URL rejection');
} catch (SuiteCrmException $e) {
$this->assertSame('invalid_base_url', $e->reason);
}
}
Http::assertNothingSent();
}
public function test_authentication_contract_and_token_cache_are_encrypted(): void
{
Http::fake([
'*/Api/access_token' => Http::response($this->tokenResponse()),
'*/Api/V8/module/Contacts/contact-1' => Http::response(['data' => ['id' => 'contact-1', 'type' => 'Contacts']]),
]);
$this->client->find('Contacts', 'contact-1');
$this->client->find('Contacts', 'contact-1');
Http::assertSentCount(3);
Http::assertSent(fn (Request $r) => str_ends_with($r->url(), '/Api/access_token') && $r['grant_type'] === 'client_credentials'
&& $r['client_id'] === 'test-client' && $r['client_secret'] === 'secret-not-for-logs');
Http::assertSent(fn (Request $r) => str_contains($r->url(), '/Api/V8/') && $r->hasHeader('Authorization', 'Bearer test-token'));
$encrypted = Cache::store('array')->get($this->tokenKey());
$this->assertNotSame('test-token', $encrypted);
$this->assertSame('test-token', Crypt::decryptString($encrypted));
}
public function test_401_renews_once_and_replays_the_unauthorised_request(): void
{
Http::fake([
'*/Api/access_token' => Http::sequence()->push($this->tokenResponse('old'))->push($this->tokenResponse('new')),
'*/Api/V8/*' => Http::sequence()->push([], 401)->push(['data' => ['id' => 'contact-1']]),
]);
$result = $this->client->find('Contacts', 'contact-1');
$this->assertSame('contact-1', $result['data']['id']);
Http::assertSentCount(4);
Http::assertSent(fn (Request $r) => $r->hasHeader('Authorization', 'Bearer new'));
}
public function test_second_401_is_not_retried(): void
{
Http::fake([
'*/Api/access_token' => Http::response($this->tokenResponse()),
'*/Api/V8/*' => Http::response(['errors' => [['detail' => 'personal@example.invalid']]], 401),
]);
try {
$this->client->find('Contacts', 'contact-1');
$this->fail('Expected authentication failure');
} catch (SuiteCrmException $e) {
$this->assertSame('unauthenticated', $e->reason);
$this->assertFalse($e->retryable);
$this->assertStringNotContainsString('personal@', $e->getMessage());
$this->assertNull($e->getPrevious());
}
Http::assertSentCount(4);
}
public function test_json_api_create_update_relationship_and_exact_search_contracts(): void
{
Http::fake(['*/Api/access_token' => Http::response($this->tokenResponse()), '*' => Http::response(['data' => ['id' => 'record-1']])]);
$this->client->create('Accounts', ['name' => 'Example']);
$this->client->update('Accounts', 'account-1', ['name' => 'Revised']);
$this->client->relate('Accounts', 'account-1', 'contacts', 'Contacts', 'contact-1');
$this->client->search('Contacts', ['email1' => 'person@example.invalid'], 2, 10);
Http::assertSent(fn (Request $r) => $r->method() === 'POST' && str_ends_with($r->url(), '/V8/module')
&& $r['data'] === ['type' => 'Accounts', 'attributes' => ['name' => 'Example']]);
Http::assertSent(fn (Request $r) => $r->method() === 'PATCH' && $r['data']['id'] === 'account-1');
Http::assertSent(fn (Request $r) => str_ends_with($r->url(), '/Accounts/account-1/relationships/contacts') && $r['data'] === ['type' => 'Contacts', 'id' => 'contact-1']);
Http::assertSent(fn (Request $r) => $r->method() === 'GET' && $r['filter']['email1']['eq'] === 'person@example.invalid' && $r['page']['number'] === 2);
}
public function test_read_retries_transient_failures_but_create_does_not(): void
{
Http::fake(['*/Api/access_token' => Http::response($this->tokenResponse()),
'*/Api/V8/*' => Http::sequence()->push([], 503)->push([], 503)->push(['data' => []])->push([], 503)]);
$this->assertSame(['data' => []], $this->client->search('Contacts', []));
try {
$this->client->create('Leads', ['last_name' => 'Example']);
$this->fail('Expected uncertain write');
} catch (SuiteCrmException $e) {
$this->assertTrue($e->outcomeUnknown);
$this->assertSame(503, $e->httpStatus);
}
Http::assertSentCount(5);
}
public function test_write_timeout_is_not_replayed_and_transport_exception_is_not_retained(): void
{
Http::fake(['*/Api/access_token' => Http::response($this->tokenResponse()), '*/Api/V8/*' => Http::failedConnection('secret-not-for-logs')]);
try {
$this->client->create('Leads', ['last_name' => 'Private Name']);
$this->fail('Expected transport failure');
} catch (SuiteCrmException $e) {
$this->assertTrue($e->outcomeUnknown);
$this->assertSame('transport_failure', $e->reason);
$this->assertNull($e->getPrevious());
$this->assertStringNotContainsString('secret-not-for-logs', $e->getMessage());
}
Log::shouldHaveReceived('info')->with('suitecrm.http', \Mockery::on(fn ($context) => array_keys($context) === ['operation', 'status', 'duration_ms']))->twice();
}
public function test_retry_after_is_returned_without_immediate_retry_for_long_delays(): void
{
Http::fake(['*/Api/access_token' => Http::response($this->tokenResponse()), '*/Api/V8/*' => Http::response([], 429, ['Retry-After' => '120'])]);
try {
$this->client->search('Contacts', []);
$this->fail('Expected rate limit');
} catch (SuiteCrmException $e) {
$this->assertSame(120, $e->retryAfter);
$this->assertSame('rate_limited', $e->reason);
}
Http::assertSentCount(2);
}
public function test_circuit_opens_then_allows_recovery_after_cooldown(): void
{
config(['suitecrm.circuit_threshold' => 1, 'suitecrm.read_retries' => 0]);
Http::fake(['*/Api/access_token' => Http::response($this->tokenResponse()),
'*/Api/V8/*' => Http::sequence()->push([], 503)->push(['data' => []])]);
foreach (['unavailable', 'circuit_open'] as $expected) {
try {
$this->client->search('Contacts', []);
$this->fail('Expected circuit failure');
} catch (SuiteCrmException $e) {
$this->assertSame($expected, $e->reason);
}
}
Http::assertSentCount(2);
$this->travel(61)->seconds();
$this->assertSame(['data' => []], $this->client->search('Contacts', []));
Http::assertSentCount(3);
}
public function test_identifiers_modules_and_page_limits_are_rejected_before_network(): void
{
Http::fake();
foreach ([fn () => $this->client->find('Users', '1'), fn () => $this->client->find('Contacts', '../Accounts'),
fn () => $this->client->search('Contacts', [], 1, 1000)] as $operation) {
try {
$operation();
$this->fail('Expected rejection');
} catch (SuiteCrmException $e) {
$this->assertContains($e->reason, ['module_not_allowed', 'invalid_identifier', 'invalid_pagination']);
}
}
Http::assertNothingSent();
}
public function test_invalid_token_response_and_redirect_are_rejected(): void
{
Http::fake(['*/Api/access_token' => Http::sequence()->push(['access_token' => 'secret', 'expires_in' => 0])->push($this->tokenResponse()),
'*/Api/V8/*' => Http::response('', 302, ['Location' => 'https://untrusted.example.invalid'])]);
try {
$this->client->find('Contacts', 'contact-1');
$this->fail('Expected invalid token');
} catch (SuiteCrmException $e) {
$this->assertSame('invalid_token_response', $e->reason);
}
try {
$this->client->find('Contacts', 'contact-1');
$this->fail('Expected redirect rejection');
} catch (SuiteCrmException $e) {
$this->assertSame('unexpected_status', $e->reason);
$this->assertSame(302, $e->httpStatus);
}
}
public function test_token_expiration_and_credential_rotation_obtain_new_tokens(): void
{
Http::fake(['*/Api/access_token' => Http::sequence()->push($this->tokenResponse('first'))
->push($this->tokenResponse('renewed'))->push($this->tokenResponse('rotated')),
'*/Api/V8/*' => Http::response(['data' => []])]);
$this->client->search('Contacts', []);
$this->travel(3601)->seconds();
$this->client->search('Contacts', []);
config(['suitecrm.client_secret' => 'rotated-secret']);
$this->client->search('Contacts', []);
Http::assertSentCount(6);
Http::assertSent(fn (Request $r) => $r->hasHeader('Authorization', 'Bearer rotated'));
}
public function test_forbidden_and_invalid_input_responses_are_permanent_and_sanitised(): void
{
Http::fake(['*/Api/access_token' => Http::response($this->tokenResponse()),
'*/Api/V8/*' => Http::sequence()->push(['errors' => [['detail' => 'private@example.invalid']]], 403)
->push(['errors' => [['detail' => 'Private Name']]], 422)]);
foreach (['forbidden', 'validation_failed'] as $reason) {
try {
$this->client->create('Contacts', ['last_name' => 'Private Name']);
$this->fail('Expected permanent failure');
} catch (SuiteCrmException $e) {
$this->assertSame($reason, $e->reason);
$this->assertFalse($e->retryable);
$this->assertFalse($e->outcomeUnknown);
$this->assertNull($e->getPrevious());
}
}
Http::assertSentCount(3);
}
public function test_a_malformed_successful_write_is_treated_as_ambiguous(): void
{
Http::fake(['*/Api/access_token' => Http::response($this->tokenResponse()), '*/Api/V8/*' => Http::response('<html>Private data</html>', 200)]);
try {
$this->client->create('Contacts', ['last_name' => 'Private Name']);
$this->fail('Expected invalid response');
} catch (SuiteCrmException $e) {
$this->assertSame('invalid_response', $e->reason);
$this->assertTrue($e->outcomeUnknown);
}
Http::assertSentCount(2);
}
public function test_retry_after_short_delay_is_respected_and_logs_have_no_payload(): void
{
Http::fake(['*/Api/access_token' => Http::response($this->tokenResponse()),
'*/Api/V8/*' => Http::sequence()->push([], 429, ['Retry-After' => '2'])->push(['data' => []])]);
$this->client->search('Contacts', ['email1' => 'private@example.invalid']);
Sleep::assertSlept(function (CarbonInterval $duration) {
return $duration->totalMilliseconds >= 2000;
});
Log::shouldHaveReceived('info')->with('suitecrm.http', \Mockery::on(fn ($context) => array_keys($context) === ['operation', 'status', 'duration_ms']))->times(3);
}
}