a8a23cb269
Закрывает 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.
204 lines
7.4 KiB
PHP
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');
|
|
});
|