Files
portal/app/tests/Feature/Api/ProjectBulkActionsTest.php
T
Дмитрий 2ec70b338f
Accessibility (Pa11y live) / a11y (push) Has been cancelled
test: оздоровление тест-стенда — изоляция протекателей плюс фикстуры, партиции, видимость supplier-коннекта
Закрыто 36 из 55 пре-существующих падений backend-набора (55 to 19), всё тест-сторона,
код продукта не тронут. Группы:
- incident-показ/РКН: добавлен SharesSupplierPdo + синхрон уровня транзакции в трейте
  (вложенный transaction на общем PDO теперь делает SAVEPOINT, не повторный BEGIN).
- auto-pause и lead-delivery: тесты создают project_routing_snapshots, от которого
  зависит выбор кандидатов в LeadRouter (slepok-инвариант).
- изоляция 16 протекающих тестов: добавлен DatabaseTransactions (где нужно плюс
  SharesSupplierPdo) — перестали оставлять committed-строки, отравлявшие глобально
  сканирующие тесты (snapshot, verify-audit, size-N).
- partition time-bombs: ensureRange месячных партиций для тестов на дату 2026-05.
- устаревшие ассерты: SchemaDelta метрики v8.35 to v8.52, ProjectsStore телефон 8 to 7
  нормализуется, incidents-watch фильтр активного admin, register captcha_token,
  impersonation активный юзер тенанта, activity_log.deal_id, ProjectUpdateDedup пауза.

Остаток 19 (отдельно): verify-audit-chains и size-N (протекатели audit-строк),
webhook B-префикс (решение владельца), пара env/каскадных.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:39:51 +03:00

257 lines
9.4 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
uses(\Illuminate\Foundation\Testing\DatabaseTransactions::class);
it('accepts update_regions action with subject-code arrays', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$p = Project::factory()->for($tenant)->create(['regions' => [82]]);
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'update_regions',
'ids' => [$p->id],
'add_regions' => [83, 84], // Санкт-Петербург + Севастополь
'remove_regions' => [82], // Москва
])
->assertOk()
->assertJsonStructure(['updated', 'skipped', 'warnings']);
});
it('rejects unknown action', function () {
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'nuke_everything',
'ids' => [1],
])
->assertStatus(422)
->assertJsonValidationErrors(['action']);
});
it('rejects update_limit with both delta and replace', function () {
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'update_limit',
'ids' => [1],
'delta' => 50,
'replace' => 500,
])
->assertStatus(422);
});
it('rejects empty ids without scope', function () {
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'pause',
])
->assertStatus(422);
});
it('accepts empty scope.filter as valid scope (all projects)', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'pause',
'scope' => ['filter' => []],
])
->assertOk();
});
it('applies update_regions add_regions and remove_regions to the regions array', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$p1 = Project::factory()->for($tenant)->create(['regions' => [82, 56]]); // Москва + Московская обл.
$p2 = Project::factory()->for($tenant)->create(['regions' => []]); // вся РФ
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'update_regions',
'ids' => [$p1->id, $p2->id],
'add_regions' => [83], // Санкт-Петербург
'remove_regions' => [56], // Московская область
])
->assertOk()
->assertJson(['updated' => 2, 'skipped' => [], 'warnings' => []]);
expect($p1->fresh()->regions)->toBe([82, 83]); // [82,56] {83} \ {56}, отсортировано
expect($p2->fresh()->regions)->toBe([83]); // [] {83} \ {56}
});
it('rejects update_regions with out-of-range subject code', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$p = Project::factory()->for($tenant)->create();
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'update_regions',
'ids' => [$p->id],
'add_regions' => [90], // > 89 — невалидный код субъекта РФ
])
->assertStatus(422)
->assertJsonValidationErrors(['add_regions.0']);
});
it('applies update_days add and remove correctly', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$p = Project::factory()->for($tenant)->create(['delivery_days_mask' => 31]); // Пн-Пт
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'update_days',
'ids' => [$p->id],
'add' => 96, // 32+64 = Сб+Вс
'remove' => 1, // Пн
])
->assertOk()
->assertJson(['updated' => 1, 'skipped' => [], 'warnings' => []]);
expect($p->fresh()->delivery_days_mask)->toBe((31 | 96) & ~1); // = 126
});
it('applies update_limit delta to all projects', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$p1 = Project::factory()->for($tenant)->create(['daily_limit_target' => 100, 'delivered_today' => 0]);
$p2 = Project::factory()->for($tenant)->create(['daily_limit_target' => 200, 'delivered_today' => 0]);
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'update_limit',
'ids' => [$p1->id, $p2->id],
'delta' => 50,
])
->assertOk()
->assertJson(['updated' => 2, 'skipped' => [], 'warnings' => []]);
expect($p1->fresh()->daily_limit_target)->toBe(150);
expect($p2->fresh()->daily_limit_target)->toBe(250);
});
it('skips projects when limit delta would drop below delivered_today', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$p1 = Project::factory()->for($tenant)->create(['daily_limit_target' => 100, 'delivered_today' => 80]);
$p2 = Project::factory()->for($tenant)->create(['daily_limit_target' => 50, 'delivered_today' => 30]);
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'update_limit',
'ids' => [$p1->id, $p2->id],
'delta' => -40, // p1: 100-40=60 < 80 → SKIP; p2: 50-40=10 < 30 → SKIP
])
->assertOk()
->assertJson([
'updated' => 0,
'skipped' => [
['id' => $p1->id, 'reason' => 'below_delivered_today'],
['id' => $p2->id, 'reason' => 'below_delivered_today'],
],
]);
expect($p1->fresh()->daily_limit_target)->toBe(100); // unchanged
expect($p2->fresh()->daily_limit_target)->toBe(50);
});
it('applies update_limit replace with skip for conflicts', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$p1 = Project::factory()->for($tenant)->create(['daily_limit_target' => 100, 'delivered_today' => 30]);
$p2 = Project::factory()->for($tenant)->create(['daily_limit_target' => 200, 'delivered_today' => 150]);
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'update_limit',
'ids' => [$p1->id, $p2->id],
'replace' => 100, // p1: ok (100>=30); p2: 100<150 → skip
])
->assertOk()
->assertJson([
'updated' => 1,
'skipped' => [['id' => $p2->id, 'reason' => 'below_delivered_today']],
]);
expect($p1->fresh()->daily_limit_target)->toBe(100);
expect($p2->fresh()->daily_limit_target)->toBe(200);
});
it('resolves scope.filter to project ids and applies action', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
Project::factory()->for($tenant)->asSiteSignal('example.com')->count(3)->create(['is_active' => true]);
Project::factory()->for($tenant)->asSmsSignal(['SENDER'])->count(2)->create(['is_active' => true]);
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'pause',
'scope' => ['filter' => ['signal_type' => 'site']],
])
->assertOk()
->assertJson(['updated' => 3]);
expect(Project::where('tenant_id', $tenant->id)->where('signal_type', 'site')->where('is_active', false)->count())->toBe(3);
expect(Project::where('tenant_id', $tenant->id)->where('signal_type', 'sms')->where('is_active', true)->count())->toBe(2);
});
it('rejects bulk when scope.filter captures more than 500 projects', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
Project::factory()->for($tenant)->count(501)->create();
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'pause',
'scope' => ['filter' => []],
])
->assertStatus(422)
->assertJsonValidationErrors(['scope']);
});
it('does not affect projects of other tenants (RLS)', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
$userA = User::factory()->for($tenantA)->create();
$pA = Project::factory()->for($tenantA)->create(['is_active' => true]);
$pB = Project::factory()->for($tenantB)->create(['is_active' => true]);
$this->actingAs($userA)
->postJson('/api/projects/bulk', [
'action' => 'pause',
'ids' => [$pA->id, $pB->id],
])
->assertOk()
->assertJson(['updated' => 1]); // Only tenant A's project
expect($pA->fresh()->is_active)->toBeFalse();
expect($pB->fresh()->is_active)->toBeTrue(); // unchanged
});
it('returns 0 updated when ids empty after filter resolution', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'pause',
'scope' => ['filter' => ['signal_type' => 'site']], // no projects exist
])
->assertOk()
->assertJson(['updated' => 0]);
});