import { describe, it, expect, vi } from 'vitest'; import { mount } from '@vue/test-utils'; import { createPinia, setActivePinia } from 'pinia'; import { createVuetify } from 'vuetify'; import { createRouter, createMemoryHistory } from 'vue-router'; import AdminLayout from '../../resources/js/layouts/AdminLayout.vue'; import { useAuthStore } from '../../resources/js/stores/auth'; import type { AuthUser } from '../../resources/js/api/auth'; // AdminLayout содержит: // - sidebar #012019 с brand-block «Лидерра.» + ADMIN метка + 9 nav-items // (Тенанты / Биллинг / Тарифная сетка / Цены поставщиков / Инциденты / // Impersonation / Система / Интеграция с поставщиком / Проекты у поставщика), // без mock count-badge; // - topbar с breadcrumb («Админка › ») + user-menu; // - RouterView; DevIndexBadge. const mockUser: AuthUser = { id: 7, email: 'admin.operator@liderra.ru', first_name: 'Сергей', last_name: 'Иванов', tenant_id: 0, totp_enabled: true, last_login_at: null, }; const mountAdminLayout = async (path = '/admin/tenants', user: AuthUser | null = mockUser) => { setActivePinia(createPinia()); const auth = useAuthStore(); auth.user = user; // logout — экшн store; подменяем spy'ем без замены целиком, // чтобы handleLogout пробежал реальную auth.logout()-сигнатуру. auth.logout = vi.fn().mockResolvedValue(undefined); const router = createRouter({ history: createMemoryHistory(), routes: [ { path: '/admin/tenants', component: { template: '
tenants
' } }, { path: '/admin/billing', component: { template: '
billing
' } }, { path: '/admin/pricing-tiers', component: { template: '
pricing-tiers
' } }, { path: '/admin/supplier-prices', component: { template: '
supplier-prices
' } }, { path: '/admin/incidents', component: { template: '
incidents
' } }, { path: '/admin/impersonation', component: { template: '
impersonation
' } }, { path: '/admin/system', component: { template: '
system
' } }, { path: '/dashboard', component: { template: '
dashboard
' } }, { path: '/login', component: { template: '
login
' } }, { path: '/some-other-path', component: { template: '
other
' } }, ], }); await router.push(path); await router.isReady(); const wrapper = mount(AdminLayout, { global: { plugins: [createVuetify(), router], stubs: { DevIndexBadge: true, ImpersonationBanner: true }, }, }); return { wrapper, auth, router }; }; describe('AdminLayout.vue', () => { afterEach(() => { vi.unstubAllEnvs(); }); it('монтируется без ошибок', async () => { const { wrapper } = await mountAdminLayout(); expect(wrapper.exists()).toBe(true); }); it('содержит брендовый блок «Лидерра.» + ADMIN метку', async () => { const { wrapper } = await mountAdminLayout(); const text = wrapper.text(); expect(text).toContain('Лидерра'); expect(text).toContain('ADMIN'); }); it('рендерит 7 nav-пунктов (Тенанты, Биллинг, Тарифная сетка, Цены поставщиков, Инциденты, Impersonation, Система)', async () => { const { wrapper } = await mountAdminLayout(); const text = wrapper.text(); ['Тенанты', 'Биллинг', 'Тарифная сетка', 'Цены поставщиков', 'Инциденты', 'Impersonation', 'Система'].forEach( (label) => expect(text).toContain(label), ); }); it('не рендерит захардкоженные mock count-badge (live-счётчики — отдельная фича)', async () => { // Ранее в nav были mock-счётчики Тенанты=142 / Инциденты=3, расходящиеся с реальными // данными (5 тенантов / 0 открытых инцидентов). Удалены — неверный бейдж хуже отсутствия. const { wrapper } = await mountAdminLayout(); const counts = wrapper.findAll('.nav-count'); expect(counts).toHaveLength(0); }); it('breadcrumb на /admin/tenants показывает «Тенанты»', async () => { const { wrapper } = await mountAdminLayout('/admin/tenants'); const crumb = wrapper.find('.crumb'); expect(crumb.exists()).toBe(true); expect(crumb.text()).toContain('Админка'); expect(crumb.text()).toContain('Тенанты'); }); it('breadcrumb на /admin/billing показывает «Биллинг»', async () => { const { wrapper } = await mountAdminLayout('/admin/billing'); expect(wrapper.find('.crumb').text()).toContain('Биллинг'); }); it('breadcrumb на /admin/pricing-tiers показывает «Тарифная сетка»', async () => { const { wrapper } = await mountAdminLayout('/admin/pricing-tiers'); expect(wrapper.find('.crumb').text()).toContain('Тарифная сетка'); }); it('breadcrumb на /admin/system показывает «Система»', async () => { const { wrapper } = await mountAdminLayout('/admin/system'); expect(wrapper.find('.crumb').text()).toContain('Система'); }); it('breadcrumb fallback на «Админка» когда route не из admin-nav', async () => { const { wrapper } = await mountAdminLayout('/some-other-path'); const crumbText = wrapper.find('.crumb').text(); // «Админка» появляется и как статика breadcrumb'а, и как fallback-title; // важно убедиться что нет специфичных nav-titles вроде Тенанты/Биллинг. expect(crumbText).toContain('Админка'); ['Тенанты', 'Биллинг', 'Тарифная сетка', 'Цены поставщиков', 'Инциденты', 'Impersonation', 'Система'].forEach( (title) => { expect(crumbText).not.toContain(title); }, ); }); it('user-chip показывает initials «СИ» и shortName «Сергей И.» из store user', async () => { const { wrapper } = await mountAdminLayout(); const chipText = wrapper.find('.user-chip').text(); expect(chipText).toContain('СИ'); expect(chipText).toContain('Сергей И.'); }); it('при null user показывает «АО» и «Админ Оператор»', async () => { const { wrapper } = await mountAdminLayout('/admin/tenants', null); const chipText = wrapper.find('.user-chip').text(); expect(chipText).toContain('АО'); expect(chipText).toContain('Админ Оператор'); }); it('initials fallback на email.slice(0,2).toUpperCase() когда first_name/last_name пусты', async () => { const { wrapper } = await mountAdminLayout('/admin/tenants', { ...mockUser, first_name: null, last_name: null, email: 'xy.operator@liderra.ru', }); const chipText = wrapper.find('.user-chip').text(); expect(chipText).toContain('XY'); }); it('shortName fallback на first_name когда last_name пуст', async () => { const { wrapper } = await mountAdminLayout('/admin/tenants', { ...mockUser, first_name: 'Олег', last_name: null, }); const chipText = wrapper.find('.user-chip').text(); expect(chipText).toContain('Олег'); }); it('shortName fallback на email когда first_name и last_name пусты', async () => { const { wrapper } = await mountAdminLayout('/admin/tenants', { ...mockUser, first_name: null, last_name: null, email: 'fallback@liderra.ru', }); const chipText = wrapper.find('.user-chip').text(); expect(chipText).toContain('fallback@liderra.ru'); }); it('handleLogout вызывает auth.logout() + router.push(/login)', async () => { const { wrapper, auth, router } = await mountAdminLayout('/admin/tenants'); const pushSpy = vi.spyOn(router, 'push'); // handleLogout — приватная функция setup'а; вызываем через invoke // на экземпляре компонента (mount даёт доступ к setup-returns). // Однако functions из