Files
portal/app/tests/Unit/Supplier/SupplierPortalClientTest.php
T

289 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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/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(
'<html><body><form action="/login"><input id="loginform-username" name="LoginForm[username]"></form></body></html>',
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(
'<html><input id="loginform-username"></html>',
200,
['Content-Type' => 'text/html; charset=utf-8'],
)
->push(
'<html><input id="loginform-username"></html>',
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
});