7d00052321
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).
143 lines
7.2 KiB
PHP
143 lines
7.2 KiB
PHP
<?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);
|
||
});
|