Files
portal/app/tests/Unit/Supplier/SupplierPortalClientTest.php
T
Дмитрий e87b1385cf feat(supplier): verify rt-project-* contract live on crm.bp-gr.ru
Live discovery через Playwright MCP (Task 1):
- создан LIDPOTOK_TEST_DELETE_ME (B1+B2+B3) → 3 rt-проекта на портале;
- записаны сетевые запросы /admin/visit/rt-*;
- все три проекта удалены вручную, портал чист.

Endpoints (verified):
- POST /admin/visit/rt-project-save (create id:0, update id:N — same URL)
- POST /admin/visit/rt-project-delete (id строкой)
- GET  /admin/visit/rt-projects-load?src=none

Все три — application/json. Конверт ответа:
- success: HTTP 200 + {status:OK, message, result, id?:string}
- error:   HTTP 200 + {status:Error, message, result:null}
ID — строка (12721245), приводится к int (fits в int64).
Один save с B1+B2+B3 включёнными создаёт 3 rt-проекта — toPayload()
шлёт ровно один платформенный флаг (srcrt|srcbl|srcmt).

SupplierPortalClient:
- docblock переписан под verified контракт
- listProjects: путь /admin/visit/rt-projects-load + ?src=none query
- saveProject: путь /admin/visit/rt-project-save, asJson, парсинг id
- updateProject: тот же endpoint что save, id:N в body
- deleteProject: путь /admin/visit/rt-project-delete, asJson, id строкой
- new assertStatusOk() — HTTP 200 + status:Error → SupplierClientException
- toPayload(): полный Vuex-payload с маппингом DTO → portal:
  - platform B1/B2/B3 → srcrt/srcbl/srcmt (single-true)
  - signalType site/call/sms → type:hosts/calls/sms
  - workdays int[] → string[]
  - status active/paused → bool
  - + tag:_lidpotok, name/content из uniqueKey, defaults для show/depth/etc

Tests:
- new: tests/Feature/Supplier/SupplierPortalClientRtProjectTest.php (7 tests,
  contract: save+update+delete+list + 2 status:Error error-paths + B2/calls
  mapping)
- Sync/Cleanup/Unit тесты обновлены под новый URL + envelope shape.

Закрывает spec §1 honest-caveat «placeholder, не верифицирован»
и журнал решений запись 9. Регрессия: Pest 944/941/0 failed / 3 skipped
/ 2768 assertions / 59.2s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:55:05 +03:00

227 lines
8.2 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');
});