fix(supplier): Plan 3 Task 3 code-review fixes (4 Important + 3 Minor)

Закрывает 4 Important issues из code-review Task 3 (6d6181b):
- config/database.php: inline 11-key duplication заменён на single-source
  pattern через локальную переменную $pgsqlConnection (config() внутри
  config-файла не работает — Repository ещё не bootstrap'нут); 'pgsql' и
  'pgsql_supplier' теперь оба ссылаются на $pgsqlConnection; PDO options
  block с string-key _role_purpose удалён (PDO ждёт integer ATTR_* keys)
- tests/Concerns/SharesSupplierPdo.php (новый): trait для cross-connection
  PDO visibility в DatabaseTransactions; setUp override из TestCase.php
  удалён (был global на 562 теста, forced eager PDO connect);
  trait применён к 5 supplier-flow тестам: SupplierConnectionTest,
  LeadRouterTest, RouteSupplierLeadJobTest, ResetDeliveredTodayCommandTest,
  SupplierLeadFlowTest (все нуждаются в cross-connection видимости)
- phpstan-baseline.neon: entry для Pest TestCall->artisan() в
  SupplierConnectionTest заменён на inline @phpstan-ignore-next-line
  — local + self-documenting; добавлен baseline-entry для
  SharesSupplierPdo trait.unused (PHPStan не видит Pest uses() как trait usage)

Plus 3 Minor:
- typos 'dafault'/'corretly' (удалились с setUp override из TestCase.php)
- RouteSupplierLeadJob.php PHPDoc: \$connection → DB_CONNECTION консистентность

Pest: 562 tests, 560 passed + 2 skipped (без regression). PHPStan: 0 errors. Pint: clean.
This commit is contained in:
Дмитрий
2026-05-11 01:26:24 +03:00
parent 6d6181b8cc
commit 8c70255d2b
10 changed files with 84 additions and 66 deletions
+1 -1
View File
@@ -306,7 +306,7 @@ class RouteSupplierLeadJob implements ShouldQueue
* tenant ещё не определён на момент routing'а) для ручного разбора.
* supplier_lead.error апдейтится текстом исключения.
*
* INSERT с tenant_id=NULL проходит благодаря $connection='pgsql_supplier'
* INSERT с tenant_id=NULL проходит благодаря DB_CONNECTION='pgsql_supplier'
* (BYPASSRLS-роль crm_supplier_worker обходит политику tenant_isolation,
* которая под обычной ролью отвергла бы NULL). Закрыто Plan 3 Task 3.
*/
+29 -34
View File
@@ -3,6 +3,29 @@
use Illuminate\Support\Str;
use Pdo\Mysql;
// pgsql base config — single source of truth для pgsql + pgsql_supplier (Plan 3 Task 3).
// config('database.connections.pgsql') внутри этого файла не работает: config Repository
// ещё не bootstrap'нут на момент return этого массива (chicken-and-egg). Используем
// локальную переменную: pgsql_supplier = $pgsqlConnection + override (username/password).
$pgsqlConnection = [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
// PG session timezone = UTC. Без этого TIMESTAMPTZ возвращается с локальным offset
// (+03), а Carbon::parse теряет offset → password reset token expiry-check
// и аналогичные TZ-чувствительные сравнения ломаются.
'timezone' => env('DB_TIMEZONE', 'UTC'),
];
return [
/*
@@ -84,24 +107,7 @@ return [
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
// PG session timezone = UTC. Без этого TIMESTAMPTZ возвращается с локальным offset
// (+03), а Carbon::parse теряет offset → password reset token expiry-check
// и аналогичные TZ-чувствительные сравнения ломаются.
'timezone' => env('DB_TIMEZONE', 'UTC'),
],
'pgsql' => $pgsqlConnection,
// Plan 3 Task 3: dedicated PG connection для supplier-flow под BYPASSRLS-ролью
// crm_supplier_worker (создана Plan 2.6 #iv 7899071 в db/00_create_roles.sql v1.1).
@@ -123,25 +129,14 @@ return [
// Brainstorm decision: вариант C (BYPASSRLS-role) из 3 опций. См.
// docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §1.
'pgsql_supplier' => array_merge(
$pgsqlConnection,
[
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
'timezone' => env('DB_TIMEZONE', 'UTC'),
],
[
// crm_supplier_worker (BYPASSRLS) for supplier-flow jobs.
// Plan 3 Task 3 — закрывает BLOCKER #6 + WARN #2/#3.
// На dev fallback на default DB_USERNAME/DB_PASSWORD; на prod
// ОБЯЗАТЕЛЬНО задать DB_SUPPLIER_USERNAME=crm_supplier_worker.
'username' => env('DB_SUPPLIER_USERNAME', env('DB_USERNAME', 'root')),
'password' => env('DB_SUPPLIER_PASSWORD', env('DB_PASSWORD', '')),
'options' => [
'_role_purpose' => 'crm_supplier_worker (BYPASSRLS) for supplier-flow jobs',
],
]
),
+6 -6
View File
@@ -756,12 +756,6 @@ parameters:
count: 11
path: tests/Feature/RemindersDispatchDueTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Supplier/SupplierConnectionTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
@@ -923,3 +917,9 @@ parameters:
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
count: 1
path: tests/Concerns/SharesSupplierPdo.php
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Tests\Concerns;
use Illuminate\Support\Facades\DB;
/**
* Share PDO between pgsql и pgsql_supplier connections в тестах.
*
* Зачем: Laravel DatabaseTransactions trait оборачивает каждый connection в
* свою транзакцию. Записи через default Project::factory() ($pgsql) не видны
* с pgsql_supplier connection до commit'а. Sharing PDO означает обе
* connection'ы используют ту же PDO session одну транзакцию.
*
* На production обе connection'ы реальные separate PDO, и видимость
* обеспечивается READ COMMITTED после commit'а webhook-handler'а перед
* dispatch'ом supplier-flow job'а. Этот trait только для test-окружения.
*
* Использование: `uses(SharesSupplierPdo::class)` в Pest test-файле, где
* нужна cross-connection visibility (например, tests/Feature/Supplier/*).
*/
trait SharesSupplierPdo
{
protected function setUpSharesSupplierPdo(): void
{
if (! config()->has('database.connections.pgsql_supplier')) {
return;
}
$defaultConnection = DB::connection('pgsql');
DB::connection('pgsql_supplier')->setPdo($defaultConnection->getPdo());
DB::connection('pgsql_supplier')->setReadPdo($defaultConnection->getReadPdo());
}
}
@@ -6,8 +6,10 @@ use App\Models\Project;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function () {
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
@@ -10,8 +10,10 @@ use App\Models\SystemSetting;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
SystemSetting::query()->where('key', 'supplier_webhook_secret')
@@ -16,8 +16,10 @@ use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Mockery as M;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
@@ -8,8 +8,10 @@ use App\Models\Tenant;
use App\Services\LeadRouter;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
// Clear tenant context — LeadRouter operates without it (sharing across tenants).
@@ -27,8 +27,10 @@ use App\Models\Tenant;
use App\Services\LeadRouter;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
// Симулируем условия supplier-flow на queue worker'е: tenant ещё не определён.
@@ -113,6 +115,7 @@ test("ResetDeliveredTodayCommand сбрасывает delivered_today по вс
$projectIds[] = $project->id;
}
// @phpstan-ignore-next-line method.notFound (Pest TestCall->artisan() mixin)
$this->artisan('projects:reset-delivered-today')->assertExitCode(0);
$remaining = DB::connection('pgsql_supplier')
+1 -25
View File
@@ -3,32 +3,8 @@
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Facades\DB;
abstract class TestCase extends BaseTestCase
{
/**
* Plan 3 Task 3: share PDO between `pgsql` and `pgsql_supplier` connections в тестах,
* чтобы DatabaseTransactions corretly rollback'ил данные, созданные через дефолтный
* connection, но запрошенные через supplier. Без этого Project::on('pgsql_supplier')
* не видит свежесозданные через Project::factory() записи две PDO-сессии = две
* разные транзакции, supplier-side не видит uncommitted INSERTs default-side.
*
* На production обе роли (crm_app_user + crm_supplier_worker) две настоящие
* сессии, общая видимость через commit. В тестах достаточно одной разделяемой PDO.
*/
protected function setUp(): void
{
parent::setUp();
// После того как Laravel инициализировал dafault connection (pgsql),
// ре-используем его PDO для pgsql_supplier. Делаем только если оба
// connection'а есть в конфиге (продакшен может не иметь supplier).
if (config()->has('database.connections.pgsql_supplier')) {
$defaultPdo = DB::connection('pgsql')->getPdo();
$defaultReadPdo = DB::connection('pgsql')->getReadPdo();
DB::connection('pgsql_supplier')->setPdo($defaultPdo);
DB::connection('pgsql_supplier')->setReadPdo($defaultReadPdo);
}
}
//
}