Files
portal/app/tests/Feature/Plan5/Projects/ProjectsStoreTest.php
T
Дмитрий c1ecefafc0 feat(projects): backend support for subject-level regions array (Plan 6 Task 3)
- Project model: +regions in fillable + cast via PostgresIntArray
  (custom Eloquent cast for PG INT[] — Laravel stock 'array' uses JSON
  which Postgres rejects on native INT[] columns)
- StoreProjectRequest / UpdateProjectRequest: drop region_mask/mode rules,
  add regions array validation (1..89 each, present/sometimes)
- ProjectService::create: dual-write — regions источник истины + legacy
  region_mask=255 + region_mode='include' для PhonePrefixService/LeadRouter
  compatibility (Plan 6.5 cleanup will remove dual-write)
- +5 Pest tests covering create/update/dual-write/validation rejection
- Drive-by: SchemaDeltaTest indexes pin 117 → 118 (Plan 6 v8.20 carryover
  from Task 1; should ideally have landed in Task 1 commit c487641)
- phpstan-baseline: +3 entries for Project::$regions until next ide-helper
  regen; existing Pest actingAs counts bumped 9→12 / 6→8 for new tests

Verified: Pest --parallel 747/744/3sk/0/0 (5 new tests pass +
SchemaDeltaTest now green), phpstan 0 errors, pint clean, gitleaks 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 05:39:43 +03:00

202 lines
7.2 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\Support\Facades\Queue;
beforeEach(fn () => Queue::fake());
it('creates a site project with valid payload', function () {
$tenant = Tenant::factory()->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()->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()->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('rejects call signal_identifier not starting with 7', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$response = $this->actingAs($user)->postJson('/api/projects', [
'name' => 'X', 'signal_type' => 'call', 'signal_identifier' => '89991234567',
'daily_limit_target' => 30, 'regions' => [],
'delivery_days_mask' => 127,
]);
$response->assertStatus(422);
});
it('creates sms project with senders + keyword', function () {
$tenant = Tenant::factory()->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()->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()->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()->create();
$tenantB = Tenant::factory()->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()->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()->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);
$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()->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()->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']);
});