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([ // URL клиента — /admin/visit/rt-projects-load (см. SupplierPortalClient::listProjects); // старый паттерн без /visit/ не матчил → запрос уходил в неподходящий fallback-fake. 'crm.bp-gr.ru/admin/visit/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/visit/rt-project-save with full payload and returns external id', function (): void { Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( ['status' => 'OK', 'message' => '', 'result' => null, '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); // Verified live 2026-05-19 (Task 1 recon): тело Vuex-state c srcrt=true для B1. Http::assertSent(fn ($r): bool => $r->method() === 'POST' && str_ends_with($r->url(), '/admin/visit/rt-project-save') && ($r['name'] ?? null) === 'example.com' && ($r['type'] ?? null) === 'hosts' && ($r['srcrt'] ?? null) === true && ($r['id'] ?? null) === 0); }); test('updateProject POSTs to /admin/visit/rt-project-save with id + full payload', function (): void { Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( ['status' => 'OK', 'message' => '', 'result' => null, 'id' => '12345'], 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); // Update = тот же endpoint что save, но с id:N (verified 2026-05-19 recon). Http::assertSent(fn ($r): bool => $r->method() === 'POST' && str_ends_with($r->url(), '/admin/visit/rt-project-save') && ((int) ($r['id'] ?? 0)) === 12345 && ($r['type'] ?? null) === 'calls' && ($r['srcbl'] ?? null) === true); }); test('deleteProject POSTs to /admin/visit/rt-project-delete with id only', function (): void { Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-delete' => Http::response( ['status' => 'OK', 'message' => '', 'result' => null], 200, ), ]); app(SupplierPortalClient::class)->deleteProject(externalId: 12345); // ID идёт строкой в JSON-body (verified 2026-05-19 recon: {"id":"12345"}). Http::assertSent(fn ($r): bool => $r->method() === 'POST' && str_ends_with($r->url(), '/admin/visit/rt-project-delete') && ($r['id'] ?? null) === '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'); }); test('200 HTML login page triggers RefreshSupplierSessionJob sync and retries once', function (): void { Bus::fake([RefreshSupplierSessionJob::class]); Http::fakeSequence('crm.bp-gr.ru/*') ->push( '
', 200, ['Content-Type' => 'text/html; charset=utf-8'], ) ->push('{"projects":[]}', 200, ['Content-Type' => 'application/json']); app(SupplierPortalClient::class)->listProjects(); Bus::assertDispatchedSync(RefreshSupplierSessionJob::class); Http::assertSentCount(2); }); test('sticky HTML login page after retry throws SupplierAuthException', function (): void { Bus::fake([RefreshSupplierSessionJob::class]); Http::fakeSequence('crm.bp-gr.ru/*') ->push( '', 200, ['Content-Type' => 'text/html; charset=utf-8'], ) ->push( '', 200, ['Content-Type' => 'text/html; charset=utf-8'], ); expect(fn () => app(SupplierPortalClient::class)->listProjects()) ->toThrow(SupplierAuthException::class, 'Portal returned login page after refresh'); }); test('JSON response with substring "loginform-username" is NOT misclassified as login page', function (): void { Http::fake([ 'crm.bp-gr.ru/*' => Http::response( '{"projects":[{"name":"loginform-username is just a string here"}]}', 200, ['Content-Type' => 'application/json'], ), ]); $result = app(SupplierPortalClient::class)->listProjects(); expect($result)->toHaveCount(1); Http::assertSentCount(1); // no retry — JSON header skips login-detect }); test('200 response without Content-Type header is NOT detected as login page', function (): void { // Документирует контракт: пустой Content-Type → str_starts_with('','text/html') === false → детект пропускается. Http::fake([ 'crm.bp-gr.ru/*' => Http::response('{"projects":[]}', 200), // no Content-Type header ]); app(SupplierPortalClient::class)->listProjects(); Http::assertSentCount(1); // no retry — empty Content-Type fails the text/html gate });