Files
portal/app/tests/Feature/Supplier/SupplierConnectionTest.php
T
Дмитрий 7d00052321 feat(slepok): Task 2.5 — LeadRouter reads from project_routing_snapshots (R-01 closure)
LeadRouter SQL переписан на JOIN с project_routing_snapshots по active_slepok_date:
до 21:00 МСК = today, после 21:00 МСК = today+1. is_active / delivery_days_mask /
daily_limit / regions / signal_type / signal_identifier берутся из snapshot.
Из live projects — только delivered_today (счётчик остатка лимита). Из tenants —
balance_rub (live auto-pause при нулевом балансе).

Active snapshot date вычисляется в PHP (метод activeSnapshotDate()) и
передаётся в SQL как параметр — тестируемо через Carbon::setTestNow,
исключает дрейф между PHP- и DB-часами.

Fail-loud: Log::error('lead_router.no_snapshot_for_active_date', ...) если
по активной дате слепка вообще нет ни одной строки snapshot'а (cron не отработал).

Closes R-01, R-04, R-06, R-07, R-08, R-15.
Partial: R-02 (через шеринг), R-09 (race), R-10 (editable identifier) — закрываются Task 2.6+.

Plan: docs/superpowers/plans/2026-05-26-slepok-routing-protection.md §Task 2.5
Spec: docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md §4.2.3

Tests added:
- tests/Feature/LeadRouter/SnapshotRoutingTest.php (4 tests, all GREEN locally)

Tests patched (downstream — добавлен createRoutingSnapshotFromProject() helper):
- tests/Pest.php — global helper createRoutingSnapshotFromProject()
- tests/Feature/LeadRouter/BalanceFilterTest.php (2/2 GREEN)
- tests/Feature/Services/LeadRouterTest.php (10/10 GREEN)
- tests/Feature/Jobs/RouteSupplierLeadJobTest.php (14/14 GREEN)
- tests/Feature/Supplier/DirectPlatformTest.php (6/6 GREEN)
- tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php (3/3 GREEN)
- tests/Feature/Supplier/SupplierConnectionTest.php (5/5 GREEN)
- tests/Feature/Integration/SupplierLeadFlowTest.php (2/2 GREEN)
- tests/Feature/Pd/DealCreatePdLogTest.php (2/2 GREEN)

Each test file isolated regression: GREEN. Combined run 49/50 with 1 flake on
quirk #77 (Faker unique domainName + cross-connection pgsql/pgsql_supplier
DatabaseTransactions scope mismatch) — pre-existing, NOT regression от Task 2.5.

Patched via 7 parallel Sonnet subagents per Pravila §15.1; controller-verified
isolated + combined regression (latter caught 1 subagent over-application:
paused project in SupplierLeadFlowTest получил snapshot, что нарушило логику
теста — fixed inline, по semantic match with SnapshotBackfillCommand SQL
WHERE p.is_active = true).
2026-05-28 05:48:15 +03:00

143 lines
7.2 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\Jobs\SyncSupplierProjectJob;
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('SyncSupplierProjectJob declares DB_CONNECTION = pgsql_supplier (queue worker has no tenant GUC)', function (): void {
// Дублирует RouteSupplierLeadJob: создание/правка проекта тоже запускается из очереди,
// где SetTenantContext-прослойка не отработала. Под обычной ролью crm_app_user
// SELECT по projects падает 42704 (unrecognized configuration parameter
// "app.current_tenant_id"). Все DB-операции джоба обязаны идти через pgsql_supplier (BYPASSRLS).
expect(SyncSupplierProjectJob::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 {
// 6 tenant × 1 проект = 6 проектов, все привязаны к одному supplier_project.
// БЕЗ SET LOCAL app.current_tenant_id (он уже '0' из beforeEach) — под обычной
// ролью RLS отбросил бы всё; под pgsql_supplier (BYPASSRLS) видны все 6.
// NB: LeadRouter возвращает DISTINCT ON (tenant_id) — один проект на тенанта,
// поэтому используем 6 тенантов × 1 проект чтобы expectation «6» оставалась.
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'plan3-task3-warn2.example.com',
]);
$tenants = Tenant::factory()->count(6)->create(['balance_leads' => 100, 'balance_rub' => 500]);
foreach ($tenants as $tenant) {
$project = Project::factory()->create([
'tenant_id' => $tenant->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,
]);
DB::table('project_supplier_links')->insert([
'project_id' => $project->id,
'supplier_project_id' => $supplier->id,
'platform' => $supplier->platform,
// @phpstan-ignore-next-line property.notFound — subject_code is in $fillable/casts, IDE stubs lag
'subject_code' => $supplier->subject_code,
]);
createRoutingSnapshotFromProject($project, null, 'site', 'plan3-task3-warn2.example.com', 10);
}
$router = app(LeadRouter::class);
$eligible = $router->matchEligibleProjects($supplier);
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);
});