Files
portal/app/tests/Feature/DealTransitionTest.php
T
Дмитрий ac186593f2 phase2(bulk-transition+reload): POST /api/deals/transition + reload-btn
Закрывает gap «UI меняет статус, но изменения не сохраняются на backend»
из v1.51. Reload-btn заменяет polling/SSE до прихода long-poll'а на prod.

Backend (DealController::transition):
- POST /api/deals/transition {tenant_id, ids: [1..1000 ints], status}.
- Валидация status — exists в lead_statuses (глобальная таблица).
- RLS-обёртка SET LOCAL + defense-in-depth where(tenant_id) для
  partial-update: чужие id остаются в исходном статусе.
- ActivityLog event=deal.status_changed с context={from, to, source: 'bulk'}
  для каждой ИЗМЕНЁННОЙ сделки. NO-OP (старый==новый) не пишется в audit.
- Ответ: {updated, requested, status}.

Pest +7 (DealTransitionTest):
- 422 missing fields / 404 unknown tenant / 422 неизвестный slug + не апдейт /
  batch update 3 сделок + 3 ActivityLog с правильным context /
  NO-OP не пишет ActivityLog / defense-in-depth (2 tenant'а — обновляется
  только свой) / 422 пустой массив ids.

Frontend:
- dealsApi.transitionDeals — типизированный helper с ensureCsrfCookie.
- applyBulkStatus в DealsView переписан async: optimistic local-update +
  backend-вызов если auth.user.tenant_id. На success — toast «Обновлено
  N из M.», на fail — warning toast + локальный update НЕ откатывается.
  Без auth.user — только optimistic (legacy local-mode сохранён).
- reload-btn в DealsView и KanbanView — outlined «Обновить» mdi-refresh,
  привязан к loadDeals. В DealsView :loading="loading" во время fetch'а.

Vitest +5:
- reload-btn (Deals + Kanban) — listDeals вызывается дважды.
- applyBulkStatus с tenant_id — transitionDeals + optimistic + toast.
- applyBulkStatus без tenant_id — НЕ вызывается transitionDeals.
- applyBulkStatus reject — toast warning + локальный update остаётся.

PHPStan baseline регенерирован. cspell-glossary +апдейт*.

Регресс:
- Lint+type-check+format passed.
- Vitest 266/266 за 18.16 сек (+5 от 261).
- Vite build 1.06 сек.
- Pint + PHPStan passed.
- Pest 193/193 за 23.27 сек (+7 от 186, 767 assertions).

Реестр v1.60→v1.61 / CLAUDE.md v1.51→v1.52.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 07:46:19 +03:00

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;
/**
* Тесты POST /api/deals/transition — bulk status-update для DealsView bulk-actions.
*
* Покрывает: validation (422 на missing/неизвестный slug), RLS+app-фильтр
* (чужие сделки НЕ обновляются), ActivityLog event=deal.status_changed,
* 404 unknown tenant, NO-OP не пишет audit entry, partial update (несколько id
* принадлежат tenant'у, один — нет → updated < requested).
*/
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/transition — 422 без обязательных полей', function () {
$this->postJson('/api/deals/transition', [])->assertStatus(422);
});
test('POST /api/deals/transition — 404 на unknown tenant', function () {
$r = $this->postJson('/api/deals/transition', [
'tenant_id' => 999999,
'ids' => [1],
'status' => 'paid',
]);
$r->assertStatus(404);
});
test('POST /api/deals/transition — 422 на неизвестный status slug', function () {
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
$r = $this->postJson('/api/deals/transition', [
'tenant_id' => $this->tenant->id,
'ids' => [$deal->id],
'status' => 'not_a_real_slug',
]);
$r->assertStatus(422);
expect($r->json('errors.status.0'))->toContain('lead_statuses');
// Сделка осталась в исходном статусе.
$deal->refresh();
expect($deal->status)->toBe('new');
});
test('POST /api/deals/transition — обновляет статус и пишет ActivityLog', function () {
$deals = Deal::factory()->count(3)->for($this->tenant)->for($this->project)->create(['status' => 'new']);
$r = $this->postJson('/api/deals/transition', [
'tenant_id' => $this->tenant->id,
'ids' => $deals->pluck('id')->all(),
'status' => 'paid',
]);
$r->assertStatus(200)->assertJson([
'updated' => 3,
'requested' => 3,
'status' => 'paid',
]);
foreach ($deals as $d) {
$d->refresh();
expect($d->status)->toBe('paid');
}
$activity = ActivityLog::where('tenant_id', $this->tenant->id)
->where('event', ActivityLog::EVENT_DEAL_STATUS_CHANGED)
->get();
expect($activity)->toHaveCount(3);
expect($activity->first()->context)->toMatchArray([
'from' => 'new',
'to' => 'paid',
'source' => 'bulk',
]);
});
test('POST /api/deals/transition — NO-OP не пишет ActivityLog', function () {
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']);
$r = $this->postJson('/api/deals/transition', [
'tenant_id' => $this->tenant->id,
'ids' => [$deal->id],
'status' => 'paid',
]);
$r->assertStatus(200)->assertJson(['updated' => 0, 'requested' => 1]);
expect(ActivityLog::where('deal_id', $deal->id)
->where('event', ActivityLog::EVENT_DEAL_STATUS_CHANGED)
->count())->toBe(0);
});
test('POST /api/deals/transition — defense-in-depth не апдейтит чужие сделки', function () {
$own = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
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(['status' => 'new']);
// Передаём оба id, но tenant_id указываем наш — чужой не должен обновиться.
$r = $this->postJson('/api/deals/transition', [
'tenant_id' => $this->tenant->id,
'ids' => [$own->id, $foreign->id],
'status' => 'paid',
]);
$r->assertStatus(200)->assertJson([
'updated' => 1,
'requested' => 2,
]);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$own->refresh();
expect($own->status)->toBe('paid');
DB::statement('SET app.current_tenant_id = '.$this->otherTenant->id);
$foreign->refresh();
expect($foreign->status)->toBe('new');
});
test('POST /api/deals/transition — 422 если ids пустой массив', function () {
$this->postJson('/api/deals/transition', [
'tenant_id' => $this->tenant->id,
'ids' => [],
'status' => 'paid',
])->assertStatus(422);
});