Files
portal/app/tests/Feature/Supplier/SupplierConnectionTest.php
T

129 lines
6.0 KiB
PHP
Raw Normal View History

<?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);
});