Files
portal/app/tests/Frontend/KanbanView.spec.ts
T
Дмитрий 83bb9de2bb phase2(backend-completion): POST /api/deals + webhook_hmac_required + POST /api/deals/export
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>
2026-05-09 06:43:21 +03:00

114 lines
5.1 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 } 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');
});
});