de066145d3
- router/index.ts: добавлен маршрут /import (name=import, layout=app, requiresAuth=true, transition=ld-route-fadeup, devIndex=29) - AppSidebar.vue: пункт «Импорт данных» (mdi-database-import-outline) добавлен в группу «Работа» следом за Дашборд - router.spec.ts: TDD-кейс маршрута /import (layout=app, requiresAuth=true) - docs/Как_перенести_данные_из_crm-bp-gr.md: инструкция H9 (4 шага + таблица ошибок) - cspell-words.txt: добавлены формы глагола «замапить» Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
145 lines
6.3 KiB
TypeScript
145 lines
6.3 KiB
TypeScript
import { beforeEach, describe, it, expect, vi } from 'vitest';
|
||
import { createPinia, setActivePinia } from 'pinia';
|
||
|
||
// Мокаем api/auth, чтобы router beforeEach guard не делал реальных HTTP-вызовов.
|
||
// `me()` возвращает AuthUser напрямую (см. resources/js/api/auth.ts:65-68).
|
||
vi.mock('../../resources/js/api/auth', () => ({
|
||
me: vi.fn(() => Promise.reject(new Error('not authenticated'))),
|
||
login: vi.fn(),
|
||
register: vi.fn(),
|
||
logout: vi.fn(),
|
||
}));
|
||
|
||
import { router } from '../../resources/js/router';
|
||
import { useAuthStore } from '../../resources/js/stores/auth';
|
||
|
||
describe('router/index.ts', () => {
|
||
beforeEach(() => {
|
||
setActivePinia(createPinia());
|
||
});
|
||
|
||
it('содержит маршрут /login с layout=auth + guestOnly', () => {
|
||
const loginRoute = router.getRoutes().find((r) => r.name === 'login');
|
||
expect(loginRoute).toBeDefined();
|
||
expect(loginRoute?.path).toBe('/login');
|
||
expect(loginRoute?.meta.layout).toBe('auth');
|
||
expect(loginRoute?.meta.guestOnly).toBe(true);
|
||
});
|
||
|
||
it('защищённые маршруты помечены requiresAuth=true', () => {
|
||
const protectedNames = ['dashboard', 'deals', 'kanban', 'billing', 'settings', 'reports'];
|
||
const routes = router.getRoutes();
|
||
protectedNames.forEach((name) => {
|
||
const route = routes.find((r) => r.name === name);
|
||
expect(route, `route ${name} not found`).toBeDefined();
|
||
expect(route?.meta.requiresAuth, `route ${name} should require auth`).toBe(true);
|
||
});
|
||
});
|
||
|
||
it('admin-маршруты помечены requiresAuth=true и layout=admin', () => {
|
||
const routes = router.getRoutes();
|
||
const adminTenants = routes.find((r) => r.name === 'admin-tenants');
|
||
expect(adminTenants?.meta.requiresAuth).toBe(true);
|
||
expect(adminTenants?.meta.layout).toBe('admin');
|
||
});
|
||
|
||
it('error-маршруты НЕ требуют auth', () => {
|
||
const routes = router.getRoutes();
|
||
const errorNames = ['forbidden', 'server-error', 'not-found'];
|
||
errorNames.forEach((name) => {
|
||
const route = routes.find((r) => r.name === name);
|
||
expect(route?.meta.requiresAuth).toBeUndefined();
|
||
expect(route?.meta.layout).toBe('error');
|
||
});
|
||
});
|
||
|
||
it('гость, идущий на /dashboard без auth, редиректится на /login', async () => {
|
||
await router.push('/dashboard');
|
||
await router.isReady();
|
||
// beforeEach guard делает fetchMe (мок отвергает) → user=null → redirect /login.
|
||
expect(router.currentRoute.value.path).toBe('/login');
|
||
// Сохраняется ?redirect=/dashboard для возврата после login.
|
||
expect(router.currentRoute.value.query.redirect).toBe('/dashboard');
|
||
});
|
||
|
||
// ---------- Integration tests (Q.DEFER.003 sub-C) ----------
|
||
// Покрывают actual navigation flows через beforeEach guard.
|
||
// NB: `authInitialized` (router/index.ts:281) — module-level флаг, после первого
|
||
// теста выше остаётся true. Тесты ниже устанавливают auth.user напрямую через
|
||
// store mutation (bypass fetchMe path).
|
||
|
||
it('authenticated user attempting /login (guestOnly) → redirected to /dashboard', async () => {
|
||
const auth = useAuthStore();
|
||
auth.user = {
|
||
id: 1,
|
||
email: 'test@demo.local',
|
||
first_name: 'Test',
|
||
last_name: 'User',
|
||
tenant_id: 1,
|
||
totp_enabled: false,
|
||
last_login_at: '2026-05-13T00:00:00Z',
|
||
} as unknown as ReturnType<typeof useAuthStore>['user'];
|
||
await router.push('/login');
|
||
await router.isReady();
|
||
expect(router.currentRoute.value.path).toBe('/dashboard');
|
||
});
|
||
|
||
it('authenticated user navigating to /dashboard passes requiresAuth guard', async () => {
|
||
const auth = useAuthStore();
|
||
auth.user = {
|
||
id: 1,
|
||
email: 'test@demo.local',
|
||
first_name: 'Test',
|
||
last_name: 'User',
|
||
tenant_id: 1,
|
||
totp_enabled: false,
|
||
last_login_at: '2026-05-13T00:00:00Z',
|
||
} as unknown as ReturnType<typeof useAuthStore>['user'];
|
||
await router.push('/dashboard');
|
||
await router.isReady();
|
||
expect(router.currentRoute.value.path).toBe('/dashboard');
|
||
expect(router.currentRoute.value.name).toBe('dashboard');
|
||
});
|
||
|
||
it('unknown path /no-such-path resolves to 404 ErrorView', async () => {
|
||
await router.push('/no-such-path-xyz');
|
||
await router.isReady();
|
||
expect(router.currentRoute.value.name).toBe('not-found');
|
||
expect(router.currentRoute.value.meta.errorCode).toBe('404');
|
||
expect(router.currentRoute.value.meta.layout).toBe('error');
|
||
});
|
||
|
||
it('/admin redirects to /admin/tenants', async () => {
|
||
const auth = useAuthStore();
|
||
auth.user = {
|
||
id: 1,
|
||
email: 'test@demo.local',
|
||
first_name: 'Test',
|
||
last_name: 'User',
|
||
tenant_id: 1,
|
||
totp_enabled: false,
|
||
last_login_at: '2026-05-13T00:00:00Z',
|
||
} as unknown as ReturnType<typeof useAuthStore>['user'];
|
||
await router.push('/admin');
|
||
await router.isReady();
|
||
expect(router.currentRoute.value.path).toBe('/admin/tenants');
|
||
expect(router.currentRoute.value.name).toBe('admin-tenants');
|
||
});
|
||
|
||
it('/reset/:token resolves with token param exposed', async () => {
|
||
// auth.user остаётся null (beforeEach создал свежий Pinia), guestOnly пропускает.
|
||
await router.push('/reset/abc123-token-xyz');
|
||
await router.isReady();
|
||
expect(router.currentRoute.value.name).toBe('reset-password');
|
||
expect(router.currentRoute.value.params.token).toBe('abc123-token-xyz');
|
||
});
|
||
|
||
it('маршрут /import зарегистрирован с layout app', () => {
|
||
const importRoute = router.getRoutes().find((r) => r.name === 'import');
|
||
expect(importRoute, 'route import not found').toBeDefined();
|
||
expect(importRoute?.path).toBe('/import');
|
||
expect(importRoute?.meta.layout).toBe('app');
|
||
expect(importRoute?.meta.requiresAuth).toBe(true);
|
||
});
|
||
});
|