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>
215 lines
7.9 KiB
TypeScript
215 lines
7.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
import { mount, flushPromises } from '@vue/test-utils';
|
||
import { createVuetify } from 'vuetify';
|
||
import { createPinia, setActivePinia } from 'pinia';
|
||
import DealDetailDrawer from '../../resources/js/components/deals/DealDetailDrawer.vue';
|
||
import { MOCK_DEALS } from '../../resources/js/composables/mockDeals';
|
||
import { MOCK_EVENTS } from '../../resources/js/composables/mockDealEvents';
|
||
import type { GetDealResponse, ApiDealEvent } from '../../resources/js/api/deals';
|
||
|
||
vi.mock('../../resources/js/api/deals', async (importOriginal) => {
|
||
const orig = await importOriginal<typeof import('../../resources/js/api/deals')>();
|
||
return {
|
||
...orig,
|
||
getDeal: vi.fn(),
|
||
updateDeal: vi.fn(),
|
||
};
|
||
});
|
||
|
||
const dealsApi = await import('../../resources/js/api/deals');
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
setActivePinia(createPinia());
|
||
});
|
||
|
||
const factory = (props: { open: boolean; tenantId?: number }) =>
|
||
mount(DealDetailDrawer, {
|
||
props: { deal: MOCK_DEALS[0], ...props },
|
||
global: {
|
||
plugins: [createVuetify()],
|
||
stubs: {
|
||
VNavigationDrawer: {
|
||
template: '<div class="drawer-stub" v-if="modelValue"><slot /></div>',
|
||
props: ['modelValue'],
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
function makeApiEvent(overrides: Partial<ApiDealEvent> = {}): ApiDealEvent {
|
||
return {
|
||
id: 100,
|
||
event: 'deal.created',
|
||
context: { source: 'webhook' },
|
||
created_at: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
|
||
actor: null,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
|
||
it('БЕЗ tenantId — getDeal не вызывается, показываются MOCK_EVENTS', async () => {
|
||
const wrapper = factory({ open: true });
|
||
await flushPromises();
|
||
|
||
expect(dealsApi.getDeal).not.toHaveBeenCalled();
|
||
const items = wrapper.findAll('.timeline-item');
|
||
expect(items).toHaveLength(MOCK_EVENTS.length);
|
||
});
|
||
|
||
it('С tenantId — getDeal вызывается, events заменяются на API', async () => {
|
||
const apiResponse: GetDealResponse = {
|
||
deal: {
|
||
id: MOCK_DEALS[0].id,
|
||
tenant_id: 1,
|
||
project_id: 5,
|
||
project_name: 'Окна Москва',
|
||
phone: '+7 (999) 100-00-01',
|
||
contact_name: 'Anna',
|
||
comment: null,
|
||
status: 'new',
|
||
manager_id: null,
|
||
manager_name: null,
|
||
manager_initials: null,
|
||
received_at: new Date().toISOString(),
|
||
assigned_at: null,
|
||
},
|
||
events: [
|
||
makeApiEvent({ id: 1, event: 'deal.created', context: { source: 'webhook' } }),
|
||
makeApiEvent({
|
||
id: 2,
|
||
event: 'deal.status_changed',
|
||
context: { from: 'new', to: 'paid' },
|
||
actor: { id: 1, name: 'Иван П.', initials: 'ИП' },
|
||
}),
|
||
],
|
||
};
|
||
vi.mocked(dealsApi.getDeal).mockResolvedValueOnce(apiResponse);
|
||
|
||
const wrapper = factory({ open: true, tenantId: 1 });
|
||
await flushPromises();
|
||
|
||
expect(dealsApi.getDeal).toHaveBeenCalledWith(MOCK_DEALS[0].id, 1);
|
||
const items = wrapper.findAll('.timeline-item');
|
||
expect(items).toHaveLength(2);
|
||
// status_changed event имеет detail "new → paid".
|
||
expect(wrapper.text()).toContain('new → paid');
|
||
});
|
||
|
||
it('getDeal reject → eventsFetchError=true, alert виден, MOCK_EVENTS как fallback', async () => {
|
||
vi.mocked(dealsApi.getDeal).mockRejectedValueOnce(new Error('500'));
|
||
|
||
const wrapper = factory({ open: true, tenantId: 1 });
|
||
await flushPromises();
|
||
|
||
const vm = wrapper.vm as unknown as { eventsFetchError: boolean };
|
||
expect(vm.eventsFetchError).toBe(true);
|
||
expect(wrapper.find('[data-testid="events-fetch-error-alert"]').exists()).toBe(true);
|
||
// Fallback на MOCK_EVENTS.
|
||
const items = wrapper.findAll('.timeline-item');
|
||
expect(items).toHaveLength(MOCK_EVENTS.length);
|
||
});
|
||
|
||
it('open=false → getDeal не вызывается', async () => {
|
||
factory({ open: false, tenantId: 1 });
|
||
await flushPromises();
|
||
expect(dealsApi.getDeal).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('saveComment вызывает updateDeal + toast success + reload events', async () => {
|
||
const apiResponse: GetDealResponse = {
|
||
deal: {
|
||
id: MOCK_DEALS[0].id,
|
||
tenant_id: 1,
|
||
project_id: 5,
|
||
project_name: 'Окна',
|
||
phone: '+7 (999) 100-00-01',
|
||
contact_name: 'Anna',
|
||
comment: 'old',
|
||
status: 'new',
|
||
manager_id: null,
|
||
manager_name: null,
|
||
manager_initials: null,
|
||
received_at: new Date().toISOString(),
|
||
assigned_at: null,
|
||
},
|
||
events: [],
|
||
};
|
||
vi.mocked(dealsApi.getDeal).mockResolvedValue(apiResponse);
|
||
vi.mocked(dealsApi.updateDeal).mockResolvedValueOnce({
|
||
...apiResponse.deal,
|
||
comment: 'новая заметка',
|
||
});
|
||
|
||
const wrapper = factory({ open: true, tenantId: 1 });
|
||
await flushPromises();
|
||
|
||
const vm = wrapper.vm as unknown as {
|
||
commentDraft: string;
|
||
saveComment: () => Promise<void>;
|
||
commentToastOpen: boolean;
|
||
commentToastText: string;
|
||
commentSaveError: boolean;
|
||
};
|
||
expect(vm.commentDraft).toBe('old'); // загружено из getDeal
|
||
vm.commentDraft = 'новая заметка';
|
||
await vm.saveComment();
|
||
await flushPromises();
|
||
|
||
expect(dealsApi.updateDeal).toHaveBeenCalledWith(MOCK_DEALS[0].id, {
|
||
tenant_id: 1,
|
||
comment: 'новая заметка',
|
||
});
|
||
expect(vm.commentToastOpen).toBe(true);
|
||
expect(vm.commentToastText).toContain('сохранён');
|
||
expect(vm.commentSaveError).toBe(false);
|
||
// reload events после save — getDeal вызвался ещё раз.
|
||
expect(dealsApi.getDeal).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it('saveComment reject → toast warning + commentSaveError=true', async () => {
|
||
vi.mocked(dealsApi.getDeal).mockResolvedValueOnce({
|
||
deal: {
|
||
id: MOCK_DEALS[0].id,
|
||
tenant_id: 1,
|
||
project_id: 5,
|
||
project_name: null,
|
||
phone: '+7 (999)',
|
||
contact_name: null,
|
||
comment: null,
|
||
status: 'new',
|
||
manager_id: null,
|
||
manager_name: null,
|
||
manager_initials: null,
|
||
received_at: null,
|
||
assigned_at: null,
|
||
},
|
||
events: [],
|
||
});
|
||
vi.mocked(dealsApi.updateDeal).mockRejectedValueOnce(new Error('500'));
|
||
|
||
const wrapper = factory({ open: true, tenantId: 1 });
|
||
await flushPromises();
|
||
|
||
const vm = wrapper.vm as unknown as {
|
||
commentDraft: string;
|
||
saveComment: () => Promise<void>;
|
||
commentSaveError: boolean;
|
||
commentToastText: string;
|
||
};
|
||
vm.commentDraft = 'новый';
|
||
await vm.saveComment();
|
||
await flushPromises();
|
||
|
||
expect(vm.commentSaveError).toBe(true);
|
||
expect(vm.commentToastText).toContain('Не удалось');
|
||
});
|
||
|
||
it('comment-section не показывается без tenantId (read-only mode)', async () => {
|
||
const wrapper = factory({ open: true });
|
||
await flushPromises();
|
||
expect(wrapper.find('[data-testid="comment-section"]').exists()).toBe(false);
|
||
});
|
||
});
|