diff --git a/app/app/Jobs/RouteSupplierLeadJob.php b/app/app/Jobs/RouteSupplierLeadJob.php index 997f6e31..2d0fe035 100644 --- a/app/app/Jobs/RouteSupplierLeadJob.php +++ b/app/app/Jobs/RouteSupplierLeadJob.php @@ -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. */ diff --git a/app/config/database.php b/app/config/database.php index aa76909b..f736ef68 100644 --- a/app/config/database.php +++ b/app/config/database.php @@ -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', - ], ] ), diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 34489fd5..6800f980 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -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 diff --git a/app/tests/Concerns/SharesSupplierPdo.php b/app/tests/Concerns/SharesSupplierPdo.php new file mode 100644 index 00000000..05624f87 --- /dev/null +++ b/app/tests/Concerns/SharesSupplierPdo.php @@ -0,0 +1,36 @@ +has('database.connections.pgsql_supplier')) { + return; + } + + $defaultConnection = DB::connection('pgsql'); + DB::connection('pgsql_supplier')->setPdo($defaultConnection->getPdo()); + DB::connection('pgsql_supplier')->setReadPdo($defaultConnection->getReadPdo()); + } +} diff --git a/app/tests/Feature/Console/ResetDeliveredTodayCommandTest.php b/app/tests/Feature/Console/ResetDeliveredTodayCommandTest.php index f6c6cba8..3ec8ab0f 100644 --- a/app/tests/Feature/Console/ResetDeliveredTodayCommandTest.php +++ b/app/tests/Feature/Console/ResetDeliveredTodayCommandTest.php @@ -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)"); diff --git a/app/tests/Feature/Integration/SupplierLeadFlowTest.php b/app/tests/Feature/Integration/SupplierLeadFlowTest.php index 568955ef..6250c82e 100644 --- a/app/tests/Feature/Integration/SupplierLeadFlowTest.php +++ b/app/tests/Feature/Integration/SupplierLeadFlowTest.php @@ -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') diff --git a/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php b/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php index d19b98fc..585f3a09 100644 --- a/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php +++ b/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php @@ -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)"); diff --git a/app/tests/Feature/Services/LeadRouterTest.php b/app/tests/Feature/Services/LeadRouterTest.php index 6d582003..3b11f121 100644 --- a/app/tests/Feature/Services/LeadRouterTest.php +++ b/app/tests/Feature/Services/LeadRouterTest.php @@ -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). diff --git a/app/tests/Feature/Supplier/SupplierConnectionTest.php b/app/tests/Feature/Supplier/SupplierConnectionTest.php index 757590aa..dadd7a4b 100644 --- a/app/tests/Feature/Supplier/SupplierConnectionTest.php +++ b/app/tests/Feature/Supplier/SupplierConnectionTest.php @@ -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') diff --git a/app/tests/TestCase.php b/app/tests/TestCase.php index eba886bd..fe1ffc2f 100644 --- a/app/tests/TestCase.php +++ b/app/tests/TestCase.php @@ -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); - } - } + // }