88ace4e3d9
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Снижение остатка 19 to 5. Всё тест-сторона: - PdErasureServiceTest + AdminPdSubjectRequestsControllerTest: SharesSupplierPdo — перестали коммитить pd_processing_log через pgsql_supplier, что ломало глобальный audit:verify-chains (6 падений) и амплифицировало PhoneRegionSmoke. - ReportFileDeletePdLogTest: SharesSupplierPdo — cron reports:cleanup-expired теперь видит незакоммиченные job'ы теста. - AdminSuppliersControllerTest: устойчивый ассерт (с фазы 3 в suppliers есть direct). - AuthLogCoverageTest/AuthFlowIntegrationTest: новый флоу самозаписи G1/SP1 — register_success пишется после confirm-email; добавлен шаг подтверждения. - ImpersonationTest end: verify (G7-B) ставит маркер impersonation → admin-зона закрыта by design; помечаем токен used напрямую вместо session-takeover. - CleanupInactiveSupplierProjectsJobTest: phase A читает pivot project_supplier_links — добавлена привязка linkProjectToSupplier (раньше был только legacy FK). - Pint-нормализация uses() FQN to import в ранее тронутых файлах. Остаток 5 (НЕ слепой патч): webhook B-префикс ×2 (решение владельца), advisory-lock audit-цепочки (возможный дрейф схемы, флажок), SupplierConnection WARN#2 (cap-3, поведенческое), SupplierPortalClientTest (пре-существующий, не от этих правок). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
225 lines
8.7 KiB
PHP
225 lines
8.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Jobs\SyncSupplierProjectJob;
|
|
use App\Models\Project;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\Queue;
|
|
|
|
uses(DatabaseTransactions::class);
|
|
|
|
beforeEach(fn () => Queue::fake());
|
|
|
|
it('creates a site project with valid payload', function () {
|
|
$tenant = Tenant::factory()->withRequisites()->create();
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
|
|
|
$response = $this->actingAs($user)->postJson('/api/projects', [
|
|
'name' => 'Окна СПб',
|
|
'signal_type' => 'site',
|
|
'signal_identifier' => 'okna-spb.ru',
|
|
'daily_limit_target' => 50,
|
|
'regions' => [],
|
|
'delivery_days_mask' => 127,
|
|
]);
|
|
|
|
$response->assertCreated();
|
|
$response->assertJsonPath('data.sync_status', 'pending');
|
|
expect(Project::where('signal_identifier', 'okna-spb.ru')->exists())->toBeTrue();
|
|
Queue::assertPushed(SyncSupplierProjectJob::class);
|
|
});
|
|
|
|
it('rejects invalid site domain', function () {
|
|
$tenant = Tenant::factory()->withRequisites()->create();
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
|
|
|
$response = $this->actingAs($user)->postJson('/api/projects', [
|
|
'name' => 'X', 'signal_type' => 'site', 'signal_identifier' => 'not a domain',
|
|
'daily_limit_target' => 50, 'regions' => [],
|
|
'delivery_days_mask' => 127,
|
|
]);
|
|
|
|
$response->assertStatus(422);
|
|
$response->assertJsonValidationErrors(['signal_identifier']);
|
|
});
|
|
|
|
it('creates a call project with valid 11-digit phone', function () {
|
|
$tenant = Tenant::factory()->withRequisites()->create();
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
|
|
|
$response = $this->actingAs($user)->postJson('/api/projects', [
|
|
'name' => 'Натяжные', 'signal_type' => 'call', 'signal_identifier' => '79161234567',
|
|
'daily_limit_target' => 30, 'regions' => [],
|
|
'delivery_days_mask' => 127,
|
|
]);
|
|
|
|
$response->assertCreated();
|
|
});
|
|
|
|
it('normalizes call phone starting with 8 to 7 (accepts)', function () {
|
|
// StoreProjectRequest::prepareForValidation() нормализует call-телефон
|
|
// через PhoneNormalizer (8XXXXXXXXXX → 7XXXXXXXXXX). UX-подсказка формы
|
|
// обещает «можно вводить с 8 — приведём сами», поэтому 8-префикс валиден.
|
|
$tenant = Tenant::factory()->withRequisites()->create();
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
|
|
|
$response = $this->actingAs($user)->postJson('/api/projects', [
|
|
'name' => 'Колл 8-префикс', 'signal_type' => 'call', 'signal_identifier' => '89991234567',
|
|
'daily_limit_target' => 30, 'regions' => [],
|
|
'delivery_days_mask' => 127,
|
|
]);
|
|
|
|
$response->assertCreated();
|
|
expect(Project::where('name', 'Колл 8-префикс')->value('signal_identifier'))->toBe('79991234567');
|
|
});
|
|
|
|
it('rejects call signal_identifier that is not a valid RU phone', function () {
|
|
// Слишком короткий номер не нормализуется (PhoneNormalizer → null) → regex ^7\d{10}$ не проходит.
|
|
$tenant = Tenant::factory()->withRequisites()->create();
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
|
|
|
$response = $this->actingAs($user)->postJson('/api/projects', [
|
|
'name' => 'X', 'signal_type' => 'call', 'signal_identifier' => '12345',
|
|
'daily_limit_target' => 30, 'regions' => [],
|
|
'delivery_days_mask' => 127,
|
|
]);
|
|
|
|
$response->assertStatus(422);
|
|
$response->assertJsonValidationErrors(['signal_identifier']);
|
|
});
|
|
|
|
it('creates sms project with senders + keyword', function () {
|
|
$tenant = Tenant::factory()->withRequisites()->create();
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
|
|
|
$response = $this->actingAs($user)->postJson('/api/projects', [
|
|
'name' => 'Ипотека', 'signal_type' => 'sms',
|
|
'sms_senders' => ['TINKOFF'], 'sms_keyword' => 'ипотека',
|
|
'daily_limit_target' => 100, 'regions' => [],
|
|
'delivery_days_mask' => 127,
|
|
]);
|
|
|
|
$response->assertCreated();
|
|
$project = Project::where('name', 'Ипотека')->first();
|
|
expect($project->sms_senders)->toBe(['TINKOFF']);
|
|
expect($project->sms_keyword)->toBe('ипотека');
|
|
});
|
|
|
|
it('rejects sms project without sms_senders', function () {
|
|
$tenant = Tenant::factory()->withRequisites()->create();
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
|
|
|
$response = $this->actingAs($user)->postJson('/api/projects', [
|
|
'name' => 'X', 'signal_type' => 'sms',
|
|
'daily_limit_target' => 100, 'regions' => [],
|
|
'delivery_days_mask' => 127,
|
|
]);
|
|
|
|
$response->assertStatus(422);
|
|
$response->assertJsonValidationErrors(['sms_senders']);
|
|
});
|
|
|
|
it('rejects when tenant exceeds max_projects limit', function () {
|
|
$tenant = Tenant::factory()->withRequisites()->create(['limits' => ['max_projects' => 1]]);
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
|
Project::factory()->create(['tenant_id' => $tenant->id]);
|
|
|
|
$response = $this->actingAs($user)->postJson('/api/projects', [
|
|
'name' => 'second', 'signal_type' => 'site', 'signal_identifier' => 'second.ru',
|
|
'daily_limit_target' => 10, 'regions' => [],
|
|
'delivery_days_mask' => 127,
|
|
]);
|
|
|
|
$response->assertStatus(403);
|
|
});
|
|
|
|
it('forces tenant_id from auth user (not from payload)', function () {
|
|
$tenantA = Tenant::factory()->withRequisites()->create();
|
|
$tenantB = Tenant::factory()->withRequisites()->create();
|
|
$userA = User::factory()->create(['tenant_id' => $tenantA->id]);
|
|
|
|
$this->actingAs($userA)->postJson('/api/projects', [
|
|
'tenant_id' => $tenantB->id, // попытка инъекции
|
|
'name' => 'X', 'signal_type' => 'site', 'signal_identifier' => 'x.ru',
|
|
'daily_limit_target' => 10, 'regions' => [],
|
|
'delivery_days_mask' => 127,
|
|
]);
|
|
|
|
$project = Project::where('signal_identifier', 'x.ru')->latest()->first();
|
|
expect($project->tenant_id)->toBe($tenantA->id);
|
|
});
|
|
|
|
it('rejects site domain with consecutive dots', function () {
|
|
$tenant = Tenant::factory()->withRequisites()->create();
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
|
|
|
$response = $this->actingAs($user)->postJson('/api/projects', [
|
|
'name' => 'X', 'signal_type' => 'site', 'signal_identifier' => 'okna..spb.ru',
|
|
'daily_limit_target' => 50, 'regions' => [],
|
|
'delivery_days_mask' => 127,
|
|
]);
|
|
|
|
$response->assertStatus(422);
|
|
$response->assertJsonValidationErrors(['signal_identifier']);
|
|
});
|
|
|
|
// Plan 6 — subject-level regions[] support.
|
|
|
|
it('creates project with subject-level regions array', function () {
|
|
$tenant = Tenant::factory()->withRequisites()->create();
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
|
|
|
$response = $this->actingAs($user)->postJson('/api/projects', [
|
|
'name' => 'Regions Test Project',
|
|
'signal_type' => 'site',
|
|
'signal_identifier' => 'regions-test.example',
|
|
'daily_limit_target' => 50,
|
|
'delivery_days_mask' => 127,
|
|
'regions' => [82, 83], // Москва + СПб
|
|
]);
|
|
|
|
$response->assertStatus(201);
|
|
$response->assertJsonPath('data.regions', [82, 83]);
|
|
$created = Project::where('name', 'Regions Test Project')->firstOrFail();
|
|
expect($created->regions)->toBe([82, 83]);
|
|
});
|
|
|
|
it('dual-writes region_mask=255 + region_mode=include for backward-compat', function () {
|
|
$tenant = Tenant::factory()->withRequisites()->create();
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
|
|
|
$response = $this->actingAs($user)->postJson('/api/projects', [
|
|
'name' => 'Dual Write Test',
|
|
'signal_type' => 'site',
|
|
'signal_identifier' => 'dualwrite.example',
|
|
'daily_limit_target' => 50,
|
|
'delivery_days_mask' => 127,
|
|
'regions' => [77],
|
|
]);
|
|
|
|
$response->assertStatus(201);
|
|
$created = Project::where('name', 'Dual Write Test')->firstOrFail();
|
|
expect($created->region_mask)->toBe(255);
|
|
expect($created->region_mode)->toBe('include');
|
|
});
|
|
|
|
it('rejects regions code out of 1..89 range with 422', function () {
|
|
$tenant = Tenant::factory()->withRequisites()->create();
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
|
|
|
$response = $this->actingAs($user)->postJson('/api/projects', [
|
|
'name' => 'Invalid Code Test',
|
|
'signal_type' => 'site',
|
|
'signal_identifier' => 'invalid.example',
|
|
'daily_limit_target' => 50,
|
|
'delivery_days_mask' => 127,
|
|
'regions' => [90, 100],
|
|
]);
|
|
|
|
$response->assertStatus(422);
|
|
$response->assertJsonValidationErrors(['regions.0', 'regions.1']);
|
|
});
|