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