2026-05-09 11:27:57 +03:00
|
|
|
|
import { describe, it, expect, vi } from 'vitest';
|
2026-05-08 17:21:19 +03:00
|
|
|
|
import { mount } from '@vue/test-utils';
|
2026-05-08 20:29:05 +03:00
|
|
|
|
import { createPinia, setActivePinia } from 'pinia';
|
2026-05-08 17:21:19 +03:00
|
|
|
|
import { createVuetify } from 'vuetify';
|
|
|
|
|
|
import { createRouter, createMemoryHistory } from 'vue-router';
|
2026-05-09 11:27:57 +03:00
|
|
|
|
|
|
|
|
|
|
// Мокаем api/notifications до import'а AppLayout (использует store, который импортит api).
|
|
|
|
|
|
vi.mock('../../resources/js/api/notifications', () => ({
|
|
|
|
|
|
listNotifications: vi.fn().mockResolvedValue({ items: [], unread_count: 0, total: 0 }),
|
|
|
|
|
|
markNotificationRead: vi.fn(),
|
|
|
|
|
|
markAllNotificationsRead: vi.fn(),
|
|
|
|
|
|
deleteNotification: vi.fn(),
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
2026-05-09 12:41:41 +03:00
|
|
|
|
vi.mock('../../resources/js/api/reminders', () => ({
|
|
|
|
|
|
listReminders: vi.fn().mockResolvedValue({
|
|
|
|
|
|
items: [],
|
|
|
|
|
|
counts: { active: 0, today: 0, upcoming: 0, overdue: 0 },
|
|
|
|
|
|
}),
|
|
|
|
|
|
createReminder: vi.fn(),
|
|
|
|
|
|
updateReminder: vi.fn(),
|
|
|
|
|
|
completeReminder: vi.fn(),
|
|
|
|
|
|
deleteReminder: vi.fn(),
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
2026-05-09 11:27:57 +03:00
|
|
|
|
import * as notificationsApi from '../../resources/js/api/notifications';
|
2026-05-08 17:21:19 +03:00
|
|
|
|
import AppLayout from '../../resources/js/layouts/AppLayout.vue';
|
2026-05-08 20:29:05 +03:00
|
|
|
|
import { useAuthStore } from '../../resources/js/stores/auth';
|
2026-05-09 11:27:57 +03:00
|
|
|
|
import { useNotificationsStore } from '../../resources/js/stores/notifications';
|
2026-05-08 20:29:05 +03:00
|
|
|
|
import type { AuthUser } from '../../resources/js/api/auth';
|
|
|
|
|
|
|
|
|
|
|
|
const mockUser: AuthUser = {
|
|
|
|
|
|
id: 1,
|
|
|
|
|
|
email: 'ivan.petrov@example.ru',
|
|
|
|
|
|
first_name: 'Иван',
|
|
|
|
|
|
last_name: 'Петров',
|
|
|
|
|
|
tenant_id: 1,
|
|
|
|
|
|
totp_enabled: false,
|
|
|
|
|
|
last_login_at: null,
|
|
|
|
|
|
};
|
2026-05-08 17:21:19 +03:00
|
|
|
|
|
|
|
|
|
|
// AppLayout содержит sidebar (8 nav-items в 3 группах) + topbar (crumb/search/user) + RouterView.
|
|
|
|
|
|
|
2026-05-08 20:29:05 +03:00
|
|
|
|
const mountAppLayout = async (path = '/dashboard', user: AuthUser | null = mockUser) => {
|
|
|
|
|
|
setActivePinia(createPinia());
|
|
|
|
|
|
const auth = useAuthStore();
|
|
|
|
|
|
auth.user = user;
|
|
|
|
|
|
|
2026-05-08 17:21:19 +03:00
|
|
|
|
const router = createRouter({
|
|
|
|
|
|
history: createMemoryHistory(),
|
|
|
|
|
|
routes: [
|
|
|
|
|
|
{ path: '/dashboard', component: { template: '<div>dashboard</div>' } },
|
|
|
|
|
|
{ path: '/deals', component: { template: '<div>deals</div>' } },
|
|
|
|
|
|
{ path: '/kanban', component: { template: '<div>kanban</div>' } },
|
|
|
|
|
|
{ path: '/reminders', component: { template: '<div>reminders</div>' } },
|
|
|
|
|
|
{ path: '/billing', component: { template: '<div>billing</div>' } },
|
|
|
|
|
|
{ path: '/reports', component: { template: '<div>reports</div>' } },
|
|
|
|
|
|
{ path: '/managers', component: { template: '<div>managers</div>' } },
|
|
|
|
|
|
{ path: '/settings', component: { template: '<div>settings</div>' } },
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
await router.push(path);
|
|
|
|
|
|
await router.isReady();
|
|
|
|
|
|
return mount(AppLayout, {
|
|
|
|
|
|
global: { plugins: [createVuetify(), router] },
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
describe('AppLayout.vue', () => {
|
|
|
|
|
|
it('монтируется без ошибок', async () => {
|
|
|
|
|
|
const wrapper = await mountAppLayout();
|
|
|
|
|
|
expect(wrapper.exists()).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('содержит брендовый блок «Лидерра.» в sidebar', async () => {
|
|
|
|
|
|
const wrapper = await mountAppLayout();
|
|
|
|
|
|
expect(wrapper.text()).toContain('Лидерра');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('содержит 3 nav-группы: Работа, Финансы, Команда', async () => {
|
|
|
|
|
|
const wrapper = await mountAppLayout();
|
|
|
|
|
|
const text = wrapper.text();
|
|
|
|
|
|
expect(text).toContain('Работа');
|
|
|
|
|
|
expect(text).toContain('Финансы');
|
|
|
|
|
|
expect(text).toContain('Команда');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('содержит все 8 nav-пунктов', async () => {
|
|
|
|
|
|
const wrapper = await mountAppLayout();
|
|
|
|
|
|
const text = wrapper.text();
|
|
|
|
|
|
['Дашборд', 'Сделки', 'Канбан', 'Напоминания', 'Биллинг', 'Отчёты', 'Менеджеры', 'Настройки'].forEach((label) =>
|
|
|
|
|
|
expect(text).toContain(label),
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('показывает счётчики только у пунктов с count', async () => {
|
|
|
|
|
|
const wrapper = await mountAppLayout();
|
|
|
|
|
|
const text = wrapper.text();
|
2026-05-09 12:41:41 +03:00
|
|
|
|
expect(text).toContain('247'); // Сделки (mock)
|
|
|
|
|
|
expect(text).toContain('4'); // Менеджеры (mock)
|
|
|
|
|
|
// Напоминания: counts.active=0 → бейдж скрыт по новому правилу (count > 0).
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('бейдж напоминаний скрыт при counts.active=0', async () => {
|
|
|
|
|
|
const wrapper = await mountAppLayout();
|
|
|
|
|
|
await wrapper.vm.$nextTick();
|
|
|
|
|
|
expect(wrapper.find('[data-testid="nav-count-reminders"]').exists()).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('бейдж напоминаний показывается при counts.active>0', async () => {
|
|
|
|
|
|
const { useRemindersStore } = await import('../../resources/js/stores/reminders');
|
|
|
|
|
|
const wrapper = await mountAppLayout();
|
|
|
|
|
|
const reminders = useRemindersStore();
|
|
|
|
|
|
reminders.counts.active = 7;
|
|
|
|
|
|
await wrapper.vm.$nextTick();
|
|
|
|
|
|
|
|
|
|
|
|
const badge = wrapper.find('[data-testid="nav-count-reminders"]');
|
|
|
|
|
|
expect(badge.exists()).toBe(true);
|
|
|
|
|
|
expect(badge.text()).toBe('7');
|
2026-05-08 17:21:19 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('breadcrumb показывает текущую страницу', async () => {
|
|
|
|
|
|
const wrapper = await mountAppLayout('/dashboard');
|
|
|
|
|
|
expect(wrapper.text()).toContain('Рабочая область');
|
|
|
|
|
|
expect(wrapper.text()).toContain('Дашборд');
|
|
|
|
|
|
});
|
2026-05-08 20:29:05 +03:00
|
|
|
|
|
|
|
|
|
|
it('user-chip показывает initials и shortName из store user', async () => {
|
|
|
|
|
|
const wrapper = await mountAppLayout();
|
|
|
|
|
|
const text = wrapper.text();
|
|
|
|
|
|
expect(text).toContain('ИП'); // initials Иван Петров
|
|
|
|
|
|
expect(text).toContain('Иван П.'); // shortName
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('при null user (гость) показывает «?» и «Гость»', async () => {
|
|
|
|
|
|
const wrapper = await mountAppLayout('/dashboard', null);
|
|
|
|
|
|
const text = wrapper.text();
|
|
|
|
|
|
expect(text).toContain('Гость');
|
|
|
|
|
|
expect(text).toContain('?');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('при отсутствии first_name fallback на email', async () => {
|
|
|
|
|
|
const wrapper = await mountAppLayout('/dashboard', {
|
|
|
|
|
|
...mockUser,
|
|
|
|
|
|
first_name: null,
|
|
|
|
|
|
last_name: null,
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(wrapper.text()).toContain('ivan.petrov@example.ru');
|
|
|
|
|
|
});
|
2026-05-09 11:27:57 +03:00
|
|
|
|
|
|
|
|
|
|
it('bell-icon кнопка существует', async () => {
|
|
|
|
|
|
const wrapper = await mountAppLayout();
|
|
|
|
|
|
const bellBtn = wrapper.find('[data-testid="notifications-btn"]');
|
|
|
|
|
|
expect(bellBtn.exists()).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('pip скрыт когда unreadCount=0 (default state)', async () => {
|
|
|
|
|
|
const wrapper = await mountAppLayout();
|
|
|
|
|
|
await wrapper.vm.$nextTick();
|
|
|
|
|
|
const pip = wrapper.find('[data-testid="notifications-pip"]');
|
|
|
|
|
|
expect(pip.exists()).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('pip показывает unreadCount когда > 0', async () => {
|
|
|
|
|
|
const wrapper = await mountAppLayout();
|
|
|
|
|
|
const store = useNotificationsStore();
|
|
|
|
|
|
store.unreadCount = 5;
|
|
|
|
|
|
await wrapper.vm.$nextTick();
|
|
|
|
|
|
|
|
|
|
|
|
const pip = wrapper.find('[data-testid="notifications-pip"]');
|
|
|
|
|
|
expect(pip.exists()).toBe(true);
|
|
|
|
|
|
expect(pip.text()).toBe('5');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('pip показывает «99+» когда unreadCount > 99', async () => {
|
|
|
|
|
|
const wrapper = await mountAppLayout();
|
|
|
|
|
|
const store = useNotificationsStore();
|
|
|
|
|
|
store.unreadCount = 142;
|
|
|
|
|
|
await wrapper.vm.$nextTick();
|
|
|
|
|
|
|
|
|
|
|
|
const pip = wrapper.find('[data-testid="notifications-pip"]');
|
|
|
|
|
|
expect(pip.text()).toBe('99+');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('listNotifications вызывается на mount при наличии user', async () => {
|
|
|
|
|
|
await mountAppLayout('/dashboard', mockUser);
|
|
|
|
|
|
expect(notificationsApi.listNotifications).toHaveBeenCalled();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('listNotifications НЕ вызывается на mount без user', async () => {
|
|
|
|
|
|
vi.mocked(notificationsApi.listNotifications).mockClear();
|
|
|
|
|
|
await mountAppLayout('/dashboard', null);
|
|
|
|
|
|
expect(notificationsApi.listNotifications).not.toHaveBeenCalled();
|
|
|
|
|
|
});
|
2026-05-08 17:21:19 +03:00
|
|
|
|
});
|