7e1bf8b42d
Drawer из read-only становится editable. ActivityLog event пишется на
каждое изменение поля.
Backend (DealController::update):
- PATCH /api/deals/{id} {tenant_id, comment?, manager_id?, status?}.
- Каждое изменённое поле → ActivityLog:
comment → deal.commented (context.text);
manager_id → deal.assigned (context.from/to + assigned_at=NOW);
status → deal.status_changed (context.from/to/source='manual').
- NO-OP не пишется в audit. Manager FK guard + status slug validation.
- RLS + defense-in-depth where(tenant_id) → 404 для чужой сделки.
Pest +10 (DealUpdateTest):
- 422/404 базовые / 404 чужая сделка / comment+audit / manager+audit+
assigned_at / status+audit / 422 неизвестный slug / 422 чужой manager /
NO-OP не пишет / комбинированно → 2 audit записи.
Frontend:
- api/deals.ts::updateDeal — PATCH helper c ensureCsrfCookie.
- DealDetailDrawer: новая секция «Комментарий» (только при tenantId).
v-textarea auto-grow + counter=5000 + Save-btn → updateDeal →
toast success + reload events (новый deal.commented в timeline).
На fail → warning toast.
Vitest +3 (DealDetailDrawerApi):
- saveComment вызывает updateDeal + toast + reload events (getDeal x2).
- saveComment reject → commentSaveError + warning toast.
- comment-section не рендерится без tenantId.
PHPStan baseline регенерирован.
Регресс:
- Lint+type-check+format passed.
- Vitest 283/283 за 18.13 сек (+3 от 280).
- Vite build 1.12 сек.
- Pint + PHPStan passed.
- Pest 220/220 за 25.64 сек (+10 от 210, 871 assertion).
Реестр v1.64→v1.65 / CLAUDE.md v1.55→v1.56.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
171 lines
6.5 KiB
PHP
171 lines
6.5 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Models\ActivityLog;
|
||
use App\Models\Deal;
|
||
use App\Models\Project;
|
||
use App\Models\Tenant;
|
||
use App\Models\User;
|
||
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();
|
||
$this->manager = User::factory()->for($this->tenant)->create(['is_active' => true]);
|
||
});
|
||
|
||
test('PATCH /api/deals/{id} 422 без tenant_id', function () {
|
||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||
$this->patchJson('/api/deals/'.$deal->id, [])->assertStatus(422);
|
||
});
|
||
|
||
test('PATCH /api/deals/{id} 404 unknown tenant', function () {
|
||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||
$this->patchJson('/api/deals/'.$deal->id, [
|
||
'tenant_id' => 999999,
|
||
'comment' => 'X',
|
||
])->assertStatus(404);
|
||
});
|
||
|
||
test('PATCH /api/deals/{id} 404 чужая сделка', 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();
|
||
|
||
$this->patchJson('/api/deals/'.$foreign->id, [
|
||
'tenant_id' => $this->tenant->id,
|
||
'comment' => 'leak',
|
||
])->assertStatus(404);
|
||
});
|
||
|
||
test('PATCH /api/deals/{id} обновляет comment + пишет deal.commented в ActivityLog', function () {
|
||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['comment' => 'old']);
|
||
|
||
$r = $this->patchJson('/api/deals/'.$deal->id, [
|
||
'tenant_id' => $this->tenant->id,
|
||
'comment' => 'Дозвонился, перезвоню после 14:00',
|
||
]);
|
||
$r->assertStatus(200);
|
||
expect($r->json('deal.comment'))->toBe('Дозвонился, перезвоню после 14:00');
|
||
|
||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||
$deal->refresh();
|
||
expect($deal->comment)->toBe('Дозвонился, перезвоню после 14:00');
|
||
|
||
$log = ActivityLog::where('deal_id', $deal->id)->where('event', 'deal.commented')->first();
|
||
expect($log)->not->toBeNull();
|
||
expect($log->context['text'])->toBe('Дозвонился, перезвоню после 14:00');
|
||
});
|
||
|
||
test('PATCH /api/deals/{id} обновляет manager_id + пишет deal.assigned + ставит assigned_at', function () {
|
||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create([
|
||
'manager_id' => null,
|
||
'assigned_at' => null,
|
||
]);
|
||
|
||
$r = $this->patchJson('/api/deals/'.$deal->id, [
|
||
'tenant_id' => $this->tenant->id,
|
||
'manager_id' => $this->manager->id,
|
||
]);
|
||
$r->assertStatus(200);
|
||
|
||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||
$deal->refresh();
|
||
expect($deal->manager_id)->toBe($this->manager->id);
|
||
expect($deal->assigned_at)->not->toBeNull();
|
||
|
||
$log = ActivityLog::where('deal_id', $deal->id)->where('event', 'deal.assigned')->first();
|
||
expect($log)->not->toBeNull();
|
||
expect($log->context['to'])->toBe($this->manager->id);
|
||
});
|
||
|
||
test('PATCH /api/deals/{id} обновляет status + пишет deal.status_changed source=manual', function () {
|
||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
|
||
|
||
$r = $this->patchJson('/api/deals/'.$deal->id, [
|
||
'tenant_id' => $this->tenant->id,
|
||
'status' => 'paid',
|
||
]);
|
||
$r->assertStatus(200);
|
||
|
||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||
$deal->refresh();
|
||
expect($deal->status)->toBe('paid');
|
||
|
||
$log = ActivityLog::where('deal_id', $deal->id)->where('event', 'deal.status_changed')->first();
|
||
expect($log)->not->toBeNull();
|
||
expect($log->context)->toMatchArray(['from' => 'new', 'to' => 'paid', 'source' => 'manual']);
|
||
});
|
||
|
||
test('PATCH /api/deals/{id} 422 на неизвестный status slug', function () {
|
||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||
|
||
$r = $this->patchJson('/api/deals/'.$deal->id, [
|
||
'tenant_id' => $this->tenant->id,
|
||
'status' => 'not_a_real_slug',
|
||
]);
|
||
$r->assertStatus(422);
|
||
|
||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||
$deal->refresh();
|
||
expect($deal->status)->toBe('new'); // не изменился
|
||
});
|
||
|
||
test('PATCH /api/deals/{id} 422 на manager_id чужого tenant\'а', function () {
|
||
DB::statement('SET app.current_tenant_id = '.$this->otherTenant->id);
|
||
$foreignManager = User::factory()->for($this->otherTenant)->create(['is_active' => true]);
|
||
|
||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||
|
||
$r = $this->patchJson('/api/deals/'.$deal->id, [
|
||
'tenant_id' => $this->tenant->id,
|
||
'manager_id' => $foreignManager->id,
|
||
]);
|
||
$r->assertStatus(422);
|
||
});
|
||
|
||
test('PATCH /api/deals/{id} NO-OP не пишет ActivityLog', function () {
|
||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create([
|
||
'status' => 'paid',
|
||
'comment' => 'same',
|
||
]);
|
||
|
||
$r = $this->patchJson('/api/deals/'.$deal->id, [
|
||
'tenant_id' => $this->tenant->id,
|
||
'status' => 'paid', // не меняем
|
||
'comment' => 'same', // не меняем
|
||
]);
|
||
$r->assertStatus(200);
|
||
|
||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||
expect(ActivityLog::where('deal_id', $deal->id)->count())->toBe(0);
|
||
});
|
||
|
||
test('PATCH /api/deals/{id} комбинированно — comment + status одним запросом → 2 ActivityLog', function () {
|
||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create([
|
||
'status' => 'new',
|
||
'comment' => null,
|
||
]);
|
||
|
||
$r = $this->patchJson('/api/deals/'.$deal->id, [
|
||
'tenant_id' => $this->tenant->id,
|
||
'comment' => 'Заметка',
|
||
'status' => 'worked',
|
||
]);
|
||
$r->assertStatus(200);
|
||
|
||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||
$events = ActivityLog::where('deal_id', $deal->id)->orderBy('id')->get();
|
||
expect($events)->toHaveCount(2);
|
||
$eventNames = $events->pluck('event')->all();
|
||
expect($eventNames)->toContain('deal.commented');
|
||
expect($eventNames)->toContain('deal.status_changed');
|
||
});
|