Files
portal/app/tests/Feature/Supplier/SupplierConnectionTest.php
T
Дмитрий 8c70255d2b 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.
2026-05-11 01:26:24 +03:00

129 lines
6.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Plan 3 Task 3 — supplier-flow на pgsql_supplier (BYPASSRLS) connection.
*
* 4 regression-теста, закрывающих 3 backlog-айтема Plan 2.6:
* 1. RouteSupplierLeadJob $connection = 'pgsql_supplier' (sanity-check, что свойство выставлено).
* 2. BLOCKER #6: failed_webhook_jobs INSERT с tenant_id=NULL проходит под pgsql_supplier
* (политика tenant_isolation отвергает NULL под обычной ролью; BYPASSRLS обходит).
* 3. WARN #2: LeadRouter::matchEligibleProjects видит проекты ВСЕХ tenant'ов
* без SET LOCAL app.current_tenant_id (sharing-model).
* 4. WARN #3: ResetDeliveredTodayCommand сбрасывает delivered_today по всем tenant'ам.
*
* Spec: docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §1.
*
* NB: На dev DB_USERNAME=postgres (superuser, BYPASSRLS implicit) — fallback в
* config/database.php pgsql_supplier берёт DB_USERNAME, тесты работают без отдельной
* роли. На production env-keys DB_SUPPLIER_USERNAME/PASSWORD указывают на crm_supplier_worker.
*/
use App\Jobs\RouteSupplierLeadJob;
use App\Models\Project;
use App\Models\SupplierProject;
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 ещё не определён.
// set_config('app.current_tenant_id', '0', true) — session-scoped + откатывается транзакцией.
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
});
test('RouteSupplierLeadJob declares DB_CONNECTION = pgsql_supplier (Plan 3 Task 3)', function (): void {
// Job's $connection из Bus\Queueable управляет очередью (sync/database/redis), не БД.
// Для DB-операций используется константа DB_CONNECTION — failed() callback пишет
// в failed_webhook_jobs через DB::connection(RouteSupplierLeadJob::DB_CONNECTION).
// Закрывает BLOCKER #6: INSERT с tenant_id=NULL проходит под BYPASSRLS-роль.
expect(RouteSupplierLeadJob::DB_CONNECTION)->toBe('pgsql_supplier');
});
test('failed_webhook_jobs INSERT с tenant_id=NULL проходит под pgsql_supplier (BLOCKER #6)', function (): void {
// Под обычной ролью policy tenant_isolation USING (tenant_id = current_setting('app.current_tenant_id')::bigint)
// отвергает NULL (NULL :: bigint = NULL, NULL = '0'::bigint → NULL → false).
// Под pgsql_supplier (BYPASSRLS на prod / postgres superuser на dev) INSERT проходит.
DB::connection('pgsql_supplier')->table('failed_webhook_jobs')->insert([
'tenant_id' => null,
'webhook_log_id' => null,
'raw_payload' => json_encode(['supplier_lead_id' => 42, 'project' => 'B1_test.ru']),
'exception' => 'simulated failure for BLOCKER #6 regression test',
'retry_count' => 3,
'failed_at' => now(),
]);
$exists = DB::connection('pgsql_supplier')
->table('failed_webhook_jobs')
->whereNull('tenant_id')
->where('exception', 'simulated failure for BLOCKER #6 regression test')
->exists();
expect($exists)->toBeTrue();
});
test("LeadRouter видит проекты всех tenant'ов под pgsql_supplier без SET LOCAL (WARN #2)", function (): void {
// 3 tenant × 2 проекта = 6 проектов, все привязаны к одному supplier_project.
// БЕЗ SET LOCAL app.current_tenant_id (он уже '0' из beforeEach) — под обычной
// ролью RLS отбросил бы всё; под pgsql_supplier (BYPASSRLS) видны все 6.
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'plan3-task3-warn2.example.com',
]);
$tenants = Tenant::factory()->count(3)->create(['balance_leads' => 100]);
foreach ($tenants as $tenant) {
for ($i = 0; $i < 2; $i++) {
Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'plan3-task3-warn2.example.com',
'is_active' => true,
'daily_limit_target' => 10,
'delivered_today' => 0,
'delivery_days_mask' => 127,
'region_mask' => 255,
'region_mode' => 'include',
]);
}
}
$router = app(LeadRouter::class);
$eligible = $router->matchEligibleProjects($supplier, '79991234567');
expect($eligible)->toHaveCount(6);
});
test("ResetDeliveredTodayCommand сбрасывает delivered_today по всем tenant'ам (WARN #3)", function (): void {
// Создаём 3 tenant'а с проектами, у каждого delivered_today=5.
// Команда должна сбросить все 3 → 0 (под pgsql_supplier BYPASSRLS — без SET LOCAL).
$tenants = Tenant::factory()->count(3)->create();
$projectIds = [];
foreach ($tenants as $tenant) {
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'delivered_today' => 5,
]);
$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')
->table('projects')
->whereIn('id', $projectIds)
->where('delivered_today', '>', 0)
->count();
expect($remaining)->toBe(0);
});