From 4fa07b3874fc55f13bef8b4d73f028fc6b473d60 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Tue, 16 Jun 2026 21:10:48 +0200 Subject: [PATCH] =?UTF-8?q?perf(11-02):=20lazy-load=20non-critical=20route?= =?UTF-8?q?s=20=E2=80=94=20PERF-03?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Keep FileManagerView synchronous for / per D-10 (critical first authenticated surface) - Lazy-load TopicsView, DocumentView, SettingsView, CloudStorageView, CloudFolderView - /folders/:folderId reuses synchronous FileManagerView (no split, same component) - Main bundle reduced from 264.63 kB to 180.17 kB (split into 5 new route chunks) - Extend router guard tests: admin child route blocking, refresh-before-guard flow, lazy-loaded route resolution — 234 tests pass (15 new tests added) --- .../src/router/__tests__/router.guard.test.js | 160 +++++++++++++++++- frontend/src/router/index.js | 25 +-- 2 files changed, 171 insertions(+), 14 deletions(-) diff --git a/frontend/src/router/__tests__/router.guard.test.js b/frontend/src/router/__tests__/router.guard.test.js index 036a88c..aab3144 100644 --- a/frontend/src/router/__tests__/router.guard.test.js +++ b/frontend/src/router/__tests__/router.guard.test.js @@ -6,7 +6,11 @@ vi.mock('../../stores/auth.js', () => ({ useAuthStore: vi.fn(), })) -// Mock all lazily-imported view components so the router can be instantiated +// Mock all lazily-imported view components so the router can be instantiated. +// These mocks also exercise the lazy-load code paths: because the router uses +// () => import(...) for these modules, Vitest's vi.mock() intercepts the dynamic +// import and resolves synchronously — verifying that guard logic is not broken +// by the switch from static to lazy imports (PERF-03 / Plan 11-02). vi.mock('../../views/auth/LoginView.vue', () => ({ default: { template: '
' } })) vi.mock('../../views/auth/RegisterView.vue', () => ({ default: { template: '
' } })) vi.mock('../../views/auth/PasswordResetView.vue', () => ({ default: { template: '
' } })) @@ -19,8 +23,10 @@ vi.mock('../../views/admin/AdminAiView.vue', () => ({ default: { template: '
({ default: { template: '
' } })) vi.mock('../../views/SharedView.vue', () => ({ default: { template: '
' } })) -// Heavy view components imported statically — stub them too +// FileManagerView is the only statically-imported component (kept synchronous per D-10). vi.mock('../../views/FileManagerView.vue', () => ({ default: { template: '
' } })) + +// Lazy-loaded route components (now use () => import(...) in router/index.js). vi.mock('../../views/TopicsView.vue', () => ({ default: { template: '
' } })) vi.mock('../../views/DocumentView.vue', () => ({ default: { template: '
' } })) vi.mock('../../views/SettingsView.vue', () => ({ default: { template: '
' } })) @@ -35,6 +41,10 @@ beforeEach(() => { vi.clearAllMocks() }) +// --------------------------------------------------------------------------- +// Auth route meta +// --------------------------------------------------------------------------- + describe('router — auth route meta (SEC-07, AUTH-01)', () => { const authPaths = ['/login', '/register', '/password-reset', '/password-reset/confirm'] @@ -57,6 +67,10 @@ describe('router — auth route meta (SEC-07, AUTH-01)', () => { ) }) +// --------------------------------------------------------------------------- +// Admin guard — non-admin blocked, admin redirected from user routes +// --------------------------------------------------------------------------- + describe('router — admin guard (SEC-07)', () => { it('redirects a non-admin user away from /admin to /', async () => { // Arrange: authenticated but role = 'viewer' @@ -97,4 +111,146 @@ describe('router — admin guard (SEC-07)', () => { // The D-09 guard branch returns { path: '/admin' } expect(router.currentRoute.value.path).toBe('/admin') }) + + it('non-admin user is blocked from /admin/users (admin child route)', async () => { + useAuthStore.mockReturnValue({ + accessToken: 'fake-token', + user: { id: '2', role: 'viewer' }, + refresh: vi.fn().mockResolvedValue(undefined), + }) + + await router.push('/admin/users') + // Guard returns { path: '/' } because to.matched.some(r => r.meta.requiresAdmin) is true + expect(router.currentRoute.value.path).toBe('/') + }) + + it('non-admin user is blocked from /admin/quotas (admin child route)', async () => { + useAuthStore.mockReturnValue({ + accessToken: 'fake-token', + user: { id: '2', role: 'viewer' }, + refresh: vi.fn().mockResolvedValue(undefined), + }) + + await router.push('/admin/quotas') + expect(router.currentRoute.value.path).toBe('/') + }) + + it('admin is redirected away from /settings to /admin (D-09)', async () => { + useAuthStore.mockReturnValue({ + accessToken: 'fake-token', + user: { id: '1', role: 'admin' }, + refresh: vi.fn().mockResolvedValue(undefined), + }) + + await router.push('/settings') + expect(router.currentRoute.value.path).toBe('/admin') + }) + + it('admin is redirected away from /cloud to /admin (D-09)', async () => { + useAuthStore.mockReturnValue({ + accessToken: 'fake-token', + user: { id: '1', role: 'admin' }, + refresh: vi.fn().mockResolvedValue(undefined), + }) + + await router.push('/cloud') + expect(router.currentRoute.value.path).toBe('/admin') + }) +}) + +// --------------------------------------------------------------------------- +// Refresh-before-guard — runs when access token is absent on reload +// --------------------------------------------------------------------------- + +describe('router — refresh-before-guard (PERF-03, AUTH-01)', () => { + it('calls authStore.refresh() when access token is absent for a protected route', async () => { + const mockRefresh = vi.fn().mockResolvedValue(undefined) + useAuthStore.mockReturnValue({ + accessToken: null, // simulates page reload (token is memory-only) + user: { id: '3', role: 'viewer' }, + refresh: mockRefresh, + }) + + await router.push('/topics') + expect(mockRefresh).toHaveBeenCalledOnce() + }) + + it('redirects to /login when refresh fails and no token exists', async () => { + useAuthStore.mockReturnValue({ + accessToken: null, + user: null, + refresh: vi.fn().mockRejectedValue(new Error('session expired')), + }) + + await router.push('/settings') + expect(router.currentRoute.value.path).toBe('/login') + }) + + it('does NOT call refresh() for public routes', async () => { + const mockRefresh = vi.fn() + useAuthStore.mockReturnValue({ + accessToken: null, + user: null, + refresh: mockRefresh, + }) + + await router.push('/login') + expect(mockRefresh).not.toHaveBeenCalled() + expect(router.currentRoute.value.path).toBe('/login') + }) +}) + +// --------------------------------------------------------------------------- +// Lazy-loaded routes resolve correctly for authenticated regular users +// (verifies that switching from static to () => import() did not break routing) +// --------------------------------------------------------------------------- + +describe('router — lazy-loaded routes resolve (PERF-03)', () => { + const regularUserRoutes = [ + '/topics', + '/topics/finance', + '/document/some-uuid', + '/settings', + '/cloud', + '/cloud/onedrive/root', + ] + + it.each(regularUserRoutes)( + 'route %s resolves for an authenticated regular user', + async (path) => { + useAuthStore.mockReturnValue({ + accessToken: 'fake-token', + user: { id: '4', role: 'viewer' }, + refresh: vi.fn().mockResolvedValue(undefined), + }) + + await router.push(path) + // The guard does not redirect regular users away from their own routes. + // Because router.push() resolves after guards run and the component is + // loaded, currentRoute.path should match the navigation target. + expect(router.currentRoute.value.path).toBe(path) + } + ) + + it('/folders/:folderId reuses synchronous FileManagerView (D-10)', async () => { + useAuthStore.mockReturnValue({ + accessToken: 'fake-token', + user: { id: '4', role: 'viewer' }, + refresh: vi.fn().mockResolvedValue(undefined), + }) + + await router.push('/folders/abc-123') + expect(router.currentRoute.value.path).toBe('/folders/abc-123') + }) + + it('/shared resolves for an authenticated regular user', async () => { + useAuthStore.mockReturnValue({ + accessToken: 'fake-token', + user: { id: '4', role: 'viewer' }, + refresh: vi.fn().mockResolvedValue(undefined), + }) + + await router.push('/shared') + expect(router.currentRoute.value.path).toBe('/shared') + }) }) diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index e0f0d73..ab183f3 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -1,19 +1,20 @@ import { createRouter, createWebHistory } from 'vue-router' import { useAuthStore } from '../stores/auth.js' +// FileManagerView is kept synchronous because it is the critical first authenticated +// surface rendered at '/'. Lazy-loading it would delay the initial paint for logged-in +// users with a valid refresh cookie (the most common app entry point). +// All other authenticated routes are lazy-loaded to split them into separate chunks. +// See PERF-03 / Plan 11-02 and D-10 in 11-CONTEXT.md. import FileManagerView from '../views/FileManagerView.vue' -import TopicsView from '../views/TopicsView.vue' -import DocumentView from '../views/DocumentView.vue' -import SettingsView from '../views/SettingsView.vue' -import CloudFolderView from '../views/CloudFolderView.vue' -import CloudStorageView from '../views/CloudStorageView.vue' const routes = [ - // File manager is the home — handles both root and folder views + // File manager is the home — handles both root and folder views. + // FileManagerView stays synchronous (see comment above). { path: '/', component: FileManagerView }, - { path: '/topics', component: TopicsView }, - { path: '/topics/:name', component: TopicsView }, - { path: '/document/:id', component: DocumentView }, - { path: '/settings', component: SettingsView }, + { path: '/topics', component: () => import('../views/TopicsView.vue') }, + { path: '/topics/:name', component: () => import('../views/TopicsView.vue') }, + { path: '/document/:id', component: () => import('../views/DocumentView.vue') }, + { path: '/settings', component: () => import('../views/SettingsView.vue') }, { path: '/login', @@ -57,13 +58,13 @@ const routes = [ { path: '/cloud', name: 'cloud', - component: CloudStorageView, + component: () => import('../views/CloudStorageView.vue'), meta: { requiresAuth: true }, }, { path: '/cloud/:provider/:folderId(.*)', name: 'cloud-folder', - component: CloudFolderView, + component: () => import('../views/CloudFolderView.vue'), meta: { requiresAuth: true }, },