83bb9de2bb
3 backend-completion после tightening v1.56.
(1) POST /api/deals — manual create endpoint:
- DealController::store. Project firstOrCreate (type='manual'). Deal с
source_crm_id=NULL. RLS-обёрнутая транзакция.
- Manual НЕ списывает баланс / НЕ дедуп / НЕ SupplierLeadCost.
ActivityLog с context.source=manual.
- NewDealDialog получил optional tenantId prop. С tenantId — POST → backend-id;
на error fallback на local-id + warning + dialog open.
- DealsView/KanbanView передают auth.user?.tenant_id.
- Pest +8.
(2) webhook_hmac_required flag в system_settings:
- Seed-row в db/schema.sql (default false backward-compat).
- WebhookReceiveController::isHmacRequired private helper.
- При true: запрос без X-Webhook-Signature → 401.
- Pest +3.
(3) POST /api/deals/export — backend CSV:
- DealController::export. Валидация ids[1-10000]. RLS-обёрнутый whereIn.
- Excel-friendly CSV: BOM "\u{FEFF}" PHP-литерал, ; разделитель, \r\n.
- text/csv attachment headers.
- Frontend applyBulkExport: backend → fallback на client-side
(buildLocalCsv вынесен).
- Pest +4.
Vitest +3 (всего 245/245).
PHPStan убрал лишнюю Deal->id===null проверку (Eloquent int).
DealsView/KanbanView spec'ы получили setActivePinia.
Регресс: lint+type-check+format ✅; vitest 245/245 за 17.07 сек (+3);
vite build 1.04 сек; Pint+PHPStan passed; Pest 156/156 за 20.27 сек
(+15 от 141, 675 assertions). Реестр v1.56→v1.57, CLAUDE.md v1.47→v1.48.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
114 lines
5.1 KiB
TypeScript
114 lines
5.1 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
||
import { mount } from '@vue/test-utils';
|
||
import { createVuetify } from 'vuetify';
|
||
import { createPinia, setActivePinia } from 'pinia';
|
||
import KanbanView from '../../resources/js/views/KanbanView.vue';
|
||
import { LEAD_STATUSES } from '../../resources/js/composables/leadStatuses';
|
||
|
||
describe('KanbanView.vue', () => {
|
||
// KanbanView содержит DealDetailDrawer (v-navigation-drawer), который требует
|
||
// injected layout от v-app — оборачиваем в v-app для теста.
|
||
// KanbanView содержит DealDetailDrawer (v-navigation-drawer) — stub'им,
|
||
// т.к. layout-injection недоступна в Vitest. Drawer тестируется отдельно.
|
||
const factory = () => {
|
||
setActivePinia(createPinia());
|
||
return mount(KanbanView, {
|
||
global: {
|
||
plugins: [createVuetify()],
|
||
stubs: { DealDetailDrawer: true, NewDealDialog: true },
|
||
},
|
||
});
|
||
};
|
||
|
||
it('монтируется и содержит заголовок «Канбан»', () => {
|
||
const wrapper = factory();
|
||
expect(wrapper.find('h1').text()).toBe('Канбан');
|
||
});
|
||
|
||
it('рендерит ровно 14 KanbanColumn (по числу lead_statuses)', () => {
|
||
const wrapper = factory();
|
||
const cols = wrapper.findAllComponents({ name: 'KanbanColumn' });
|
||
expect(cols).toHaveLength(LEAD_STATUSES.length);
|
||
expect(cols).toHaveLength(14);
|
||
});
|
||
|
||
it('каждая колонка получает соответствующий статус', () => {
|
||
const wrapper = factory();
|
||
const cols = wrapper.findAllComponents({ name: 'KanbanColumn' });
|
||
cols.forEach((col, i) => {
|
||
expect(col.props('status').slug).toBe(LEAD_STATUSES[i].slug);
|
||
});
|
||
});
|
||
|
||
it('содержит page-stats с числом статусов и сделок', () => {
|
||
const wrapper = factory();
|
||
const text = wrapper.text();
|
||
expect(text).toContain('14');
|
||
expect(text).toContain('статусов');
|
||
expect(text).toContain('сделок');
|
||
});
|
||
|
||
it('содержит кнопку «Новая сделка»', () => {
|
||
const wrapper = factory();
|
||
expect(wrapper.text()).toContain('Новая сделка');
|
||
});
|
||
|
||
it('содержит подсказку про перетаскивание (DnD активен)', () => {
|
||
const wrapper = factory();
|
||
expect(wrapper.text()).toMatch(/[Пп]еретаскивание/);
|
||
});
|
||
|
||
it('кнопка «Новая сделка» открывает NewDealDialog', async () => {
|
||
const wrapper = factory();
|
||
const vm = wrapper.vm as unknown as { newDealOpen: boolean };
|
||
expect(vm.newDealOpen).toBe(false);
|
||
await wrapper.find('[data-testid="new-deal-btn"]').trigger('click');
|
||
await wrapper.vm.$nextTick();
|
||
expect(vm.newDealOpen).toBe(true);
|
||
});
|
||
|
||
it('onDealCreated кладёт сделку в правильную колонку по statusSlug', async () => {
|
||
const wrapper = factory();
|
||
const vm = wrapper.vm as unknown as {
|
||
dealsByStatus: Record<string, Array<{ id: number; statusSlug: string }>>;
|
||
onDealCreated: (deal: Record<string, unknown>) => void;
|
||
totalDeals: number;
|
||
};
|
||
const beforeNew = vm.dealsByStatus.new.length;
|
||
const beforeTotal = vm.totalDeals;
|
||
// Передаём полную форму deal — Kanban-карточка ожидает manager/cost/etc.
|
||
vm.onDealCreated({
|
||
id: 999,
|
||
name: 'Тест',
|
||
phone: '+7 (999) 000-00-00',
|
||
statusSlug: 'new',
|
||
project: 'Окна Москва',
|
||
manager: { initials: 'Т', name: 'Тест' },
|
||
cost: 100,
|
||
receivedMinutesAgo: 0,
|
||
});
|
||
await wrapper.vm.$nextTick();
|
||
expect(vm.dealsByStatus.new.length).toBe(beforeNew + 1);
|
||
expect(vm.dealsByStatus.new[0].id).toBe(999);
|
||
expect(vm.totalDeals).toBe(beforeTotal + 1);
|
||
});
|
||
|
||
it('обновляет statusSlug сделки при drop в новую колонку (event.added)', async () => {
|
||
const wrapper = factory();
|
||
const cols = wrapper.findAllComponents({ name: 'KanbanColumn' });
|
||
// Берём сделку из первой колонки (new) и эмулируем «added» в paid-колонке.
|
||
const newCol = cols[0]; // new — sortOrder=1
|
||
const paidCol = cols.find((c) => c.props('status').slug === 'paid')!;
|
||
const dealToMove = (newCol.props('deals') as { id: number; statusSlug: string }[])[0];
|
||
|
||
// Эмуляция события vuedraggable@change → KanbanView.onColumnChange.
|
||
await paidCol.vm.$emit('change', {
|
||
added: { element: dealToMove, newIndex: 0 },
|
||
});
|
||
await wrapper.vm.$nextTick();
|
||
|
||
// statusSlug сделки должен переключиться на 'paid'.
|
||
expect(dealToMove.statusSlug).toBe('paid');
|
||
});
|
||
});
|