Files
portal/app/tests/Frontend/AppLayout.spec.ts
T
Дмитрий 508de4eaf3 phase2(notifications-stage2b): API + Pinia + bell в AppLayout (P0 этап 2b)
Закрывает этап 2 P0 целиком (UI bell с unread badge + polling).

Backend:
- App\Http\Controllers\Api\InAppNotificationController под auth:sanctum:
  GET /api/notifications?unread_only=&limit= (1..100 default 50);
  PATCH /api/notifications/{id}/read (idempotent);
  POST /api/notifications/mark-all-read (bulk + count);
  DELETE /api/notifications/{id}.
- Route::middleware('auth:sanctum')->prefix('/api/notifications') в web.php.
- DB::transaction + SET LOCAL app.current_tenant_id для RLS.
- Защита от кражи чужого id через where('user_id', $auth->id).
- Pest +14 (305/305 за 34.71 сек, 1099 assertions).

Frontend:
- api/notifications.ts — типизированные axios-helpers + ensureCsrfCookie.
- stores/notifications.ts — Pinia: items/unreadCount/total/loading +
  optimistic markRead/markAllRead/remove с revert на reject.
- AppLayout: bell-icon → v-menu offset=8 location=bottom-end:
  pip badge показывает unreadDisplay (1..99 / 99+ / hidden);
  v-list последних 10 из sortedItems с event-icon + formatRelative;
  Mark-all-read btn только при unreadCount > 0;
  click на item → markRead + router.push('/deals') если deal_id.
- usePolling(loadNotifications, {intervalMs: 30_000}) с Page Visibility.
- loadNotifications no-op без auth.user.
- Vitest +18 (339/339 за 20.03 сек): store 12 + AppLayout +6
  (bell-btn / pip скрыт при 0 / pip count / 99+ / listNotifications
  на mount с user / no-op без user).

PHPStan baseline регенерирован (50 Pest false-positives подавлены).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:27:57 +03:00

167 lines
6.8 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';
// Мокаем 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(),
}));
import * as notificationsApi from '../../resources/js/api/notifications';
import AppLayout from '../../resources/js/layouts/AppLayout.vue';
import { useAuthStore } from '../../resources/js/stores/auth';
import { useNotificationsStore } from '../../resources/js/stores/notifications';
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,
};
// AppLayout содержит sidebar (8 nav-items в 3 группах) + topbar (crumb/search/user) + RouterView.
const mountAppLayout = async (path = '/dashboard', user: AuthUser | null = mockUser) => {
setActivePinia(createPinia());
const auth = useAuthStore();
auth.user = user;
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();
expect(text).toContain('247'); // Сделки
expect(text).toContain('12'); // Напоминания
expect(text).toContain('4'); // Менеджеры
});
it('breadcrumb показывает текущую страницу', async () => {
const wrapper = await mountAppLayout('/dashboard');
expect(wrapper.text()).toContain('Рабочая область');
expect(wrapper.text()).toContain('Дашборд');
});
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');
});
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();
});
});