2026-05-12 14:38:59 +03:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
use App\Models\Project;
|
|
|
|
|
|
use App\Models\Tenant;
|
|
|
|
|
|
use App\Models\User;
|
|
|
|
|
|
|
2026-05-17 14:35:16 +03:00
|
|
|
|
it('accepts update_regions action with subject-code arrays', function () {
|
2026-05-12 14:38:59 +03:00
|
|
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
|
|
$user = User::factory()->for($tenant)->create();
|
2026-05-17 14:35:16 +03:00
|
|
|
|
$p = Project::factory()->for($tenant)->create(['regions' => [82]]);
|
2026-05-12 14:38:59 +03:00
|
|
|
|
|
|
|
|
|
|
$this->actingAs($user)
|
|
|
|
|
|
->postJson('/api/projects/bulk', [
|
|
|
|
|
|
'action' => 'update_regions',
|
|
|
|
|
|
'ids' => [$p->id],
|
2026-05-17 14:35:16 +03:00
|
|
|
|
'add_regions' => [83, 84], // Санкт-Петербург + Севастополь
|
|
|
|
|
|
'remove_regions' => [82], // Москва
|
2026-05-12 14:38:59 +03:00
|
|
|
|
])
|
|
|
|
|
|
->assertOk()
|
|
|
|
|
|
->assertJsonStructure(['updated', 'skipped', 'warnings']);
|
2026-05-12 14:48:10 +03:00
|
|
|
|
});
|
2026-05-12 14:38:59 +03:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-05-12 14:43:45 +03:00
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
});
|
2026-05-12 14:48:10 +03:00
|
|
|
|
|
2026-05-17 14:35:16 +03:00
|
|
|
|
it('applies update_regions add_regions and remove_regions to the regions array', function () {
|
2026-05-12 14:48:10 +03:00
|
|
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
|
|
$user = User::factory()->for($tenant)->create();
|
2026-05-17 14:35:16 +03:00
|
|
|
|
$p1 = Project::factory()->for($tenant)->create(['regions' => [82, 56]]); // Москва + Московская обл.
|
|
|
|
|
|
$p2 = Project::factory()->for($tenant)->create(['regions' => []]); // вся РФ
|
2026-05-12 14:48:10 +03:00
|
|
|
|
|
|
|
|
|
|
$this->actingAs($user)
|
|
|
|
|
|
->postJson('/api/projects/bulk', [
|
|
|
|
|
|
'action' => 'update_regions',
|
|
|
|
|
|
'ids' => [$p1->id, $p2->id],
|
2026-05-17 14:35:16 +03:00
|
|
|
|
'add_regions' => [83], // Санкт-Петербург
|
|
|
|
|
|
'remove_regions' => [56], // Московская область
|
2026-05-12 14:48:10 +03:00
|
|
|
|
])
|
|
|
|
|
|
->assertOk()
|
|
|
|
|
|
->assertJson(['updated' => 2, 'skipped' => [], 'warnings' => []]);
|
|
|
|
|
|
|
2026-05-17 14:35:16 +03:00
|
|
|
|
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']);
|
2026-05-12 14:48:10 +03:00
|
|
|
|
});
|
2026-05-12 14:52:23 +03:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
});
|
2026-05-12 14:55:45 +03:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-05-12 14:59:59 +03:00
|
|
|
|
|
|
|
|
|
|
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']);
|
|
|
|
|
|
});
|
2026-05-12 15:02:04 +03:00
|
|
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
});
|