f0dce283a8
Bulk soft-delete для UI applyBulkDelete. Hard-delete отбракован из-за
CASCADE-FK от webhook_dedup_keys: hard уничтожил бы dedup-ключи и
нарушил идемпотентность webhook §5.5.
Schema v8.8 → v8.9:
- deals.deleted_at TIMESTAMPTZ (NULL = живая).
- Partial index (tenant_id, status) WHERE deleted_at IS NULL —
самый частый UI-фильтр.
- ALTER TABLE на партиционированной deals distributes во все 6
партиций автоматически (PG 14+).
- CHANGELOG +§U с обоснованием soft vs hard.
Backend (DealController::destroy):
- DELETE /api/deals {tenant_id, ids: [1..1000 ints]}.
- Bulk-update deleted_at=NOW() через RLS + defense-in-depth where(tenant_id).
- ActivityLog event=deal.deleted (source='bulk') для каждой ИЗМЕНЁННОЙ.
- NO-OP (уже удалена) не пишет audit.
- Deal model: SoftDeletes trait + deleted_at в fillable/casts. Global
scope автоматически добавляет whereNull('deleted_at') ко всем существующим
query (index/show/transition/update/export).
Pest +8 (DealDestroyTest):
- 422/404 базовые / soft-delete + audit / defense-in-depth (свой
удалён, чужой жив) / NO-OP idempotency / GET скрывает soft-deleted
(list+show 404) / 422 пустой массив.
- Quirk: migrate:fresh --env=testing без .env.testing использует liderra
вместо liderra_testing → решение DB_DATABASE=liderra_testing migrate:fresh.
Frontend:
- dealsApi.bulkDeleteDeals — DELETE-helper с config.data (axios особенность).
- DealsView::applyBulkDelete async: optimistic local-removal +
bulkDeleteDeals если auth.user; success → toast «Удалено N из M.»;
fail → warning toast + локальный update НЕ откатывается.
Vitest +3 (DealsListIntegration):
- bulkDeleteDeals с tenant_id + optimistic + toast.
- Без tenant_id — НЕ вызывается.
- Reject → warning toast + локальный update остаётся.
PHPStan baseline регенерирован.
АВТО-ПЛАН (5 этапов) ЗАКРЫТ ПОЛНОСТЬЮ.
Регресс:
- Lint+type-check+format passed.
- Vitest 308/308 за 20.12 сек (+3 от 305).
- Vite build 973 ms.
- Pint + PHPStan passed.
- Pest 256/256 за 27.75 сек (+8 от 248, 977 assertions).
Реестр v1.68→v1.69 / CLAUDE.md v1.59→v1.60.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
142 lines
4.9 KiB
PHP
142 lines
4.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\ActivityLog;
|
|
use App\Models\Deal;
|
|
use App\Models\Project;
|
|
use App\Models\Tenant;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
uses(DatabaseTransactions::class);
|
|
|
|
beforeEach(function () {
|
|
$this->tenant = Tenant::factory()->create();
|
|
$this->otherTenant = Tenant::factory()->create();
|
|
|
|
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
|
$this->project = Project::factory()->for($this->tenant)->create();
|
|
});
|
|
|
|
test('DELETE /api/deals 422 без обязательных полей', function () {
|
|
$this->deleteJson('/api/deals', [])->assertStatus(422);
|
|
});
|
|
|
|
test('DELETE /api/deals 404 на unknown tenant', function () {
|
|
$r = $this->deleteJson('/api/deals', [
|
|
'tenant_id' => 999999,
|
|
'ids' => [1],
|
|
]);
|
|
$r->assertStatus(404);
|
|
});
|
|
|
|
test('DELETE /api/deals soft-удаляет сделки + пишет deal.deleted ActivityLog', function () {
|
|
$deals = Deal::factory()->count(3)->for($this->tenant)->for($this->project)->create();
|
|
|
|
$r = $this->deleteJson('/api/deals', [
|
|
'tenant_id' => $this->tenant->id,
|
|
'ids' => $deals->pluck('id')->all(),
|
|
]);
|
|
|
|
$r->assertStatus(200)->assertJson([
|
|
'deleted' => 3,
|
|
'requested' => 3,
|
|
]);
|
|
|
|
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
|
foreach ($deals as $d) {
|
|
$row = DB::table('deals')->where('id', $d->id)->first();
|
|
expect($row->deleted_at)->not->toBeNull();
|
|
}
|
|
|
|
$logs = ActivityLog::where('tenant_id', $this->tenant->id)
|
|
->where('event', 'deal.deleted')
|
|
->get();
|
|
expect($logs)->toHaveCount(3);
|
|
expect($logs->first()->context)->toMatchArray(['source' => 'bulk']);
|
|
});
|
|
|
|
test('DELETE /api/deals defense-in-depth не удаляет чужие сделки', function () {
|
|
$own = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
|
|
|
DB::statement('SET app.current_tenant_id = '.$this->otherTenant->id);
|
|
$foreignProject = Project::factory()->for($this->otherTenant)->create();
|
|
$foreign = Deal::factory()->for($this->otherTenant)->for($foreignProject)->create();
|
|
|
|
$r = $this->deleteJson('/api/deals', [
|
|
'tenant_id' => $this->tenant->id,
|
|
'ids' => [$own->id, $foreign->id],
|
|
]);
|
|
|
|
$r->assertStatus(200)->assertJson([
|
|
'deleted' => 1,
|
|
'requested' => 2,
|
|
]);
|
|
|
|
// Свой удалён.
|
|
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
|
expect(DB::table('deals')->where('id', $own->id)->value('deleted_at'))->not->toBeNull();
|
|
|
|
// Чужой жив.
|
|
DB::statement('SET app.current_tenant_id = '.$this->otherTenant->id);
|
|
expect(DB::table('deals')->where('id', $foreign->id)->value('deleted_at'))->toBeNull();
|
|
});
|
|
|
|
test('DELETE /api/deals NO-OP на уже удалённых', function () {
|
|
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
|
|
|
// Первое удаление
|
|
$this->deleteJson('/api/deals', [
|
|
'tenant_id' => $this->tenant->id,
|
|
'ids' => [$deal->id],
|
|
])->assertStatus(200)->assertJson(['deleted' => 1]);
|
|
|
|
// Повтор — уже удалена, NO-OP.
|
|
$r = $this->deleteJson('/api/deals', [
|
|
'tenant_id' => $this->tenant->id,
|
|
'ids' => [$deal->id],
|
|
]);
|
|
$r->assertStatus(200)->assertJson(['deleted' => 0, 'requested' => 1]);
|
|
|
|
// ActivityLog — только 1 запись (после первого удаления).
|
|
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
|
expect(ActivityLog::where('deal_id', $deal->id)->where('event', 'deal.deleted')->count())->toBe(1);
|
|
});
|
|
|
|
test('GET /api/deals НЕ возвращает soft-deleted сделки', function () {
|
|
$alive = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
|
$deleted = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
|
|
|
// Удаляем одну
|
|
$this->deleteJson('/api/deals', [
|
|
'tenant_id' => $this->tenant->id,
|
|
'ids' => [$deleted->id],
|
|
])->assertStatus(200);
|
|
|
|
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id);
|
|
$ids = collect($r->json('deals'))->pluck('id')->all();
|
|
expect($ids)->toContain($alive->id);
|
|
expect($ids)->not->toContain($deleted->id);
|
|
expect($r->json('total'))->toBe(1);
|
|
});
|
|
|
|
test('GET /api/deals/{id} 404 для soft-deleted сделки', function () {
|
|
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
|
|
|
$this->deleteJson('/api/deals', [
|
|
'tenant_id' => $this->tenant->id,
|
|
'ids' => [$deal->id],
|
|
])->assertStatus(200);
|
|
|
|
$this->getJson('/api/deals/'.$deal->id.'?tenant_id='.$this->tenant->id)
|
|
->assertStatus(404);
|
|
});
|
|
|
|
test('DELETE /api/deals 422 пустой массив ids', function () {
|
|
$this->deleteJson('/api/deals', [
|
|
'tenant_id' => $this->tenant->id,
|
|
'ids' => [],
|
|
])->assertStatus(422);
|
|
});
|