Files
portal/app/tests/Frontend/DealDetailDrawerApi.spec.ts
T
Дмитрий 7e1bf8b42d phase2(deal-patch): PATCH /api/deals/{id} + comment-editor в DealDetailDrawer (этап 1/5)
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>
2026-05-09 09:10:58 +03:00

215 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});