326 lines
13 KiB
TypeScript
326 lines
13 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: 'won' });
|
||
await flushPromises();
|
||
const vm = wrapper.vm as unknown as { statusSlug: string };
|
||
expect(vm.statusSlug).toBe('won');
|
||
});
|
||
|
||
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);
|
||
});
|
||
|
||
it('I3: без tenantId — projectOptions и managerOptions пусты (нет mock-fallback)', async () => {
|
||
const wrapper = factory({ modelValue: true }); // нет tenantId
|
||
await flushPromises();
|
||
const vm = wrapper.vm as unknown as {
|
||
projectOptions: string[];
|
||
managerOptions: unknown[];
|
||
};
|
||
expect(vm.projectOptions).toHaveLength(0);
|
||
expect(vm.managerOptions).toHaveLength(0);
|
||
});
|
||
|
||
it('C6: при провале loadLookups показывает degradation-alert', async () => {
|
||
vi.spyOn(dealsApi, 'listProjects').mockRejectedValue(new Error('network'));
|
||
vi.spyOn(dealsApi, 'listManagers').mockRejectedValue(new Error('network'));
|
||
|
||
const wrapper = factory({ modelValue: true, tenantId: 7 });
|
||
await flushPromises();
|
||
|
||
expect(wrapper.vm.lookupsFailed).toBe(true);
|
||
expect(wrapper.find('[data-testid="lookups-error-alert"]').exists()).toBe(true);
|
||
});
|
||
|
||
it('C6: при успешном loadLookups alert отсутствует', async () => {
|
||
vi.mocked(dealsApi.listProjects).mockResolvedValue([{ id: 1, name: 'P', tag: null, type: 'manual' }]);
|
||
vi.mocked(dealsApi.listManagers).mockResolvedValue([
|
||
{ id: 1, email: 'a@b.c', first_name: 'A', last_name: 'B', name: 'A B', initials: 'AB' },
|
||
]);
|
||
|
||
const wrapper = factory({ modelValue: true, tenantId: 7 });
|
||
await flushPromises();
|
||
|
||
expect(wrapper.vm.lookupsFailed).toBe(false);
|
||
expect(wrapper.find('[data-testid="lookups-error-alert"]').exists()).toBe(false);
|
||
});
|
||
});
|