Files
portal/app/tests/Feature/Api/ProjectBulkActionsTest.php
T
Дмитрий 54451d2ea6 feat(projects): RegionsBulkDialog — subject-level regions (89 RF subjects) #1426
Bulk regions dialog reworked from federal-district bitmask to subject/region
selection, consistent with ProjectDetailsDrawer/NewProjectDialog. Full-stack:
add_regions/remove_regions on projects.regions INT[], BulkProjectActionRequest
split validation, ProjectService model-instance update. federal-districts.ts
removed (zero consumers). +menuRepositionFix util for v-autocomplete menu.
phpstan-baseline: bump actingAs ignore count 14->15 (new validation test).

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

255 lines
9.3 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;
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]);
});