294 lines
10 KiB
TypeScript
294 lines
10 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
import { mount, flushPromises } from '@vue/test-utils';
|
||
import { createVuetify } from 'vuetify';
|
||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||
import { createPinia, setActivePinia } from 'pinia';
|
||
import DealsView from '../../resources/js/views/DealsView.vue';
|
||
import KanbanView from '../../resources/js/views/KanbanView.vue';
|
||
import { useAuthStore } from '../../resources/js/stores/auth';
|
||
import type { ApiDeal } 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,
|
||
listDeals: vi.fn(),
|
||
listManagers: vi.fn().mockResolvedValue([]),
|
||
listProjects: vi.fn().mockResolvedValue([]),
|
||
transitionDeals: vi.fn(),
|
||
};
|
||
});
|
||
|
||
const dealsApi = await import('../../resources/js/api/deals');
|
||
|
||
function makeApiDeal(overrides: Partial<ApiDeal> = {}): ApiDeal {
|
||
return {
|
||
id: 100,
|
||
tenant_id: 1,
|
||
project_id: 5,
|
||
project_name: 'Окна Москва',
|
||
phone: '+7 (999) 100-00-01',
|
||
contact_name: 'Анна Б.',
|
||
status: 'new',
|
||
manager_id: 10,
|
||
manager_name: 'Иван П.',
|
||
manager_initials: 'ИП',
|
||
received_at: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
||
comment: null,
|
||
city: null,
|
||
project_signal_type: null,
|
||
next_reminder_at: null,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function setupAuth(tenantId: number | null) {
|
||
setActivePinia(createPinia());
|
||
const auth = useAuthStore();
|
||
if (tenantId !== null) {
|
||
auth.user = {
|
||
id: 1,
|
||
email: 'test@example.test',
|
||
first_name: 'Test',
|
||
last_name: 'User',
|
||
tenant_id: tenantId,
|
||
totp_enabled: false,
|
||
last_login_at: null,
|
||
};
|
||
}
|
||
}
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
const mountDealsView = async () => {
|
||
const router = createRouter({
|
||
history: createMemoryHistory(),
|
||
routes: [{ path: '/deals', component: DealsView }],
|
||
});
|
||
await router.push('/deals');
|
||
await router.isReady();
|
||
return mount(DealsView, {
|
||
global: {
|
||
plugins: [createVuetify(), router],
|
||
stubs: { DealDetailDrawer: true },
|
||
},
|
||
});
|
||
};
|
||
|
||
const mountKanbanView = () =>
|
||
mount(KanbanView, {
|
||
global: {
|
||
plugins: [createVuetify()],
|
||
stubs: { DealDetailDrawer: true, NewDealDialog: true, KanbanColumn: true },
|
||
},
|
||
});
|
||
|
||
describe('DealsView ↔ GET /api/deals integration', () => {
|
||
it('БЕЗ auth.user.tenant_id — listDeals не вызывается, dealsState пустой', async () => {
|
||
setupAuth(null);
|
||
const wrapper = await mountDealsView();
|
||
await flushPromises();
|
||
expect(dealsApi.listDeals).not.toHaveBeenCalled();
|
||
const vm = wrapper.vm as unknown as { dealsState: { id: number }[] };
|
||
expect(vm.dealsState.length).toBe(0);
|
||
});
|
||
|
||
it('С auth.user.tenant_id — listDeals вызывается с tenantId + replace dealsState', async () => {
|
||
setupAuth(1);
|
||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||
deals: [
|
||
makeApiDeal({ id: 200, contact_name: 'Из API #1', status: 'won' }),
|
||
makeApiDeal({ id: 201, contact_name: 'Из API #2', status: 'new' }),
|
||
],
|
||
total: 2,
|
||
limit: 20,
|
||
offset: 0,
|
||
});
|
||
|
||
const wrapper = await mountDealsView();
|
||
await flushPromises();
|
||
|
||
expect(dealsApi.listDeals).toHaveBeenCalledTimes(1);
|
||
expect(dealsApi.listDeals).toHaveBeenCalledWith(expect.objectContaining({ tenantId: 1, limit: 20 }));
|
||
|
||
const vm = wrapper.vm as unknown as { dealsState: { id: number; name: string }[] };
|
||
expect(vm.dealsState).toHaveLength(2);
|
||
expect(vm.dealsState.map((d) => d.id).sort()).toEqual([200, 201]);
|
||
expect(vm.dealsState.find((d) => d.id === 200)?.name).toBe('Из API #1');
|
||
});
|
||
|
||
it('listDeals reject → fetchError=true, alert виден, dealsState пустой', async () => {
|
||
setupAuth(1);
|
||
vi.mocked(dealsApi.listDeals).mockRejectedValueOnce(new Error('network'));
|
||
|
||
const wrapper = await mountDealsView();
|
||
await flushPromises();
|
||
|
||
const vm = wrapper.vm as unknown as { fetchError: boolean; dealsState: unknown[] };
|
||
expect(vm.fetchError).toBe(true);
|
||
expect(vm.dealsState.length).toBe(0);
|
||
// Alert виден.
|
||
expect(wrapper.find('[data-testid="fetch-error-alert"]').exists()).toBe(true);
|
||
});
|
||
|
||
it('reload-btn повторно вызывает listDeals', async () => {
|
||
setupAuth(1);
|
||
vi.mocked(dealsApi.listDeals).mockResolvedValue({
|
||
deals: [makeApiDeal({ id: 400 })],
|
||
total: 1,
|
||
limit: 20,
|
||
offset: 0,
|
||
});
|
||
|
||
const wrapper = await mountDealsView();
|
||
await flushPromises();
|
||
expect(dealsApi.listDeals).toHaveBeenCalledTimes(1);
|
||
|
||
await wrapper.find('[data-testid="reload-btn"]').trigger('click');
|
||
await flushPromises();
|
||
expect(dealsApi.listDeals).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it('applyBulkStatus с tenant_id вызывает transitionDeals + локальный optimistic update', async () => {
|
||
setupAuth(1);
|
||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||
deals: [makeApiDeal({ id: 500, status: 'new' }), makeApiDeal({ id: 501, status: 'new' })],
|
||
total: 2,
|
||
limit: 20,
|
||
offset: 0,
|
||
});
|
||
vi.mocked(dealsApi.transitionDeals).mockResolvedValueOnce({
|
||
updated: 2,
|
||
requested: 2,
|
||
status: 'won',
|
||
});
|
||
|
||
const wrapper = await mountDealsView();
|
||
await flushPromises();
|
||
|
||
const vm = wrapper.vm as unknown as {
|
||
selected: number[];
|
||
applyBulkStatus: (slug: string) => Promise<void>;
|
||
dealsState: { id: number; statusSlug: string }[];
|
||
statusToastOpen: boolean;
|
||
statusToastText: string;
|
||
};
|
||
vm.selected = [500, 501];
|
||
await flushPromises();
|
||
|
||
await vm.applyBulkStatus('won');
|
||
await flushPromises();
|
||
|
||
// Optimistic local-update применился до завершения API-вызова.
|
||
expect(vm.dealsState.find((d) => d.id === 500)?.statusSlug).toBe('won');
|
||
expect(vm.dealsState.find((d) => d.id === 501)?.statusSlug).toBe('won');
|
||
expect(dealsApi.transitionDeals).toHaveBeenCalledWith({
|
||
tenant_id: 1,
|
||
ids: [500, 501],
|
||
status: 'won',
|
||
});
|
||
expect(vm.statusToastOpen).toBe(true);
|
||
expect(vm.statusToastText).toContain('Обновлено 2');
|
||
});
|
||
|
||
it('applyBulkStatus с reject → toast с warning, локальный update остаётся (не откатываем)', async () => {
|
||
setupAuth(1);
|
||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||
deals: [makeApiDeal({ id: 600, status: 'new' })],
|
||
total: 1,
|
||
limit: 20,
|
||
offset: 0,
|
||
});
|
||
vi.mocked(dealsApi.transitionDeals).mockRejectedValueOnce(new Error('500'));
|
||
|
||
const wrapper = await mountDealsView();
|
||
await flushPromises();
|
||
|
||
const vm = wrapper.vm as unknown as {
|
||
selected: number[];
|
||
applyBulkStatus: (slug: string) => Promise<void>;
|
||
dealsState: { id: number; statusSlug: string }[];
|
||
statusToastText: string;
|
||
};
|
||
vm.selected = [600];
|
||
await flushPromises();
|
||
await vm.applyBulkStatus('won');
|
||
await flushPromises();
|
||
|
||
expect(vm.dealsState.find((d) => d.id === 600)?.statusSlug).toBe('won');
|
||
expect(vm.statusToastText).toContain('Не удалось');
|
||
});
|
||
});
|
||
|
||
describe('KanbanView ↔ GET /api/deals integration', () => {
|
||
it('БЕЗ tenant_id — listDeals не вызывается', async () => {
|
||
setupAuth(null);
|
||
mountKanbanView();
|
||
await flushPromises();
|
||
expect(dealsApi.listDeals).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('С tenant_id — заполняет колонки по statusSlug + обновляет totalDeals', async () => {
|
||
setupAuth(1);
|
||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||
deals: [
|
||
makeApiDeal({ id: 300, status: 'new' }),
|
||
makeApiDeal({ id: 301, status: 'won' }),
|
||
makeApiDeal({ id: 302, status: 'won' }),
|
||
],
|
||
total: 3,
|
||
limit: 500,
|
||
offset: 0,
|
||
});
|
||
|
||
const wrapper = mountKanbanView();
|
||
await flushPromises();
|
||
|
||
const vm = wrapper.vm as unknown as {
|
||
dealsByStatus: Record<string, { id: number }[]>;
|
||
totalDeals: number;
|
||
fetchError: boolean;
|
||
};
|
||
expect(vm.dealsByStatus.new.map((d) => d.id)).toEqual([300]);
|
||
expect(vm.dealsByStatus.won.map((d) => d.id).sort()).toEqual([301, 302]);
|
||
expect(vm.totalDeals).toBe(3);
|
||
expect(vm.fetchError).toBe(false);
|
||
});
|
||
|
||
it('listDeals reject → fetchError=true, колонки пусты', async () => {
|
||
setupAuth(1);
|
||
vi.mocked(dealsApi.listDeals).mockRejectedValueOnce(new Error('500'));
|
||
|
||
const wrapper = mountKanbanView();
|
||
await flushPromises();
|
||
|
||
const vm = wrapper.vm as unknown as {
|
||
dealsByStatus: Record<string, unknown[]>;
|
||
fetchError: boolean;
|
||
};
|
||
expect(vm.fetchError).toBe(true);
|
||
const filledColumns = Object.values(vm.dealsByStatus).filter((arr) => arr.length > 0);
|
||
expect(filledColumns.length).toBe(0);
|
||
});
|
||
|
||
it('reload-btn вызывает listDeals второй раз', async () => {
|
||
setupAuth(1);
|
||
vi.mocked(dealsApi.listDeals).mockResolvedValue({
|
||
deals: [],
|
||
total: 0,
|
||
limit: 500,
|
||
offset: 0,
|
||
});
|
||
|
||
const wrapper = mountKanbanView();
|
||
await flushPromises();
|
||
expect(dealsApi.listDeals).toHaveBeenCalledTimes(1);
|
||
|
||
await wrapper.find('[data-testid="reload-btn"]').trigger('click');
|
||
await flushPromises();
|
||
expect(dealsApi.listDeals).toHaveBeenCalledTimes(2);
|
||
});
|
||
});
|