c34d4009d1
Soft-delete был half-done: пользователь не мог отменить случайное удаление.
Теперь после bulk-delete показывается snackbar «Удалено N · Восстановить»
на 8 секунд.
Backend (DealController::restore):
- POST /api/deals/restore {tenant_id, ids: [1..1000 ints]}.
- withTrashed() обходит global scope SoftDeletes + явный
whereNotNull('deleted_at') для NO-OP idempotency на живых.
- RLS + defense-in-depth where(tenant_id).
- ActivityLog event=deal.restored, context.source='bulk' для каждой
ВОССТАНОВЛЕННОЙ. Константа EVENT_DEAL_RESTORED добавлена в модель.
Pest +7 (DealRestoreTest):
- 422/404 базовые / soft-delete + restore + audit / NO-OP на живых
не пишет audit / defense-in-depth (свой restored, чужой остался) /
после restore видна в GET /api/deals / 422 пустой массив.
Frontend:
- dealsApi.bulkRestoreDeals — POST-helper.
- DealsView::applyBulkDelete: snapshot удалённых сделок (deep-clone
manager.*) сохраняется в lastDeletedSnapshot ref.
- undoBulkDelete() async: optimistic re-insert + bulkRestoreDeals если
auth.user; success → toast «Восстановлено N»; fail → warning.
- v-snackbar bulk-delete: 3→8 сек timeout + #actions слот с кнопкой
«Восстановить» (показ только при snapshot.length > 0). После undo
snapshot очищается → кнопка пропадает.
Vitest +3 (DealsListIntegration):
- bulk-delete + undo восстанавливает обе + bulkRestoreDeals + cleanup
snapshot.
- Undo без tenant_id — НЕ вызывает API + только локально.
- Undo reject → warning toast + локальное восстановление остаётся.
PHPStan baseline регенерирован. cspell-glossary +unshift +партиальный.
Регресс:
- Lint+type-check+format passed.
- Vitest 311/311 за 18.71 сек (+3 от 308).
- Vite build 877 ms.
- Pint + PHPStan passed.
- Pest 263/263 за 27.68 сек (+7 от 256, 998 assertions).
Реестр v1.69→v1.70 / CLAUDE.md v1.60→v1.61.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
136 lines
4.7 KiB
PHP
136 lines
4.7 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('POST /api/deals/restore 422 без обязательных полей', function () {
|
|
$this->postJson('/api/deals/restore', [])->assertStatus(422);
|
|
});
|
|
|
|
test('POST /api/deals/restore 404 на unknown tenant', function () {
|
|
$r = $this->postJson('/api/deals/restore', [
|
|
'tenant_id' => 999999,
|
|
'ids' => [1],
|
|
]);
|
|
$r->assertStatus(404);
|
|
});
|
|
|
|
test('POST /api/deals/restore восстанавливает soft-deleted + пишет deal.restored', 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);
|
|
|
|
// Восстановим
|
|
$r = $this->postJson('/api/deals/restore', [
|
|
'tenant_id' => $this->tenant->id,
|
|
'ids' => [$deal->id],
|
|
]);
|
|
$r->assertStatus(200)->assertJson([
|
|
'restored' => 1,
|
|
'requested' => 1,
|
|
]);
|
|
|
|
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
|
expect(DB::table('deals')->where('id', $deal->id)->value('deleted_at'))->toBeNull();
|
|
|
|
$log = ActivityLog::where('deal_id', $deal->id)
|
|
->where('event', 'deal.restored')
|
|
->first();
|
|
expect($log)->not->toBeNull();
|
|
expect($log->context)->toMatchArray(['source' => 'bulk']);
|
|
});
|
|
|
|
test('POST /api/deals/restore NO-OP для не-удалённых (живых) сделок', function () {
|
|
$alive = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
|
|
|
$r = $this->postJson('/api/deals/restore', [
|
|
'tenant_id' => $this->tenant->id,
|
|
'ids' => [$alive->id],
|
|
]);
|
|
$r->assertStatus(200)->assertJson([
|
|
'restored' => 0,
|
|
'requested' => 1,
|
|
]);
|
|
|
|
// Не пишем audit для NO-OP.
|
|
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
|
expect(ActivityLog::where('deal_id', $alive->id)->where('event', 'deal.restored')->count())->toBe(0);
|
|
});
|
|
|
|
test('POST /api/deals/restore defense-in-depth не восстанавливает чужие сделки', function () {
|
|
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();
|
|
$foreign->delete(); // soft-delete
|
|
|
|
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
|
$own = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
|
$own->delete();
|
|
|
|
$r = $this->postJson('/api/deals/restore', [
|
|
'tenant_id' => $this->tenant->id,
|
|
'ids' => [$own->id, $foreign->id],
|
|
]);
|
|
$r->assertStatus(200)->assertJson([
|
|
'restored' => 1,
|
|
'requested' => 2,
|
|
]);
|
|
|
|
// Свой жив (deleted_at=NULL).
|
|
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
|
expect(DB::table('deals')->where('id', $own->id)->value('deleted_at'))->toBeNull();
|
|
|
|
// Чужой остался удалённым.
|
|
DB::statement('SET app.current_tenant_id = '.$this->otherTenant->id);
|
|
expect(DB::table('deals')->where('id', $foreign->id)->value('deleted_at'))->not->toBeNull();
|
|
});
|
|
|
|
test('POST /api/deals/restore — после restore сделка снова видна в GET /api/deals', 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);
|
|
|
|
// GET не возвращает
|
|
expect($this->getJson('/api/deals?tenant_id='.$this->tenant->id)->json('total'))->toBe(0);
|
|
|
|
// Restore
|
|
$this->postJson('/api/deals/restore', [
|
|
'tenant_id' => $this->tenant->id,
|
|
'ids' => [$deal->id],
|
|
])->assertStatus(200);
|
|
|
|
// GET снова возвращает
|
|
expect($this->getJson('/api/deals?tenant_id='.$this->tenant->id)->json('total'))->toBe(1);
|
|
});
|
|
|
|
test('POST /api/deals/restore 422 пустой массив ids', function () {
|
|
$this->postJson('/api/deals/restore', [
|
|
'tenant_id' => $this->tenant->id,
|
|
'ids' => [],
|
|
])->assertStatus(422);
|
|
});
|