Files
portal/app/tests/Unit/Supplier/SupplierPortalClientTest.php
T
Дмитрий a8a23cb269 fix(supplier): Plan 3 Task 4 code-review fixes (4 Important defense-in-depth)
Закрывает 4 Important issues из code-review Task 4 (a2c5374):
- #1 SupplierPortalClient: parse_url host validation → SupplierClientException
  вместо silent cookie skip + false-positive SupplierAuthException
- #2 dispatch_sync(RefreshSupplierSessionJob) обёрнут try/catch (request retry +
  loadSession) → raw exceptions translated в SupplierAuthException для
  consistency с error taxonomy перед Task 5 real Playwright impl
- #3 RefreshSupplierSessionJob stub handle() теперь throws LogicException
  с понятным сообщением (вместо silent no-op → confusing 'cache still empty'
  error). После Task 5 — LogicException заменяется real Playwright code.
  Снят final-модификатор класса (test override через container bind + Laravel
  dispatchSync serialization не работает с anonymous classes).
- #5 SupplierProjectDto::equals → canonical order для workdays/regions
  через sort в constructor (defense vs PG jsonb non-deterministic order).
  Без этого Task 6 SyncJob false-positive обнаруживал бы diff где его нет
  → unnecessary updateProject HTTP calls.

+3 tests в SupplierPortalClientTest (malformed url, 2 retry-translation paths)
+2 tests в новом SupplierProjectDtoTest (order-independent equals + non-equal)
+1 stub-класс ThrowingRefreshSupplierSessionJob (anonymous classes несовместимы
  с SerializesModels trait в dispatchSync).

Pest: 38/38 supplier-suite, 574/574 full suite (576 total, 2 skipped, +5 new
tests vs Task 4 baseline). PHPStan 0 errors. Pint clean.
2026-05-11 06:46:13 +03:00

204 lines
7.4 KiB
PHP

<?php
declare(strict_types=1);
use App\Exceptions\Supplier\SupplierAuthException;
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Services\Supplier\Dto\SupplierProjectDto;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
use Tests\Unit\Supplier\Stubs\ThrowingRefreshSupplierSessionJob;
uses(TestCase::class);
beforeEach(function (): void {
Cache::store('redis')->put('supplier:session', [
'phpsessid' => 'abc123sessid',
'csrf' => 'csrf-token-xyz',
'refreshed_at' => now()->toIso8601String(),
], now()->addHours(6));
config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
});
afterEach(function (): void {
Cache::store('redis')->forget('supplier:session');
});
test('listProjects attaches PHPSESSID cookie and X-CSRF-Token header', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/rt-projects-load*' => Http::response('[]', 200),
]);
app(SupplierPortalClient::class)->listProjects();
Http::assertSent(function ($request): bool {
$cookieHeader = $request->header('Cookie')[0] ?? '';
$csrfHeader = $request->header('X-CSRF-Token')[0] ?? '';
return str_contains($cookieHeader, 'PHPSESSID=abc123sessid')
&& $csrfHeader === 'csrf-token-xyz';
});
});
test('401 triggers RefreshSupplierSessionJob sync and retries once', function (): void {
Bus::fake([RefreshSupplierSessionJob::class]);
Http::fakeSequence('crm.bp-gr.ru/*')
->push('Unauthorized', 401)
->push('[]', 200);
app(SupplierPortalClient::class)->listProjects();
Bus::assertDispatchedSync(RefreshSupplierSessionJob::class);
Http::assertSentCount(2);
});
test('sticky 401 throws SupplierAuthException after one retry', function (): void {
Bus::fake([RefreshSupplierSessionJob::class]);
Http::fakeSequence('crm.bp-gr.ru/*')
->push('Unauthorized', 401)
->push('Still unauthorized', 401);
expect(fn () => app(SupplierPortalClient::class)->listProjects())
->toThrow(SupplierAuthException::class);
});
test('5xx response throws SupplierTransientException', function (): void {
Http::fake(['crm.bp-gr.ru/*' => Http::response('upstream gateway error', 503)]);
expect(fn () => app(SupplierPortalClient::class)->listProjects())
->toThrow(SupplierTransientException::class);
});
test('4xx not 401/403 throws SupplierClientException', function (): void {
Http::fake(['crm.bp-gr.ru/*' => Http::response('bad payload', 422)]);
expect(fn () => app(SupplierPortalClient::class)->listProjects())
->toThrow(SupplierClientException::class);
});
test('network error throws SupplierTransientException', function (): void {
Http::fake(function (): void {
throw new ConnectionException('Connection refused');
});
expect(fn () => app(SupplierPortalClient::class)->listProjects())
->toThrow(SupplierTransientException::class);
});
test('saveProject POSTs to /admin/rt-project-save with full payload and returns external id', function (): void {
Http::fake(['crm.bp-gr.ru/admin/rt-project-save' => Http::response(['id' => 99001], 200)]);
$dto = new SupplierProjectDto(
platform: 'B1',
signalType: 'site',
uniqueKey: 'example.com',
limit: 5,
workdays: [1, 2, 3, 4, 5],
regions: [],
regionsReverse: false,
status: 'active',
);
$id = app(SupplierPortalClient::class)->saveProject($dto);
expect($id)->toBe(99001);
Http::assertSent(fn ($r): bool => $r->method() === 'POST'
&& str_ends_with($r->url(), '/admin/rt-project-save')
&& ($r['platform'] ?? null) === 'B1');
});
test('updateProject POSTs to /admin/rt-project-update with id + full payload', function (): void {
Http::fake(['crm.bp-gr.ru/admin/rt-project-update' => Http::response('', 200)]);
$dto = new SupplierProjectDto(
platform: 'B2',
signalType: 'call',
uniqueKey: '79991234567',
limit: 10,
workdays: [1, 2, 3],
regions: [77],
regionsReverse: false,
status: 'active',
);
app(SupplierPortalClient::class)->updateProject(externalId: 12345, dto: $dto);
Http::assertSent(fn ($r): bool => $r->method() === 'POST'
&& str_ends_with($r->url(), '/admin/rt-project-update')
&& ((int) ($r['id'] ?? 0)) === 12345);
});
test('deleteProject POSTs to /admin/rt-project-delete with id only', function (): void {
Http::fake(['crm.bp-gr.ru/admin/rt-project-delete' => Http::response('', 200)]);
app(SupplierPortalClient::class)->deleteProject(externalId: 12345);
Http::assertSent(fn ($r): bool => $r->method() === 'POST'
&& str_ends_with($r->url(), '/admin/rt-project-delete')
&& ((int) ($r['id'] ?? 0)) === 12345);
});
test('malformed portal_url throws SupplierClientException, not auth path', function (): void {
config(['services.supplier.portal_url' => '/no-scheme-no-host']);
Http::fake(); // не должен быть вызван
expect(fn () => app(SupplierPortalClient::class)->listProjects())
->toThrow(SupplierClientException::class);
});
test('RefreshSupplierSessionJob throws during retry path translated to SupplierAuthException', function (): void {
// Initial session valid → first request goes through → 401 → triggers refresh sync
Http::fake(['crm.bp-gr.ru/*' => Http::response('Unauthorized', 401)]);
// Override container binding — simulates Task 5 Playwright failure
app()->bind(
RefreshSupplierSessionJob::class,
fn () => new ThrowingRefreshSupplierSessionJob('Simulated Playwright crash during retry'),
);
$caught = null;
try {
app(SupplierPortalClient::class)->listProjects();
} catch (SupplierAuthException $e) {
$caught = $e;
}
expect($caught)->toBeInstanceOf(SupplierAuthException::class);
// Message MUST mention "refresh failed" — proves translation, not sticky-401 path
expect($caught->getMessage())->toContain('Session refresh failed')
->and($caught->getPrevious())->toBeInstanceOf(RuntimeException::class)
->and($caught->getPrevious()?->getMessage())->toBe('Simulated Playwright crash during retry');
});
test('RefreshSupplierSessionJob throws during initial loadSession translated to SupplierAuthException', function (): void {
// Force loadSession path: clear cache so request() enters loadSession() refresh branch
Cache::store('redis')->forget('supplier:session');
app()->bind(
RefreshSupplierSessionJob::class,
fn () => new ThrowingRefreshSupplierSessionJob('Simulated Playwright crash during loadSession'),
);
$caught = null;
try {
app(SupplierPortalClient::class)->listProjects();
} catch (SupplierAuthException $e) {
$caught = $e;
}
expect($caught)->toBeInstanceOf(SupplierAuthException::class);
// Message MUST mention "loadSession" — proves translation from raw RuntimeException
expect($caught->getMessage())->toContain('loadSession')
->and($caught->getPrevious())->toBeInstanceOf(RuntimeException::class)
->and($caught->getPrevious()?->getMessage())->toBe('Simulated Playwright crash during loadSession');
});