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:
@@ -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]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Generated
+59
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user