diff --git a/app/tests/Feature/Audit/AuditChainRaceConditionTest.php b/app/tests/Feature/Audit/AuditChainRaceConditionTest.php index a676e152..daf2a807 100644 --- a/app/tests/Feature/Audit/AuditChainRaceConditionTest.php +++ b/app/tests/Feature/Audit/AuditChainRaceConditionTest.php @@ -104,18 +104,23 @@ it('audit_chain_hash holds pg_advisory_xact_lock on the partition OID during INS 'created_at' => now(), ]); - // pg_advisory_xact_lock releases at END of transaction — still held here. + // pg_advisory_xact_lock(bigint) → pg_locks: classid = старшие 32 бита ключа, + // objid = младшие 32. Декомпозицию считаем в PHP: bind-параметр в SQL-сдвиге + // (? >> 32) не сдвигается корректно (PDO передаёт значение так, что сдвиг + // даёт сам ключ, а не 0) → classid не совпадал и блокировка «не находилась». + $classid = (int) (((int) $lockKey >> 32) & 0xFFFFFFFF); + $objid = (int) ((int) $lockKey & 0xFFFFFFFF); $held = DB::selectOne( 'SELECT EXISTS ( SELECT 1 FROM pg_locks WHERE locktype = \'advisory\' - AND classid = (? >> 32)::int - AND objid = (? & x\'ffffffff\'::bigint)::int + AND classid = ? + AND objid = ? AND granted = true AND pid = pg_backend_pid() ) AS held', - [(int) $lockKey, (int) $lockKey] + [$classid, $objid] ); $lockHeld = (bool) ($held->held ?? false); }); diff --git a/app/tests/Feature/Http/Webhook/SupplierWebhookTest.php b/app/tests/Feature/Http/Webhook/SupplierWebhookTest.php index ec94a041..52fc55df 100644 --- a/app/tests/Feature/Http/Webhook/SupplierWebhookTest.php +++ b/app/tests/Feature/Http/Webhook/SupplierWebhookTest.php @@ -92,11 +92,13 @@ it('rejects invalid phone format (not 7XXXXXXXXXX) with 422', function () { $response->assertStatus(422); }); -it('rejects invalid project format (no B[123]_ prefix) with 422', function () { +it('accepts project without B[123]_ prefix as DIRECT (Phase 3)', function () { + // Фаза 3: требование префикса B1_/B2_/B3_ снято намеренно — проект без префикса + // принимается (202) и маршрутизируется как platform=DIRECT, чтобы не терять заявки. $response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [ 'vid' => 1, 'project' => 'invalid_format', 'phone' => '79991234567', 'time' => time(), ]); - $response->assertStatus(422); + $response->assertStatus(202); }); it('blocks empty IP allowlist в production env (Plan 2.6 fix #ii)', function () { diff --git a/app/tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php b/app/tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php index a2486934..aaca8043 100644 --- a/app/tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php +++ b/app/tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php @@ -28,8 +28,10 @@ it('returns 422 JSON when supplier posts invalid payload WITHOUT Accept: applica [], // files ['HTTP_CONTENT_TYPE' => 'application/x-www-form-urlencoded'], // server: НЕТ Accept JSON http_build_query([ - 'vid' => 1, - 'project' => 'invalid_no_b_prefix', + // Фаза 3 сняла валидацию формата project (non-B → DIRECT), поэтому + // для проверки JSON-рендера 422 используем всё ещё обязательное поле: + // пропускаем vid → гарантированный 422. + 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => time(), ]) @@ -37,18 +39,18 @@ it('returns 422 JSON when supplier posts invalid payload WITHOUT Accept: applica $response->assertStatus(422); expect($response->headers->get('Content-Type'))->toContain('application/json'); - $response->assertJsonStructure(['message', 'errors' => ['project']]); + $response->assertJsonStructure(['message', 'errors' => ['vid']]); }); it('still works correctly for postJson clients (regression)', function () { $response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [ - 'vid' => 1, - 'project' => 'invalid_no_b_prefix', + // vid пропущен намеренно → 422 (project-формат больше не валидируется, фаза 3). + 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => time(), ]); - $response->assertStatus(422)->assertJsonValidationErrors('project'); + $response->assertStatus(422)->assertJsonValidationErrors('vid'); }); it('non-webhook routes still use default render (no JSON forced)', function () { diff --git a/app/tests/Feature/Supplier/SupplierConnectionTest.php b/app/tests/Feature/Supplier/SupplierConnectionTest.php index 420e09a4..2dffb4f8 100644 --- a/app/tests/Feature/Supplier/SupplierConnectionTest.php +++ b/app/tests/Feature/Supplier/SupplierConnectionTest.php @@ -78,11 +78,13 @@ test('failed_webhook_jobs INSERT с tenant_id=NULL проходит под pgsql }); test("LeadRouter видит проекты всех tenant'ов под pgsql_supplier без SET LOCAL (WARN #2)", function (): void { - // 6 tenant × 1 проект = 6 проектов, все привязаны к одному supplier_project. + // 6 tenant × 1 проект, все привязаны к одному 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» оставалась. + // ролью RLS отбросил бы всё (вернул 0); под pgsql_supplier (BYPASSRLS) видны все 6. + // NB: matchEligibleProjects ограничивает выдачу cap = LeadDistributor::CAP = 3 + // (лид продаётся ≤3 раз, взвешенный жребий), по одному проекту на тенанта. + // Поэтому возвращается 3 (а не 6 и не 0) — это и доказывает кросс-tenant + // видимость под BYPASSRLS: при RLS было бы 0. $supplier = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', @@ -112,7 +114,10 @@ test("LeadRouter видит проекты всех tenant'ов под pgsql_sup $router = app(LeadRouter::class); $eligible = $router->matchEligibleProjects($supplier); - expect($eligible)->toHaveCount(6); + // cap=3 из 6 видимых тенантов; ключевое — выбраны РАЗНЫЕ тенанты (кросс-tenant + // видимость под BYPASSRLS), а не 0 (что было бы под RLS). + expect($eligible)->toHaveCount(3); + expect($eligible->pluck('tenant_id')->unique()->count())->toBe(3); }); test("ResetDeliveredTodayCommand сбрасывает delivered_today по всем tenant'ам (WARN #3)", function (): void { diff --git a/app/tests/Unit/Supplier/SupplierPortalClientTest.php b/app/tests/Unit/Supplier/SupplierPortalClientTest.php index ef9d1f95..12619076 100644 --- a/app/tests/Unit/Supplier/SupplierPortalClientTest.php +++ b/app/tests/Unit/Supplier/SupplierPortalClientTest.php @@ -33,7 +33,9 @@ afterEach(function (): void { test('listProjects attaches PHPSESSID cookie and X-CSRF-Token header', function (): void { Http::fake([ - 'crm.bp-gr.ru/admin/rt-projects-load*' => Http::response('[]', 200), + // URL клиента — /admin/visit/rt-projects-load (см. SupplierPortalClient::listProjects); + // старый паттерн без /visit/ не матчил → запрос уходил в неподходящий fallback-fake. + 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response('[]', 200), ]); app(SupplierPortalClient::class)->listProjects();