9d2e7270de
- StoreProjectRequest: 3-way conditional validation (site domain regex, call 7\d{10}, sms senders required)
- ProjectService::create(): max_projects limit check via Tenant.limits JSONB + dispatch SyncSupplierProjectJob
- ProjectController: constructor DI + store() method returning 201
- SyncSupplierProjectJob: stub (Task 4 полная реализация)
- POST /api/projects route inside auth:sanctum+tenant group (name projects.store)
- Migration add_limits_to_tenants: JSONB DEFAULT '{}' per-tenant limits column
- Tenant model: limits added to fillable + casts as array
- schema.sql/CHANGELOG: tenants.limits documented in v8.20
- phpstan-baseline: +8 actingAs entries for new test file
- Quirk: region_mode in request uses 'include'/'exclude' (schema CHECK) not 'all'/'whitelist' (plan spec typo)
- Quirk: Project::first() → Project::where('signal_identifier','x.ru')->latest()->first() (no RefreshDatabase, persistent test DB)
- 8/8 ProjectsStoreTest passed; 699/706 total (4 pre-existing failures unchanged)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
132 lines
4.9 KiB
PHP
132 lines
4.9 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,
|
|
'region_mask' => 0,
|
|
'region_mode' => 'include',
|
|
'delivery_days_mask' => 127,
|
|
]);
|
|
|
|
$response->assertCreated();
|
|
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, 'region_mask' => 0, 'region_mode' => 'include',
|
|
'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, 'region_mask' => 0, 'region_mode' => 'include',
|
|
'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, 'region_mask' => 0, 'region_mode' => 'include',
|
|
'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, 'region_mask' => 0, 'region_mode' => 'include',
|
|
'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, 'region_mask' => 0, 'region_mode' => 'include',
|
|
'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, 'region_mask' => 0, 'region_mode' => 'include',
|
|
'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, 'region_mask' => 0, 'region_mode' => 'include',
|
|
'delivery_days_mask' => 127,
|
|
]);
|
|
|
|
$project = Project::where('signal_identifier', 'x.ru')->latest()->first();
|
|
expect($project->tenant_id)->toBe($tenantA->id);
|
|
});
|