Files
portal/app/tests/Feature/Api/ProjectBulkActionsTest.php
T
Дмитрий 88ace4e3d9
Accessibility (Pa11y live) / a11y (push) Has been cancelled
test: дозакрытие оздоровления — protekateli pd-аудита, видимость supplier, новый флоу регистрации
Снижение остатка 19 to 5. Всё тест-сторона:
- PdErasureServiceTest + AdminPdSubjectRequestsControllerTest: SharesSupplierPdo —
  перестали коммитить pd_processing_log через pgsql_supplier, что ломало
  глобальный audit:verify-chains (6 падений) и амплифицировало PhoneRegionSmoke.
- ReportFileDeletePdLogTest: SharesSupplierPdo — cron reports:cleanup-expired
  теперь видит незакоммиченные job'ы теста.
- AdminSuppliersControllerTest: устойчивый ассерт (с фазы 3 в suppliers есть direct).
- AuthLogCoverageTest/AuthFlowIntegrationTest: новый флоу самозаписи G1/SP1 —
  register_success пишется после confirm-email; добавлен шаг подтверждения.
- ImpersonationTest end: verify (G7-B) ставит маркер impersonation → admin-зона
  закрыта by design; помечаем токен used напрямую вместо session-takeover.
- CleanupInactiveSupplierProjectsJobTest: phase A читает pivot project_supplier_links —
  добавлена привязка linkProjectToSupplier (раньше был только legacy FK).
- Pint-нормализация uses() FQN to import в ранее тронутых файлах.

Остаток 5 (НЕ слепой патч): webhook B-префикс ×2 (решение владельца), advisory-lock
audit-цепочки (возможный дрейф схемы, флажок), SupplierConnection WARN#2 (cap-3,
поведенческое), SupplierPortalClientTest (пре-существующий, не от этих правок).

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

258 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;
use Illuminate\Foundation\Testing\DatabaseTransactions;
uses(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]);
});