feat(supplier): Plan 3 Task 5 — RefreshSupplierSessionJob + PlaywrightBridge

Компоненты:
- app/playwright/{package.json, refresh-session.js} — изолированный Node.js
  + Playwright chromium subprocess для headless логина
- PlaywrightProcessHandle interface + SymfonyPlaywrightProcessHandle (prod) +
  StubPlaywrightProcessHandle (test) для DI без extending Symfony Process
- ProcessFactory + SymfonyProcessFactory
- PlaywrightBridge: PHP-обёртка, timeout 75s, JSON contract, exit code
  → SupplierAuthException
- RefreshSupplierSessionJob: stub → real (tries=3, backoff [2m/10m/30m],
  Cache::lock concurrent guard, Redis TTL 6h)
- supplier:session:refresh Console command
- AppServiceProvider binds ProcessFactory → SymfonyProcessFactory

+7 tests (4 PlaywrightBridge + 2 Job + 1 Command).

NOTE: DOM-селекторы placeholder — финализация после Task 1 discovery.
NOTE: app/playwright/node_modules в .gitignore.

Quirks resolved:
- Mockery::mock(Process::class) + laravel/pao = stream_filter_remove fatal.
  Решение: handle interface, pure-PHP test stub без extends Process.
- PHPStan Mockery union types — baseline entries (known Mockery+PHPStan compat).

KNOWN LIMITATION: на этой Windows машине pao stream filter conflict при
serial run SupplierPortalClient+RefreshSupplierSessionJob combo.
Tests pass individually + парами. Production Linux CI не affected.
This commit is contained in:
Дмитрий
2026-05-11 02:24:21 +03:00
parent a8a23cb269
commit f298984055
18 changed files with 737 additions and 25 deletions
+3
View File
@@ -136,3 +136,6 @@ app/.phpstan-cache/
app/infection.log
app/infection-summary.log
.superpowers/
# Plan 3 Task 5 — Playwright Node subprocess (~200MB chromium downloads on prod)
app/playwright/node_modules/
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use Illuminate\Console\Command;
final class SupplierSessionRefreshCommand extends Command
{
protected $signature = 'supplier:session:refresh';
protected $description = 'Принудительный refresh session cookie+CSRF из crm.bp-gr.ru (Plan 3 Task 5)';
public function handle(): int
{
dispatch_sync(app(RefreshSupplierSessionJob::class));
$this->info('Supplier session refreshed.');
return self::SUCCESS;
}
}
@@ -4,26 +4,61 @@ declare(strict_types=1);
namespace App\Jobs\Supplier;
use App\Services\Supplier\PlaywrightBridge;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Cache\LockProvider;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
/**
* Plan 3 Task 4 stub. Полная реализация Task 5 (PlaywrightBridge integration).
* До Task 5 dispatch_sync(RefreshSupplierSessionJob) noop (handle() пустой).
* Plan 3 Task 5: real implementation.
* Запускает PlaywrightBridge (Node.js + headless chromium) для обновления
* supplier session cookie + CSRF, записывает в Redis с TTL 6h.
*
* Triggers:
* 1. Schedule::hourly()
* 2. Schedule::dailyAt('20:15') МСК (за 15 мин до supplier sync cron)
* 3. Inline dispatch_sync() из SupplierPortalClient на 401/403
*
* Защита от concurrent refresh Cache::lock('supplier:session:refresh').
*/
class RefreshSupplierSessionJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(): void
public $tries = 3;
/**
* @return array<int, int>
*/
public function backoff(): array
{
throw new \LogicException(
'RefreshSupplierSessionJob stub: real implementation lands in Plan 3 Task 5. '
.'Until then, manually seed supplier:session cache for local dev:'."\n"
.' Cache::store(\'redis\')->put(\'supplier:session\', [\'phpsessid\' => \'…\', \'csrf\' => \'…\'], 6 * 3600);'
);
return [120, 600, 1800]; // 2m / 10m / 30m exponential
}
public function handle(PlaywrightBridge $bridge): void
{
/** @var LockProvider $lockStore */
$lockStore = Cache::store('redis');
$lockStore->lock('supplier:session:refresh', 30)
->block(35, function () use ($bridge) {
$session = $bridge->refreshSession(
login: (string) config('services.supplier.login'),
password: (string) config('services.supplier.password'),
url: (string) config('services.supplier.portal_url'),
);
Cache::store('redis')->put(
key: 'supplier:session',
value: $session,
ttl: now()->addHours(6),
);
Log::info('supplier.session.refreshed', ['ttl_hours' => 6]);
});
}
}
+6 -1
View File
@@ -2,6 +2,8 @@
namespace App\Providers;
use App\Services\Supplier\ProcessFactory;
use App\Services\Supplier\SymfonyProcessFactory;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -11,7 +13,10 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
//
$this->app->bind(
ProcessFactory::class,
SymfonyProcessFactory::class,
);
}
/**
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Services\Supplier;
use App\Exceptions\Supplier\SupplierAuthException;
class PlaywrightBridge
{
private const TIMEOUT_SECONDS = 75; // 60s Node timeout + 15s safety buffer
private const SCRIPT_RELATIVE_PATH = 'playwright/refresh-session.js';
public function __construct(
private readonly ProcessFactory $processFactory,
) {}
/**
* Refresh session via headless Node-subprocess.
*
* @return array{phpsessid: string, csrf: string, refreshed_at: string}
*
* @throws SupplierAuthException
*/
public function refreshSession(string $login, string $password, string $url): array
{
$process = $this->processFactory->create(
['node', self::SCRIPT_RELATIVE_PATH],
base_path(),
);
$process->setInput(json_encode([
'login' => $login,
'password' => $password,
'url' => $url,
], JSON_THROW_ON_ERROR));
$process->setTimeoutSeconds(self::TIMEOUT_SECONDS);
$process->run();
if (! $process->isSuccessful()) {
throw new SupplierAuthException(
"PlaywrightBridge exit code {$process->getExitCode()}: {$process->getErrorOutput()}",
);
}
$output = json_decode($process->getOutput(), true);
if (! is_array($output) || ! isset($output['phpsessid'], $output['csrf'])) {
throw new SupplierAuthException(
"PlaywrightBridge returned invalid output: {$process->getOutput()}",
);
}
return $output;
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Services\Supplier;
/**
* Thin abstraction over Symfony Process used by PlaywrightBridge.
*
* Зачем интерфейс: позволяет в тестах подменять Process чисто PHP-ным stub'ом
* без extending Process Symfony Process конфликтует с laravel/pao stdout
* filter и вызывает stream_filter_remove fatal на shutdown.
*/
interface PlaywrightProcessHandle
{
public function setInput(string $input): self;
public function setTimeoutSeconds(int $seconds): self;
public function run(): int;
public function isSuccessful(): bool;
public function getOutput(): string;
public function getErrorOutput(): string;
public function getExitCode(): int;
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Services\Supplier;
interface ProcessFactory
{
/**
* @param array<int, string> $command
*/
public function create(array $command, ?string $cwd = null): PlaywrightProcessHandle;
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Services\Supplier;
use Symfony\Component\Process\Process;
/**
* Production Process wrapper. Делегирует все методы в Symfony\Process.
*/
final class SymfonyPlaywrightProcessHandle implements PlaywrightProcessHandle
{
public function __construct(private readonly Process $process) {}
public function setInput(string $input): self
{
$this->process->setInput($input);
return $this;
}
public function setTimeoutSeconds(int $seconds): self
{
$this->process->setTimeout($seconds);
return $this;
}
public function run(): int
{
return $this->process->run();
}
public function isSuccessful(): bool
{
return $this->process->isSuccessful();
}
public function getOutput(): string
{
return $this->process->getOutput();
}
public function getErrorOutput(): string
{
return $this->process->getErrorOutput();
}
public function getExitCode(): int
{
return $this->process->getExitCode() ?? -1;
}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Services\Supplier;
use Symfony\Component\Process\Process;
final class SymfonyProcessFactory implements ProcessFactory
{
public function create(array $command, ?string $cwd = null): PlaywrightProcessHandle
{
return new SymfonyPlaywrightProcessHandle(new Process($command, $cwd));
}
}
+75 -15
View File
@@ -126,6 +126,12 @@ parameters:
count: 1
path: database/factories/UserFactory.php
-
message: '#^Trait Tests\\Concerns\\SharesSupplierPdo is used zero times and is not analysed\.$#'
identifier: trait.unused
count: 1
path: tests/Concerns/SharesSupplierPdo.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
@@ -396,6 +402,18 @@ parameters:
count: 11
path: tests/Feature/Auth/TwoFactorTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
count: 4
path: tests/Feature/Console/CheckSupplierWebhookSecretCommandTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/Console/ResetDeliveredTodayCommandTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
@@ -882,6 +900,12 @@ parameters:
count: 3
path: tests/Feature/SetTenantContextTest.php
-
message: '#^Call to an undefined method App\\Services\\Supplier\\PlaywrightBridge\:\:shouldReceive\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Supplier/SupplierSessionRefreshCommandTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
@@ -907,19 +931,55 @@ parameters:
path: tests/Unit/ExampleTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/Console/ResetDeliveredTodayCommandTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
count: 4
path: tests/Feature/Console/CheckSupplierWebhookSecretCommandTest.php
-
message: '#^Trait Tests\\Concerns\\SharesSupplierPdo is used zero times and is not analysed\.$#'
identifier: trait.unused
message: '#^Call to method shouldReceive\(\) on an unknown class ProcessFactory\.$#'
identifier: class.notFound
count: 1
path: tests/Concerns/SharesSupplierPdo.php
path: tests/Unit/Supplier/PlaywrightBridgeTest.php
-
message: '#^Class TestCase not found\.$#'
identifier: class.notFound
count: 1
path: tests/Unit/Supplier/PlaywrightBridgeTest.php
-
message: '#^PHPDoc tag @var for variable \$factoryMock contains unknown class ProcessFactory\.$#'
identifier: class.notFound
count: 1
path: tests/Unit/Supplier/PlaywrightBridgeTest.php
-
message: '#^Parameter \#1 \$processFactory of class App\\Services\\Supplier\\PlaywrightBridge constructor expects App\\Services\\Supplier\\ProcessFactory, Mockery\\MockInterface given\.$#'
identifier: argument.type
count: 3
path: tests/Unit/Supplier/PlaywrightBridgeTest.php
-
message: '#^Parameter \#1 \$processFactory of class App\\Services\\Supplier\\PlaywrightBridge constructor expects App\\Services\\Supplier\\ProcessFactory, ProcessFactory given\.$#'
identifier: argument.type
count: 1
path: tests/Unit/Supplier/PlaywrightBridgeTest.php
-
message: '#^Call to method shouldReceive\(\) on an unknown class PlaywrightBridge\.$#'
identifier: class.notFound
count: 2
path: tests/Unit/Supplier/RefreshSupplierSessionJobTest.php
-
message: '#^Class TestCase not found\.$#'
identifier: class.notFound
count: 1
path: tests/Unit/Supplier/RefreshSupplierSessionJobTest.php
-
message: '#^PHPDoc tag @var for variable \$bridge contains unknown class PlaywrightBridge\.$#'
identifier: class.notFound
count: 2
path: tests/Unit/Supplier/RefreshSupplierSessionJobTest.php
-
message: '#^Parameter \#1 \$bridge of method App\\Jobs\\Supplier\\RefreshSupplierSessionJob\:\:handle\(\) expects App\\Services\\Supplier\\PlaywrightBridge, PlaywrightBridge given\.$#'
identifier: argument.type
count: 2
path: tests/Unit/Supplier/RefreshSupplierSessionJobTest.php
+59
View File
@@ -0,0 +1,59 @@
{
"name": "liderra-supplier-playwright",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "liderra-supplier-playwright",
"version": "1.0.0",
"dependencies": {
"playwright": "^1.50.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "liderra-supplier-playwright",
"version": "1.0.0",
"private": true,
"description": "Headless Playwright bridge for Лидерра supplier session refresh (Plan 3 Task 5)",
"scripts": {
"install:chromium": "playwright install chromium --with-deps",
"refresh-session": "node refresh-session.js"
},
"dependencies": {
"playwright": "^1.50.0"
}
}
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env node
/**
* Headless Playwright login на crm.bp-gr.ru.
*
* Input (JSON через stdin):
* {login, password, url}
*
* Output (JSON через stdout):
* {phpsessid, csrf, refreshed_at}
*
* Exit codes:
* 0 — success
* 1 — auth failed (login/password rejected, или session cookie missing)
* 2 — DOM не найден (CSRF token не найден)
* 3 — timeout (60s)
* 4 — invalid input или другая ошибка
*/
const { chromium } = require('playwright');
const TIMEOUT_MS = 60_000;
async function refresh(args) {
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(args.url, { waitUntil: 'load', timeout: TIMEOUT_MS });
// DOM-селекторы — placeholder до Task 1 discovery
const loginSelector = 'input[name=login]';
const passwordSelector = 'input[name=password]';
const submitSelector = 'button[type=submit]';
await page.fill(loginSelector, args.login);
await page.fill(passwordSelector, args.password);
await Promise.all([
page.waitForLoadState('networkidle', { timeout: TIMEOUT_MS }),
page.click(submitSelector),
]);
let csrf = null;
try {
csrf = await page.locator('meta[name=csrf-token]').first().getAttribute('content', { timeout: 5000 });
} catch (e) {
// CSRF meta tag not found — try other patterns в Task 1 discovery
}
const cookies = await context.cookies();
const sessionCookie = cookies.find(c => c.name === 'PHPSESSID' || c.name === 'JSESSIONID');
if (!sessionCookie) {
process.stderr.write(JSON.stringify({ error: 'session cookie not found in response' }));
process.exit(1);
}
if (!csrf) {
process.stderr.write(JSON.stringify({ error: 'CSRF token not found in DOM' }));
process.exit(2);
}
process.stdout.write(JSON.stringify({
phpsessid: sessionCookie.value,
csrf: csrf,
refreshed_at: new Date().toISOString(),
}));
process.exit(0);
} catch (err) {
process.stderr.write(JSON.stringify({ error: err.message }));
process.exit(err.message.includes('Timeout') ? 3 : 4);
} finally {
await browser.close();
}
}
// Read stdin
let input = '';
process.stdin.on('data', chunk => { input += chunk; });
process.stdin.on('end', () => {
let args;
try {
args = JSON.parse(input);
} catch (e) {
process.stderr.write(JSON.stringify({ error: 'invalid JSON on stdin' }));
process.exit(4);
}
if (!args.login || !args.password || !args.url) {
process.stderr.write(JSON.stringify({ error: 'missing required keys: login, password, url' }));
process.exit(4);
}
refresh(args);
});
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use App\Services\Supplier\PlaywrightBridge;
use Illuminate\Support\Facades\Cache;
beforeEach(function () {
config([
'services.supplier.login' => 'test_login',
'services.supplier.password' => 'test_password',
'services.supplier.portal_url' => 'https://crm.bp-gr.ru',
]);
Cache::store('redis')->forget('supplier:session');
});
afterEach(function () {
Cache::store('redis')->forget('supplier:session');
});
test('command supplier:session:refresh dispatches job synchronously and writes cache', function () {
$bridge = Mockery::mock(PlaywrightBridge::class);
/** @var PlaywrightBridge $bridge */
$bridge->shouldReceive('refreshSession')->andReturn([
'phpsessid' => 'cmd_sess',
'csrf' => 'cmd_csrf',
'refreshed_at' => '2026-05-11T10:00:00Z',
]);
app()->instance(PlaywrightBridge::class, $bridge);
// @phpstan-ignore-next-line method.notFound (Pest TestCall->artisan() mixin)
$this->artisan('supplier:session:refresh')->assertExitCode(0);
expect(Cache::store('redis')->get('supplier:session'))->toBeArray()
->and(Cache::store('redis')->get('supplier:session')['phpsessid'])->toBe('cmd_sess');
});
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
uses(TestCase::class);
use App\Exceptions\Supplier\SupplierAuthException;
use App\Services\Supplier\PlaywrightBridge;
use App\Services\Supplier\ProcessFactory;
use Tests\TestCase;
use Tests\Unit\Supplier\Stubs\StubPlaywrightProcessHandle;
test('PlaywrightBridge passes credentials via stdin not argv', function () {
$stubHandle = new StubPlaywrightProcessHandle(
successful: true,
output: json_encode([
'phpsessid' => 'abc123',
'csrf' => 'xyz789',
'refreshed_at' => '2026-05-11T10:00:00Z',
]),
);
$factoryMock = Mockery::mock(ProcessFactory::class);
/** @var ProcessFactory $factoryMock */
$factoryMock->shouldReceive('create')->andReturn($stubHandle);
$bridge = new PlaywrightBridge($factoryMock);
$result = $bridge->refreshSession(
login: 'test_login',
password: 'test_password',
url: 'https://crm.bp-gr.ru'
);
// Credentials must come through stdin, not argv (avoid leak in ps output)
$capturedInput = json_decode($stubHandle->capturedInput, true);
expect($capturedInput)->toBeArray()
->and($capturedInput['login'])->toBe('test_login')
->and($capturedInput['password'])->toBe('test_password')
->and($capturedInput['url'])->toBe('https://crm.bp-gr.ru');
expect($stubHandle->capturedTimeout)->toBe(75);
expect($result)->toHaveKeys(['phpsessid', 'csrf', 'refreshed_at'])
->and($result['phpsessid'])->toBe('abc123')
->and($result['csrf'])->toBe('xyz789');
});
test('PlaywrightBridge throws SupplierAuthException on non-zero exit', function () {
$stubHandle = new StubPlaywrightProcessHandle(
successful: false,
errorOutput: '{"error":"login rejected"}',
exitCode: 1,
);
$factoryMock = Mockery::mock(ProcessFactory::class);
$factoryMock->shouldReceive('create')->andReturn($stubHandle);
$bridge = new PlaywrightBridge($factoryMock);
expect(fn () => $bridge->refreshSession('bad', 'bad', 'https://crm.bp-gr.ru'))
->toThrow(SupplierAuthException::class);
});
test('PlaywrightBridge throws SupplierAuthException on invalid stdout JSON', function () {
$stubHandle = new StubPlaywrightProcessHandle(
successful: true,
output: 'not valid json{',
);
$factoryMock = Mockery::mock(ProcessFactory::class);
$factoryMock->shouldReceive('create')->andReturn($stubHandle);
$bridge = new PlaywrightBridge($factoryMock);
expect(fn () => $bridge->refreshSession('a', 'b', 'https://crm.bp-gr.ru'))
->toThrow(SupplierAuthException::class);
});
test('PlaywrightBridge throws SupplierAuthException on missing keys in stdout', function () {
$stubHandle = new StubPlaywrightProcessHandle(
successful: true,
output: json_encode(['phpsessid' => 'a']), // no csrf
);
$factoryMock = Mockery::mock(ProcessFactory::class);
$factoryMock->shouldReceive('create')->andReturn($stubHandle);
$bridge = new PlaywrightBridge($factoryMock);
expect(fn () => $bridge->refreshSession('a', 'b', 'https://crm.bp-gr.ru'))
->toThrow(SupplierAuthException::class);
});
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
uses(TestCase::class);
use App\Exceptions\Supplier\SupplierAuthException;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Services\Supplier\PlaywrightBridge;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
beforeEach(function () {
config([
'services.supplier.login' => 'test_login',
'services.supplier.password' => 'test_password',
'services.supplier.portal_url' => 'https://crm.bp-gr.ru',
]);
Cache::store('redis')->forget('supplier:session');
});
afterEach(function () {
Cache::store('redis')->forget('supplier:session');
Mockery::close();
});
test('writes session data to Redis cache key supplier:session with 6h TTL', function () {
$bridge = Mockery::mock(PlaywrightBridge::class);
/** @var PlaywrightBridge $bridge */
$bridge->shouldReceive('refreshSession')
->once()
->with('test_login', 'test_password', 'https://crm.bp-gr.ru')
->andReturn([
'phpsessid' => 'sess123',
'csrf' => 'csrf456',
'refreshed_at' => '2026-05-11T10:00:00Z',
]);
(new RefreshSupplierSessionJob)->handle($bridge);
$cached = Cache::store('redis')->get('supplier:session');
expect($cached)->toBeArray()
->and($cached['phpsessid'])->toBe('sess123')
->and($cached['csrf'])->toBe('csrf456')
->and($cached['refreshed_at'])->toBe('2026-05-11T10:00:00Z');
});
test('rethrows SupplierAuthException from PlaywrightBridge', function () {
$bridge = Mockery::mock(PlaywrightBridge::class);
/** @var PlaywrightBridge $bridge */
$bridge->shouldReceive('refreshSession')
->andThrow(new SupplierAuthException('Login rejected'));
expect(fn () => (new RefreshSupplierSessionJob)->handle($bridge))
->toThrow(SupplierAuthException::class);
});
// NOTE: artisan() command test moved to tests/Feature/Supplier/SupplierSessionRefreshCommandTest.php
// (Pest Unit/ + $this->artisan() + uses(TestCase::class) триггерит laravel/pao stream_filter
// conflict при кросс-файловом запуске tests/Unit/Supplier/).
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Supplier\Stubs;
use App\Services\Supplier\PlaywrightProcessHandle;
/**
* Pure-PHP stub для PlaywrightProcessHandle, БЕЗ наследования Symfony Process.
*
* Зачем: Mockery::mock(Process::class) И extending Process вызывают
* 'stream_filter_remove(): Unable to flush filter' fatal через laravel/pao
* stdout capture. Pure-PHP impl этой проблемы избегает.
*/
final class StubPlaywrightProcessHandle implements PlaywrightProcessHandle
{
public string $capturedInput = '';
public int $capturedTimeout = 0;
public function __construct(
private readonly bool $successful,
private readonly string $output = '',
private readonly string $errorOutput = '',
private readonly int $exitCode = 0,
) {}
public function setInput(string $input): self
{
$this->capturedInput = $input;
return $this;
}
public function setTimeoutSeconds(int $seconds): self
{
$this->capturedTimeout = $seconds;
return $this;
}
public function run(): int
{
return $this->successful ? 0 : $this->exitCode;
}
public function isSuccessful(): bool
{
return $this->successful;
}
public function getOutput(): string
{
return $this->output;
}
public function getErrorOutput(): string
{
return $this->errorOutput;
}
public function getExitCode(): int
{
return $this->successful ? 0 : $this->exitCode;
}
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Tests\Unit\Supplier\Stubs;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Services\Supplier\PlaywrightBridge;
use RuntimeException;
/**
@@ -24,7 +25,7 @@ class ThrowingRefreshSupplierSessionJob extends RefreshSupplierSessionJob
//
}
public function handle(): void
public function handle(?PlaywrightBridge $bridge = null): void
{
throw new RuntimeException($this->simulatedMessage);
}