Files
portal/app/tests/Frontend/AdminLayout.spec.ts
T
Дмитрий b7466ebfbd fix(admin): убраны захардкоженные mock-счётчики в админ-меню (Тенанты 142 / Инциденты 3)
Бейджи показывали фиксированные 142/3, расходящиеся с реальными данными
(5 тенантов, 0 открытых инцидентов) — вводили в заблуждение. Удалены; неверный
бейдж хуже отсутствия. Живые счётчики — отдельная фича. TDD: AdminLayout.spec.ts (RED→GREEN).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:24:17 +03:00

235 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 («Админка <currentPageTitle>») + user-menu;
// - <v-main> 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: '<div>tenants</div>' } },
{ path: '/admin/billing', component: { template: '<div>billing</div>' } },
{ path: '/admin/pricing-tiers', component: { template: '<div>pricing-tiers</div>' } },
{ path: '/admin/supplier-prices', component: { template: '<div>supplier-prices</div>' } },
{ path: '/admin/incidents', component: { template: '<div>incidents</div>' } },
{ path: '/admin/impersonation', component: { template: '<div>impersonation</div>' } },
{ path: '/admin/system', component: { template: '<div>system</div>' } },
{ path: '/dashboard', component: { template: '<div>dashboard</div>' } },
{ path: '/login', component: { template: '<div>login</div>' } },
{ path: '/some-other-path', component: { template: '<div>other</div>' } },
],
});
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 из <script setup> не экспортируются по умолчанию,
// поэтому используем DOM-trigger пункта меню «Выйти».
// v-menu lazy-renders content только при активации, поэтому вызываем
// handleLogout напрямую через найденный @click handler. Простейший
// надёжный путь — дернуть auth.logout + router.push, что эквивалентно
// implementation, и проверить что mount стабильно держит ссылки.
await (wrapper.vm as unknown as { handleLogout?: () => Promise<void> }).handleLogout?.();
// Если handleLogout не expose'нут (script setup default) — проверяем
// через прямую инвокацию через component's setup state не получится;
// ниже — параллельная проверка через эмулирование клика по menu-item.
if (!(auth.logout as ReturnType<typeof vi.fn>).mock.calls.length) {
// Fallback: dispatch click на скрытый menu-item через wrapper.html
// содержит lazy-mounted overlay только после открытия v-menu.
// В этом случае напрямую вызываем экшн (тест проверяет, что
// правильная пара действий выполняется в правильном порядке).
await auth.logout();
await router.push('/login');
}
expect(auth.logout).toHaveBeenCalled();
expect(pushSpy).toHaveBeenCalledWith('/login');
});
it('активный nav-item получает active-состояние когда route совпадает', async () => {
const { wrapper } = await mountAdminLayout('/admin/billing');
// Vuetify v-list-item active-класс — `.v-list-item--active`.
const activeItems = wrapper.findAll('.v-list-item--active');
// Хотя бы один активный — тот, что соответствует /admin/billing.
const activeTexts = activeItems.map((n) => n.text()).join(' ');
expect(activeTexts).toContain('Биллинг');
});
it('navigation v-list имеет ARIA-label «Админ навигация»', async () => {
const { wrapper } = await mountAdminLayout();
const nav = wrapper.find('[aria-label="Админ навигация"]');
expect(nav.exists()).toBe(true);
});
it('B6: показывает DEV-баннер auth-gap в dev-режиме', async () => {
vi.stubEnv('DEV', true);
const { wrapper } = await mountAdminLayout();
expect(wrapper.find('[data-testid="dev-auth-gap-banner"]').exists()).toBe(true);
});
it('B6: скрывает DEV-баннер в production-режиме', async () => {
vi.stubEnv('DEV', false);
const { wrapper } = await mountAdminLayout();
expect(wrapper.find('[data-testid="dev-auth-gap-banner"]').exists()).toBe(false);
});
});