e87b1385cf
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>
227 lines
8.2 KiB
PHP
227 lines
8.2 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/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');
|
||
});
|