515114cff5
3 интеграционных доработки после backend-completion v1.57.
(1) GET /api/managers + /api/projects + manager FK guard:
- ManagerController::index — active users тенанта (is_active+deleted_at IS NULL).
Формат {id, email, first_name, last_name, name, initials} с
formatName/formatInitials helpers (fallback на email).
- ProjectController::index — active projects (is_active=true).
- Оба endpoint'а: tenant_id query-param, 422 без, 404 unknown, RLS-обёртка.
- DealController::store FK guard: manager_id должен принадлежать tenant'у +
is_active. Иначе 422 (закрывает security-gap чужого менеджера).
- Pest +8 в LookupsTest.
(2) Replace MOCK_MANAGERS / MOCK_PROJECTS на API в NewDealDialog:
- projectOptions/managerOptions ref'ы с MOCK fallback.
- loadLookups через Promise.all([listProjects, listManagers]) на open
диалога с tenantId.
- managerIdByName Map name→id для submit'а.
- Silent fallback на mock при network-error.
- Vitest +2.
(3) SupplierLeadCost для manual-leads:
- В DealController::store после Deal::create — resolveSupplierId (копия
логики ProcessWebhookJob: project_suppliers JOIN suppliers + ORDER BY
sort_order). Если supplier найден — SupplierLeadCost с snapshot cost_rub
+ supplier_lead_id=NULL (manual: нет внешнего id).
- Manual по-прежнему НЕ списывает баланс (Ю-2 reseller-модель — charge
только при webhook'е); cost-аналитика всё равно нужна.
- Pest +2.
- TODO: рефактор resolveSupplierId в App\Services\SupplierResolver чтобы
Job + Controller разделяли логику.
Старый тест manager_id=42 переписан под FK guard через User::factory.
PHPStan baseline регенерирован (+28 ignored Pest TestCall warnings).
Регресс: lint+type-check+format ✅; vitest 247/247 за 16.32 сек (+2);
vite build 951 ms; Pint+PHPStan passed; Pest 166/166 за 22.11 сек
(+10 от 156, 699 assertions). Реестр v1.57→v1.58, CLAUDE.md v1.48→v1.49.
Production TODO остаточные:
- resolveSupplierId → SupplierResolver service.
- XLSX-export через PhpSpreadsheet.
- GET /api/deals для replace MOCK_DEALS в DealsView/KanbanView.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
291 lines
12 KiB
TypeScript
291 lines
12 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||
import { mount, flushPromises } from '@vue/test-utils';
|
||
import { createVuetify } from 'vuetify';
|
||
|
||
vi.mock('../../resources/js/api/deals', () => ({
|
||
createDeal: vi.fn(),
|
||
listProjects: vi.fn(() => Promise.resolve([])),
|
||
listManagers: vi.fn(() => Promise.resolve([])),
|
||
}));
|
||
vi.mock('../../resources/js/api/client', () => ({
|
||
extractErrorMessage: vi.fn((_e, fb?: string) => fb ?? 'err'),
|
||
extractValidationErrors: vi.fn(() => null),
|
||
apiClient: {},
|
||
ensureCsrfCookie: vi.fn(),
|
||
}));
|
||
|
||
import * as dealsApi from '../../resources/js/api/deals';
|
||
import NewDealDialog from '../../resources/js/components/deals/NewDealDialog.vue';
|
||
|
||
const factory = (props: { modelValue: boolean; presetStatus?: string; tenantId?: number } = { modelValue: true }) =>
|
||
mount(NewDealDialog, {
|
||
props,
|
||
global: {
|
||
plugins: [createVuetify()],
|
||
stubs: {
|
||
VDialog: {
|
||
template: '<div class="dialog-stub" v-if="modelValue"><slot /></div>',
|
||
props: ['modelValue'],
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
describe('NewDealDialog.vue', () => {
|
||
it('не рендерит content при modelValue=false', () => {
|
||
const wrapper = factory({ modelValue: false });
|
||
expect(wrapper.find('.dialog-stub').exists()).toBe(false);
|
||
});
|
||
|
||
it('рендерит 6 полей: name/phone/project/manager/cost/status + кнопки', () => {
|
||
const wrapper = factory();
|
||
const text = wrapper.text();
|
||
expect(text).toContain('Новая сделка');
|
||
['field-name', 'field-phone', 'field-project', 'field-manager', 'field-cost', 'field-status'].forEach((id) => {
|
||
expect(wrapper.find(`[data-testid="${id}"]`).exists()).toBe(true);
|
||
});
|
||
expect(wrapper.find('[data-testid="submit-btn"]').exists()).toBe(true);
|
||
expect(wrapper.find('[data-testid="cancel-btn"]').exists()).toBe(true);
|
||
});
|
||
|
||
it('submit без данных не emits created — только validation errors', async () => {
|
||
const wrapper = factory();
|
||
await wrapper.find('[data-testid="submit-btn"]').trigger('click');
|
||
await flushPromises();
|
||
expect(wrapper.emitted('created')).toBeUndefined();
|
||
// Errors отображаются (хотя бы для name/phone/project/manager)
|
||
const text = wrapper.text();
|
||
expect(text).toContain('Имя обязательно');
|
||
expect(text).toContain('Телефон обязателен');
|
||
});
|
||
|
||
it('submit с валидными данными emits created с правильным deal + закрывает dialog', async () => {
|
||
const wrapper = factory();
|
||
const vm = wrapper.vm as unknown as {
|
||
name: string;
|
||
phone: string;
|
||
project: string;
|
||
manager: { initials: string; name: string };
|
||
cost: number;
|
||
statusSlug: string;
|
||
};
|
||
// Заполняем через прямой доступ к ref (компонент использует script setup)
|
||
vm.name = 'Тест Тестов';
|
||
vm.phone = '+7 (999) 123-45-67';
|
||
vm.project = 'Окна Москва';
|
||
vm.manager = { initials: 'ИП', name: 'Иван П.' };
|
||
vm.cost = 1500;
|
||
vm.statusSlug = 'new';
|
||
await flushPromises();
|
||
|
||
await wrapper.find('[data-testid="submit-btn"]').trigger('click');
|
||
await flushPromises();
|
||
|
||
const created = wrapper.emitted('created');
|
||
expect(created).toBeDefined();
|
||
const deal = (created![0] as unknown[])[0] as {
|
||
name: string;
|
||
phone: string;
|
||
statusSlug: string;
|
||
cost: number;
|
||
project: string;
|
||
};
|
||
expect(deal.name).toBe('Тест Тестов');
|
||
expect(deal.phone).toBe('+7 (999) 123-45-67');
|
||
expect(deal.cost).toBe(1500);
|
||
expect(deal.project).toBe('Окна Москва');
|
||
expect(deal.statusSlug).toBe('new');
|
||
|
||
const close = wrapper.emitted('update:modelValue');
|
||
expect(close).toBeDefined();
|
||
expect(close![0]).toEqual([false]);
|
||
});
|
||
|
||
it('phone < 10 цифр → validation error «Минимум 10 цифр»', async () => {
|
||
const wrapper = factory();
|
||
const vm = wrapper.vm as unknown as {
|
||
name: string;
|
||
phone: string;
|
||
project: string;
|
||
manager: { initials: string; name: string };
|
||
};
|
||
vm.name = 'X';
|
||
vm.phone = '123';
|
||
vm.project = 'Окна Москва';
|
||
vm.manager = { initials: 'X', name: 'X' };
|
||
await flushPromises();
|
||
await wrapper.find('[data-testid="submit-btn"]').trigger('click');
|
||
await flushPromises();
|
||
expect(wrapper.emitted('created')).toBeUndefined();
|
||
expect(wrapper.text()).toContain('Минимум 10 цифр');
|
||
});
|
||
|
||
it('presetStatus → statusSlug дефолтит на пресет (для KanbanView)', async () => {
|
||
const wrapper = factory({ modelValue: true, presetStatus: 'paid' });
|
||
await flushPromises();
|
||
const vm = wrapper.vm as unknown as { statusSlug: string };
|
||
expect(vm.statusSlug).toBe('paid');
|
||
});
|
||
|
||
it('без tenantId — submit НЕ вызывает API (local-only mode)', async () => {
|
||
const wrapper = factory({ modelValue: true }); // tenantId не передан
|
||
const vm = wrapper.vm as unknown as {
|
||
name: string;
|
||
phone: string;
|
||
project: string;
|
||
manager: { initials: string; name: string };
|
||
statusSlug: string;
|
||
};
|
||
vm.name = 'X';
|
||
vm.phone = '+7 (999) 000-00-00';
|
||
vm.project = 'Окна Москва';
|
||
vm.manager = { initials: 'X', name: 'X' };
|
||
vm.statusSlug = 'new';
|
||
await flushPromises();
|
||
await wrapper.find('[data-testid="submit-btn"]').trigger('click');
|
||
await flushPromises();
|
||
expect(dealsApi.createDeal).not.toHaveBeenCalled();
|
||
expect(wrapper.emitted('created')).toBeDefined();
|
||
});
|
||
|
||
it('с tenantId + успешный backend — emits deal с backend-id', async () => {
|
||
vi.mocked(dealsApi.createDeal).mockResolvedValue({
|
||
id: 4242,
|
||
tenant_id: 1,
|
||
project_id: 7,
|
||
phone: '+7 (999) 000-00-00',
|
||
status: 'new',
|
||
contact_name: 'X',
|
||
manager_id: null,
|
||
received_at: '2026-05-09T12:00:00Z',
|
||
});
|
||
|
||
const wrapper = factory({ modelValue: true, tenantId: 1 });
|
||
const vm = wrapper.vm as unknown as {
|
||
name: string;
|
||
phone: string;
|
||
project: string;
|
||
manager: { initials: string; name: string };
|
||
statusSlug: string;
|
||
};
|
||
vm.name = 'X';
|
||
vm.phone = '+7 (999) 000-00-00';
|
||
vm.project = 'Окна Москва';
|
||
vm.manager = { initials: 'X', name: 'X' };
|
||
vm.statusSlug = 'new';
|
||
await flushPromises();
|
||
await wrapper.find('[data-testid="submit-btn"]').trigger('click');
|
||
await flushPromises();
|
||
|
||
expect(dealsApi.createDeal).toHaveBeenCalledWith({
|
||
tenant_id: 1,
|
||
project_name: 'Окна Москва',
|
||
phone: '+7 (999) 000-00-00',
|
||
contact_name: 'X',
|
||
status: 'new',
|
||
});
|
||
const emitted = wrapper.emitted('created');
|
||
expect(emitted).toBeDefined();
|
||
const deal = (emitted![0] as unknown[])[0] as { id: number };
|
||
expect(deal.id).toBe(4242); // backend-id, не local nextId()
|
||
});
|
||
|
||
it('с tenantId — loadLookups вызывает listManagers + listProjects на open', async () => {
|
||
vi.mocked(dealsApi.listProjects).mockResolvedValue([
|
||
{ id: 7, name: 'Окна Москва', tag: null, type: 'webhook' },
|
||
]);
|
||
vi.mocked(dealsApi.listManagers).mockResolvedValue([
|
||
{ id: 42, email: 'iv@ex.ru', first_name: 'Иван', last_name: 'Петров', name: 'Иван П.', initials: 'ИП' },
|
||
]);
|
||
|
||
const wrapper = factory({ modelValue: true, tenantId: 1 });
|
||
await flushPromises();
|
||
|
||
expect(dealsApi.listProjects).toHaveBeenCalledWith(1);
|
||
expect(dealsApi.listManagers).toHaveBeenCalledWith(1);
|
||
|
||
// projectOptions / managerOptions заменены backend'ом
|
||
const vm = wrapper.vm as unknown as {
|
||
projectOptions: string[];
|
||
managerOptions: Array<{ name: string; initials: string }>;
|
||
managerIdByName: Map<string, number>;
|
||
};
|
||
expect(vm.projectOptions).toEqual(['Окна Москва']);
|
||
expect(vm.managerOptions).toEqual([{ name: 'Иван П.', initials: 'ИП' }]);
|
||
expect(vm.managerIdByName.get('Иван П.')).toBe(42);
|
||
});
|
||
|
||
it('submit с manager → передаёт backend manager_id из mapping', async () => {
|
||
vi.mocked(dealsApi.listManagers).mockResolvedValue([
|
||
{ id: 99, email: 'x@y.ru', first_name: 'X', last_name: 'Y', name: 'X Y.', initials: 'XY' },
|
||
]);
|
||
vi.mocked(dealsApi.createDeal).mockResolvedValue({
|
||
id: 1,
|
||
tenant_id: 1,
|
||
project_id: 1,
|
||
phone: '+7 (999) 000-00-00',
|
||
status: 'new',
|
||
contact_name: 'Z',
|
||
manager_id: 99,
|
||
received_at: '2026-05-09T12:00:00Z',
|
||
});
|
||
|
||
const wrapper = factory({ modelValue: true, tenantId: 1 });
|
||
await flushPromises();
|
||
const vm = wrapper.vm as unknown as {
|
||
name: string;
|
||
phone: string;
|
||
project: string;
|
||
manager: { initials: string; name: string };
|
||
statusSlug: string;
|
||
};
|
||
vm.name = 'Z';
|
||
vm.phone = '+7 (999) 000-00-00';
|
||
vm.project = 'Окна Москва';
|
||
vm.manager = { initials: 'XY', name: 'X Y.' };
|
||
vm.statusSlug = 'new';
|
||
await flushPromises();
|
||
await wrapper.find('[data-testid="submit-btn"]').trigger('click');
|
||
await flushPromises();
|
||
|
||
expect(dealsApi.createDeal).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
manager_id: 99,
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('с tenantId + backend-error — fallback на local-id + warning + emit', async () => {
|
||
vi.mocked(dealsApi.createDeal).mockRejectedValue(new Error('Network down'));
|
||
|
||
const wrapper = factory({ modelValue: true, tenantId: 1 });
|
||
const vm = wrapper.vm as unknown as {
|
||
name: string;
|
||
phone: string;
|
||
project: string;
|
||
manager: { initials: string; name: string };
|
||
statusSlug: string;
|
||
submitError: string | null;
|
||
};
|
||
vm.name = 'X';
|
||
vm.phone = '+7 (999) 000-00-00';
|
||
vm.project = 'Окна Москва';
|
||
vm.manager = { initials: 'X', name: 'X' };
|
||
vm.statusSlug = 'new';
|
||
await flushPromises();
|
||
await wrapper.find('[data-testid="submit-btn"]').trigger('click');
|
||
await flushPromises();
|
||
|
||
// Warning показывается, deal эмитится с local-id, диалог остаётся открытым.
|
||
expect(wrapper.find('[data-testid="submit-error-alert"]').exists()).toBe(true);
|
||
expect(wrapper.emitted('created')).toBeDefined();
|
||
// dialog НЕ закрывается на error
|
||
const closeEmits = wrapper.emitted('update:modelValue');
|
||
expect(closeEmits === undefined || !closeEmits.some((e) => e[0] === false)).toBe(true);
|
||
});
|
||
});
|