2026-05-09 07:34:39 +03:00
|
|
|
|
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([]),
|
2026-05-09 07:46:19 +03:00
|
|
|
|
transitionDeals: vi.fn(),
|
2026-05-09 08:08:53 +03:00
|
|
|
|
exportDeals: vi.fn(),
|
|
|
|
|
|
exportDealsXlsx: vi.fn(),
|
2026-05-09 09:51:47 +03:00
|
|
|
|
bulkDeleteDeals: vi.fn(),
|
2026-05-09 10:01:35 +03:00
|
|
|
|
bulkRestoreDeals: vi.fn(),
|
2026-05-09 07:34:39 +03:00
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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(),
|
|
|
|
|
|
...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, NewDealDialog: 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 не вызывается, fallback на MOCK_DEALS', async () => {
|
|
|
|
|
|
setupAuth(null);
|
|
|
|
|
|
const wrapper = await mountDealsView();
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
expect(dealsApi.listDeals).not.toHaveBeenCalled();
|
|
|
|
|
|
// MOCK_DEALS содержит 12 элементов — fallback виден.
|
|
|
|
|
|
const vm = wrapper.vm as unknown as { dealsState: { id: number }[] };
|
|
|
|
|
|
expect(vm.dealsState.length).toBeGreaterThan(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: 'paid' }),
|
|
|
|
|
|
makeApiDeal({ id: 201, contact_name: 'Из API #2', status: 'new' }),
|
|
|
|
|
|
],
|
|
|
|
|
|
total: 2,
|
|
|
|
|
|
limit: 200,
|
|
|
|
|
|
offset: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const wrapper = await mountDealsView();
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
|
|
|
|
|
expect(dealsApi.listDeals).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
expect(dealsApi.listDeals).toHaveBeenCalledWith(expect.objectContaining({ tenantId: 1, limit: 200 }));
|
|
|
|
|
|
|
|
|
|
|
|
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 виден, MOCK_DEALS остаётся как fallback', 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).toBeGreaterThan(0);
|
|
|
|
|
|
// Alert виден.
|
|
|
|
|
|
expect(wrapper.find('[data-testid="fetch-error-alert"]').exists()).toBe(true);
|
|
|
|
|
|
});
|
2026-05-09 07:46:19 +03:00
|
|
|
|
|
2026-05-09 10:27:11 +03:00
|
|
|
|
it('toggleTrashMode переключает trashMode + listDeals вызывается с onlyDeleted=true', async () => {
|
|
|
|
|
|
setupAuth(1);
|
|
|
|
|
|
// Начальный fetch (нормальный режим)
|
|
|
|
|
|
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
|
|
|
|
|
deals: [makeApiDeal({ id: 600 })],
|
|
|
|
|
|
total: 1,
|
|
|
|
|
|
limit: 200,
|
|
|
|
|
|
offset: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
// После toggle в trash
|
|
|
|
|
|
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
|
|
|
|
|
deals: [makeApiDeal({ id: 700, contact_name: 'Удалённый' })],
|
|
|
|
|
|
total: 1,
|
|
|
|
|
|
limit: 200,
|
|
|
|
|
|
offset: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const wrapper = await mountDealsView();
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
expect(dealsApi.listDeals).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
expect(dealsApi.listDeals).toHaveBeenLastCalledWith(
|
|
|
|
|
|
expect.objectContaining({ tenantId: 1, onlyDeleted: false }),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const vm = wrapper.vm as unknown as {
|
|
|
|
|
|
trashMode: boolean;
|
|
|
|
|
|
toggleTrashMode: () => void;
|
|
|
|
|
|
dealsState: { id: number }[];
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
vm.toggleTrashMode();
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
|
|
|
|
|
expect(vm.trashMode).toBe(true);
|
|
|
|
|
|
expect(dealsApi.listDeals).toHaveBeenCalledTimes(2);
|
|
|
|
|
|
expect(dealsApi.listDeals).toHaveBeenLastCalledWith(
|
|
|
|
|
|
expect.objectContaining({ tenantId: 1, onlyDeleted: true }),
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(vm.dealsState.find((d) => d.id === 700)).toBeDefined();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('applyBulkRestoreFromTrash восстанавливает + убирает из dealsState', async () => {
|
|
|
|
|
|
setupAuth(1);
|
|
|
|
|
|
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
|
|
|
|
|
deals: [makeApiDeal({ id: 800 }), makeApiDeal({ id: 801 })],
|
|
|
|
|
|
total: 2,
|
|
|
|
|
|
limit: 200,
|
|
|
|
|
|
offset: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
vi.mocked(dealsApi.bulkRestoreDeals).mockResolvedValueOnce({
|
|
|
|
|
|
restored: 2,
|
|
|
|
|
|
requested: 2,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const wrapper = await mountDealsView();
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
|
|
|
|
|
const vm = wrapper.vm as unknown as {
|
|
|
|
|
|
selected: number[];
|
|
|
|
|
|
applyBulkRestoreFromTrash: () => Promise<void>;
|
|
|
|
|
|
dealsState: { id: number }[];
|
|
|
|
|
|
deleteToastText: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
vm.selected = [800, 801];
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
await vm.applyBulkRestoreFromTrash();
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
|
|
|
|
|
expect(dealsApi.bulkRestoreDeals).toHaveBeenCalledWith({ tenant_id: 1, ids: [800, 801] });
|
|
|
|
|
|
// Восстановленные убраны из текущего trash-списка.
|
|
|
|
|
|
expect(vm.dealsState.find((d) => d.id === 800)).toBeUndefined();
|
|
|
|
|
|
expect(vm.dealsState.find((d) => d.id === 801)).toBeUndefined();
|
|
|
|
|
|
expect(vm.deleteToastText).toContain('Восстановлено 2');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-09 07:46:19 +03:00
|
|
|
|
it('reload-btn повторно вызывает listDeals', async () => {
|
|
|
|
|
|
setupAuth(1);
|
|
|
|
|
|
vi.mocked(dealsApi.listDeals).mockResolvedValue({
|
|
|
|
|
|
deals: [makeApiDeal({ id: 400 })],
|
|
|
|
|
|
total: 1,
|
|
|
|
|
|
limit: 200,
|
|
|
|
|
|
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: 200,
|
|
|
|
|
|
offset: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
vi.mocked(dealsApi.transitionDeals).mockResolvedValueOnce({
|
|
|
|
|
|
updated: 2,
|
|
|
|
|
|
requested: 2,
|
|
|
|
|
|
status: 'paid',
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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('paid');
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
|
|
|
|
|
// Optimistic local-update применился до завершения API-вызова.
|
|
|
|
|
|
expect(vm.dealsState.find((d) => d.id === 500)?.statusSlug).toBe('paid');
|
|
|
|
|
|
expect(vm.dealsState.find((d) => d.id === 501)?.statusSlug).toBe('paid');
|
|
|
|
|
|
expect(dealsApi.transitionDeals).toHaveBeenCalledWith({
|
|
|
|
|
|
tenant_id: 1,
|
|
|
|
|
|
ids: [500, 501],
|
|
|
|
|
|
status: 'paid',
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(vm.statusToastOpen).toBe(true);
|
|
|
|
|
|
expect(vm.statusToastText).toContain('Обновлено 2');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('applyBulkStatus БЕЗ tenant_id — только локальный update, transitionDeals НЕ вызывается', async () => {
|
|
|
|
|
|
setupAuth(null);
|
|
|
|
|
|
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 }[];
|
|
|
|
|
|
};
|
|
|
|
|
|
vm.selected = [1];
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
await vm.applyBulkStatus('paid');
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
|
|
|
|
|
expect(dealsApi.transitionDeals).not.toHaveBeenCalled();
|
|
|
|
|
|
expect(vm.dealsState.find((d) => d.id === 1)?.statusSlug).toBe('paid');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-09 08:08:53 +03:00
|
|
|
|
it('applyBulkExport(xlsx) с tenant_id вызывает exportDealsXlsx и триггерит download', async () => {
|
|
|
|
|
|
setupAuth(1);
|
|
|
|
|
|
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
|
|
|
|
|
deals: [makeApiDeal({ id: 700 })],
|
|
|
|
|
|
total: 1,
|
|
|
|
|
|
limit: 200,
|
|
|
|
|
|
offset: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
const fakeBlob = new Blob(['fake xlsx'], { type: 'application/octet-stream' });
|
|
|
|
|
|
vi.mocked(dealsApi.exportDealsXlsx).mockResolvedValueOnce(fakeBlob);
|
|
|
|
|
|
|
|
|
|
|
|
const createUrlSpy = vi.fn(() => 'blob:xlsx');
|
|
|
|
|
|
const revokeSpy = vi.fn();
|
|
|
|
|
|
Object.defineProperty(URL, 'createObjectURL', { value: createUrlSpy, configurable: true });
|
|
|
|
|
|
Object.defineProperty(URL, 'revokeObjectURL', { value: revokeSpy, configurable: true });
|
|
|
|
|
|
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
|
|
|
|
|
|
|
|
|
|
|
const wrapper = await mountDealsView();
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
const vm = wrapper.vm as unknown as {
|
|
|
|
|
|
selected: number[];
|
|
|
|
|
|
applyBulkExport: (format?: string) => Promise<void>;
|
|
|
|
|
|
exportToastText: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
vm.selected = [700];
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
await vm.applyBulkExport(); // default = xlsx
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
|
|
|
|
|
expect(dealsApi.exportDealsXlsx).toHaveBeenCalledWith({
|
|
|
|
|
|
tenant_id: 1,
|
|
|
|
|
|
ids: [700],
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(dealsApi.exportDeals).not.toHaveBeenCalled();
|
|
|
|
|
|
expect(createUrlSpy).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
expect(clickSpy).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
expect(vm.exportToastText).toContain('XLSX');
|
|
|
|
|
|
clickSpy.mockRestore();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('applyBulkExport(csv) с tenant_id вызывает exportDeals (CSV branch)', async () => {
|
|
|
|
|
|
setupAuth(1);
|
|
|
|
|
|
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
|
|
|
|
|
deals: [makeApiDeal({ id: 701 })],
|
|
|
|
|
|
total: 1,
|
|
|
|
|
|
limit: 200,
|
|
|
|
|
|
offset: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
vi.mocked(dealsApi.exportDeals).mockResolvedValueOnce('id;...');
|
|
|
|
|
|
|
|
|
|
|
|
Object.defineProperty(URL, 'createObjectURL', { value: vi.fn(() => 'blob:'), configurable: true });
|
|
|
|
|
|
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
|
|
|
|
|
|
|
|
|
|
|
const wrapper = await mountDealsView();
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
const vm = wrapper.vm as unknown as {
|
|
|
|
|
|
selected: number[];
|
|
|
|
|
|
applyBulkExport: (format?: string) => Promise<void>;
|
|
|
|
|
|
exportToastText: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
vm.selected = [701];
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
await vm.applyBulkExport('csv');
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
|
|
|
|
|
expect(dealsApi.exportDeals).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
expect(dealsApi.exportDealsXlsx).not.toHaveBeenCalled();
|
|
|
|
|
|
expect(vm.exportToastText).toContain('CSV');
|
|
|
|
|
|
clickSpy.mockRestore();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('applyBulkExport(xlsx) reject → fallback на local CSV', async () => {
|
|
|
|
|
|
setupAuth(1);
|
|
|
|
|
|
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
|
|
|
|
|
deals: [makeApiDeal({ id: 702 })],
|
|
|
|
|
|
total: 1,
|
|
|
|
|
|
limit: 200,
|
|
|
|
|
|
offset: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
vi.mocked(dealsApi.exportDealsXlsx).mockRejectedValueOnce(new Error('500'));
|
|
|
|
|
|
|
|
|
|
|
|
Object.defineProperty(URL, 'createObjectURL', { value: vi.fn(() => 'blob:'), configurable: true });
|
|
|
|
|
|
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
|
|
|
|
|
|
|
|
|
|
|
const wrapper = await mountDealsView();
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
const vm = wrapper.vm as unknown as {
|
|
|
|
|
|
selected: number[];
|
|
|
|
|
|
applyBulkExport: () => Promise<void>;
|
|
|
|
|
|
exportToastText: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
vm.selected = [702];
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
await vm.applyBulkExport();
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
|
|
|
|
|
expect(vm.exportToastText).toContain('Backend недоступен');
|
|
|
|
|
|
// local CSV всё равно стриггерил download
|
|
|
|
|
|
expect(clickSpy).toHaveBeenCalled();
|
|
|
|
|
|
clickSpy.mockRestore();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-09 09:51:47 +03:00
|
|
|
|
it('applyBulkDelete с tenant_id вызывает bulkDeleteDeals + optimistic local-removal', async () => {
|
|
|
|
|
|
setupAuth(1);
|
|
|
|
|
|
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
2026-05-09 10:01:35 +03:00
|
|
|
|
deals: [makeApiDeal({ id: 800, status: 'new' }), makeApiDeal({ id: 801, status: 'new' })],
|
2026-05-09 09:51:47 +03:00
|
|
|
|
total: 2,
|
|
|
|
|
|
limit: 200,
|
|
|
|
|
|
offset: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
vi.mocked(dealsApi.bulkDeleteDeals).mockResolvedValueOnce({
|
|
|
|
|
|
deleted: 2,
|
|
|
|
|
|
requested: 2,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const wrapper = await mountDealsView();
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
|
|
|
|
|
const vm = wrapper.vm as unknown as {
|
|
|
|
|
|
selected: number[];
|
|
|
|
|
|
applyBulkDelete: () => Promise<void>;
|
|
|
|
|
|
dealsState: { id: number }[];
|
|
|
|
|
|
deleteToastOpen: boolean;
|
|
|
|
|
|
deleteToastText: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
vm.selected = [800, 801];
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
|
|
|
|
|
await vm.applyBulkDelete();
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
|
|
|
|
|
// Optimistic — обе сделки убраны из state.
|
|
|
|
|
|
expect(vm.dealsState.find((d) => d.id === 800)).toBeUndefined();
|
|
|
|
|
|
expect(vm.dealsState.find((d) => d.id === 801)).toBeUndefined();
|
|
|
|
|
|
expect(dealsApi.bulkDeleteDeals).toHaveBeenCalledWith({
|
|
|
|
|
|
tenant_id: 1,
|
|
|
|
|
|
ids: [800, 801],
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(vm.deleteToastOpen).toBe(true);
|
|
|
|
|
|
expect(vm.deleteToastText).toContain('Удалено 2');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('applyBulkDelete без tenant_id — только локально, bulkDeleteDeals НЕ вызывается', async () => {
|
|
|
|
|
|
setupAuth(null);
|
|
|
|
|
|
const wrapper = await mountDealsView();
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
|
|
|
|
|
const vm = wrapper.vm as unknown as {
|
|
|
|
|
|
selected: number[];
|
|
|
|
|
|
applyBulkDelete: () => Promise<void>;
|
|
|
|
|
|
dealsState: { id: number }[];
|
|
|
|
|
|
};
|
|
|
|
|
|
const before = vm.dealsState.length;
|
|
|
|
|
|
vm.selected = [1, 2];
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
await vm.applyBulkDelete();
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
|
|
|
|
|
expect(dealsApi.bulkDeleteDeals).not.toHaveBeenCalled();
|
|
|
|
|
|
expect(vm.dealsState.length).toBe(before - 2);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('applyBulkDelete reject → warning toast, локальный update остаётся (не откатываем)', async () => {
|
|
|
|
|
|
setupAuth(1);
|
|
|
|
|
|
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
|
|
|
|
|
deals: [makeApiDeal({ id: 900 })],
|
|
|
|
|
|
total: 1,
|
|
|
|
|
|
limit: 200,
|
|
|
|
|
|
offset: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
vi.mocked(dealsApi.bulkDeleteDeals).mockRejectedValueOnce(new Error('500'));
|
|
|
|
|
|
|
|
|
|
|
|
const wrapper = await mountDealsView();
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
|
|
|
|
|
const vm = wrapper.vm as unknown as {
|
|
|
|
|
|
selected: number[];
|
|
|
|
|
|
applyBulkDelete: () => Promise<void>;
|
|
|
|
|
|
dealsState: { id: number }[];
|
|
|
|
|
|
deleteToastText: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
vm.selected = [900];
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
await vm.applyBulkDelete();
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
|
|
|
|
|
expect(vm.dealsState.find((d) => d.id === 900)).toBeUndefined(); // optimistic
|
|
|
|
|
|
expect(vm.deleteToastText).toContain('Не удалось');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-09 10:01:35 +03:00
|
|
|
|
it('bulk-delete + undo восстанавливает сделки + вызывает bulkRestoreDeals', async () => {
|
|
|
|
|
|
setupAuth(1);
|
|
|
|
|
|
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
|
|
|
|
|
deals: [makeApiDeal({ id: 1000, contact_name: 'A' }), makeApiDeal({ id: 1001, contact_name: 'B' })],
|
|
|
|
|
|
total: 2,
|
|
|
|
|
|
limit: 200,
|
|
|
|
|
|
offset: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
vi.mocked(dealsApi.bulkDeleteDeals).mockResolvedValueOnce({
|
|
|
|
|
|
deleted: 2,
|
|
|
|
|
|
requested: 2,
|
|
|
|
|
|
});
|
|
|
|
|
|
vi.mocked(dealsApi.bulkRestoreDeals).mockResolvedValueOnce({
|
|
|
|
|
|
restored: 2,
|
|
|
|
|
|
requested: 2,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const wrapper = await mountDealsView();
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
|
|
|
|
|
const vm = wrapper.vm as unknown as {
|
|
|
|
|
|
selected: number[];
|
|
|
|
|
|
applyBulkDelete: () => Promise<void>;
|
|
|
|
|
|
undoBulkDelete: () => Promise<void>;
|
|
|
|
|
|
dealsState: { id: number }[];
|
|
|
|
|
|
lastDeletedSnapshot: { id: number }[];
|
|
|
|
|
|
deleteToastText: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Удаляем
|
|
|
|
|
|
vm.selected = [1000, 1001];
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
await vm.applyBulkDelete();
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
|
|
|
|
|
expect(vm.dealsState.find((d) => d.id === 1000)).toBeUndefined();
|
|
|
|
|
|
expect(vm.lastDeletedSnapshot).toHaveLength(2);
|
|
|
|
|
|
|
|
|
|
|
|
// Undo
|
|
|
|
|
|
await vm.undoBulkDelete();
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
|
|
|
|
|
expect(dealsApi.bulkRestoreDeals).toHaveBeenCalledWith({ tenant_id: 1, ids: [1000, 1001] });
|
|
|
|
|
|
expect(vm.dealsState.find((d) => d.id === 1000)).toBeDefined();
|
|
|
|
|
|
expect(vm.dealsState.find((d) => d.id === 1001)).toBeDefined();
|
|
|
|
|
|
expect(vm.lastDeletedSnapshot).toHaveLength(0); // cleared after undo
|
|
|
|
|
|
expect(vm.deleteToastText).toContain('Восстановлено 2');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('undoBulkDelete без tenant_id — только локально, bulkRestoreDeals НЕ вызывается', async () => {
|
|
|
|
|
|
setupAuth(null);
|
|
|
|
|
|
const wrapper = await mountDealsView();
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
|
|
|
|
|
const vm = wrapper.vm as unknown as {
|
|
|
|
|
|
selected: number[];
|
|
|
|
|
|
applyBulkDelete: () => Promise<void>;
|
|
|
|
|
|
undoBulkDelete: () => Promise<void>;
|
|
|
|
|
|
dealsState: { id: number }[];
|
|
|
|
|
|
lastDeletedSnapshot: { id: number }[];
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const sample = vm.dealsState[0];
|
|
|
|
|
|
vm.selected = [sample.id];
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
await vm.applyBulkDelete();
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
expect(vm.dealsState.find((d) => d.id === sample.id)).toBeUndefined();
|
|
|
|
|
|
|
|
|
|
|
|
await vm.undoBulkDelete();
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
|
|
|
|
|
expect(dealsApi.bulkRestoreDeals).not.toHaveBeenCalled();
|
|
|
|
|
|
expect(vm.dealsState.find((d) => d.id === sample.id)).toBeDefined();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('undoBulkDelete reject → warning toast, локальное восстановление остаётся', async () => {
|
|
|
|
|
|
setupAuth(1);
|
|
|
|
|
|
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
|
|
|
|
|
deals: [makeApiDeal({ id: 1100 })],
|
|
|
|
|
|
total: 1,
|
|
|
|
|
|
limit: 200,
|
|
|
|
|
|
offset: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
vi.mocked(dealsApi.bulkDeleteDeals).mockResolvedValueOnce({ deleted: 1, requested: 1 });
|
|
|
|
|
|
vi.mocked(dealsApi.bulkRestoreDeals).mockRejectedValueOnce(new Error('500'));
|
|
|
|
|
|
|
|
|
|
|
|
const wrapper = await mountDealsView();
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
|
|
|
|
|
const vm = wrapper.vm as unknown as {
|
|
|
|
|
|
selected: number[];
|
|
|
|
|
|
applyBulkDelete: () => Promise<void>;
|
|
|
|
|
|
undoBulkDelete: () => Promise<void>;
|
|
|
|
|
|
dealsState: { id: number }[];
|
|
|
|
|
|
deleteToastText: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
vm.selected = [1100];
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
await vm.applyBulkDelete();
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
await vm.undoBulkDelete();
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
|
|
|
|
|
expect(vm.dealsState.find((d) => d.id === 1100)).toBeDefined(); // optimistic
|
|
|
|
|
|
expect(vm.deleteToastText).toContain('Не удалось восстановить');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-09 07:46:19 +03:00
|
|
|
|
it('applyBulkStatus с reject → toast с warning, локальный update остаётся (не откатываем)', async () => {
|
|
|
|
|
|
setupAuth(1);
|
|
|
|
|
|
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
|
|
|
|
|
deals: [makeApiDeal({ id: 600, status: 'new' })],
|
|
|
|
|
|
total: 1,
|
|
|
|
|
|
limit: 200,
|
|
|
|
|
|
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('paid');
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
|
|
|
|
|
expect(vm.dealsState.find((d) => d.id === 600)?.statusSlug).toBe('paid');
|
|
|
|
|
|
expect(vm.statusToastText).toContain('Не удалось');
|
|
|
|
|
|
});
|
2026-05-09 07:34:39 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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: 'paid' }),
|
|
|
|
|
|
makeApiDeal({ id: 302, status: 'paid' }),
|
|
|
|
|
|
],
|
|
|
|
|
|
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.paid.map((d) => d.id).sort()).toEqual([301, 302]);
|
|
|
|
|
|
expect(vm.totalDeals).toBe(3);
|
|
|
|
|
|
expect(vm.fetchError).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('listDeals reject → fetchError=true, MOCK_DEALS остаётся в колонках', 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);
|
|
|
|
|
|
// Хотя бы одна колонка с mock-сделками заполнена (изначальный state).
|
|
|
|
|
|
const filledColumns = Object.values(vm.dealsByStatus).filter((arr) => arr.length > 0);
|
|
|
|
|
|
expect(filledColumns.length).toBeGreaterThan(0);
|
|
|
|
|
|
});
|
2026-05-09 07:46:19 +03:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-05-09 07:34:39 +03:00
|
|
|
|
});
|