feat: PrimeVue replaced by Nuxt UI using token files from web/src/theme, with AuthShell, DashboardShell, AppBoot and the route guards from routes.md

This commit is contained in:
fsylvain committed 2026-09-25 17:09:34 +02:00
1 parent 24f95a8fd7
commit eed661bb24
38 files changed
+2365 -108

No files matched your search

@@ -0,0 +1,250 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import { defineComponent, h } from 'vue'
import { RouterView } from 'vue-router'
import router from '@/router'
import { useApi, ApiError } from '@/composables/useApi'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return {
...actual,
useApi: vi.fn(),
}
})
const RootComponent = defineComponent({
setup() {
return () => h(RouterView)
},
})
let wrapper: VueWrapper | null = null
function setUpSession() {
const pinia = createPinia()
setActivePinia(pinia)
}
function mountApp() {
wrapper = mount(RootComponent, {
global: {
plugins: [router, uiPlugin],
},
})
return wrapper
}
beforeEach(() => {
vi.mocked(useApi).mockReset()
})
afterEach(async () => {
wrapper?.unmount()
wrapper = null
await router.push('/login')
})
describe('Nuxt UI shell foundation and router guards', () => {
it('A public route never triggers a session check', async () => {
const getMe = vi.fn()
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/login')
const app = mountApp()
await flushPromises()
expect(getMe).not.toHaveBeenCalled()
expect(app.find('[aria-label="Loading"]').exists()).toBe(false)
expect(app.find('h1').text()).toBe('Log in')
})
it('The first navigation to a non-public route resolves the session and shows AppBoot meanwhile', async () => {
let resolveMe: (value: { verified: boolean }) => void = () => {}
const getMe = vi.fn(
() => new Promise<{ verified: boolean }>((resolve) => (resolveMe = resolve)),
)
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/home')
const app = mountApp()
await flushPromises()
expect(app.find('[aria-label="Loading"]').exists()).toBe(true)
expect(app.text()).not.toContain('Home placeholder')
resolveMe({ verified: true })
await flushPromises()
expect(app.find('[aria-label="Loading"]').exists()).toBe(false)
expect(app.text()).toContain('Home placeholder')
expect(router.currentRoute.value.path).toBe('/home')
})
it('A second navigation reuses the cached session instead of calling GET /v1/auth/me again', async () => {
const getMe = vi.fn().mockResolvedValue({ verified: true })
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/home')
const app = mountApp()
await flushPromises()
await router.push('/article')
await flushPromises()
expect(getMe).toHaveBeenCalledTimes(1)
expect(app.find('[aria-label="Loading"]').exists()).toBe(false)
})
it('A verified-only route admits a verified session', async () => {
const getMe = vi.fn().mockResolvedValue({ verified: true })
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/article')
mountApp()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/article')
})
it('A verified-only route redirects an unverified session to the pending page', async () => {
const getMe = vi.fn().mockResolvedValue({ verified: false })
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/article')
mountApp()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
})
it('A session-only route admits an unverified session', async () => {
const getMe = vi.fn().mockResolvedValue({ verified: false })
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/register/pending')
mountApp()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/register/pending')
})
it('No session redirects to login with the original path and an expired reason', async () => {
const getMe = vi.fn().mockRejectedValue(new ApiError(401))
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/article/12/edit')
mountApp()
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(
'/login?redirect=/article/12/edit&reason=expired',
)
})
it.each([
{ verified: true, destination: '/home' },
{ verified: false, destination: '/register/pending' },
])(
'The root path resolves depending on the session (verified $verified)',
async ({ verified, destination }) => {
const getMe = vi.fn().mockResolvedValue({ verified })
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/')
mountApp()
await flushPromises()
expect(router.currentRoute.value.path).toBe(destination)
},
)
it('The root path resolves to /login when there is no session', async () => {
const getMe = vi.fn().mockRejectedValue(new ApiError(401))
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/')
mountApp()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/login')
})
it('A session check that fails with a server error renders the Generic error view instead of redirecting', async () => {
const getMe = vi.fn().mockRejectedValue(new ApiError(500))
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/')
const app = mountApp()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/')
expect(app.find('h1').text()).toBe('Something went wrong')
expect(app.text()).toContain(
"This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.",
)
expect(app.text()).toContain('Home')
const primaryButton = app.findAll('button').find((button) => button.text() === 'Try again')
await primaryButton?.trigger('click')
await flushPromises()
expect(getMe).toHaveBeenCalledTimes(2)
})
it('A session check that fails with a network error on a non-root verified route also renders the Generic error view', async () => {
const getMe = vi.fn().mockRejectedValue(new TypeError('network error'))
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/article')
const app = mountApp()
await vi.waitFor(() =>
expect(app.find('[role="alert"] h1').text()).toBe('Something went wrong'),
)
expect(router.currentRoute.value.path).toBe('/article')
const primaryButton = app.findAll('button').find((button) => button.text() === 'Try again')
await primaryButton?.trigger('click')
await flushPromises()
expect(getMe).toHaveBeenCalledTimes(2)
})
it.each([
{ path: '/home', title: 'Home' },
{ path: '/article', title: 'Articles' },
{ path: '/integration', title: 'Integration' },
])(
'Placeholder verified route $path renders inside DashboardShell with title $title',
async ({ path, title }) => {
const getMe = vi.fn().mockResolvedValue({ verified: true })
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push(path)
const app = mountApp()
await flushPromises()
const headings = app.findAll('h1')
expect(headings.some((heading) => heading.text() === title)).toBe(true)
expect(app.find('nav[aria-label="breadcrumb"]').text()).toBe(title)
const activeLink = app.find(`a[href="${path}"][aria-current="page"]`)
expect(activeLink.exists()).toBe(true)
},
)
})
+143 -3
View File
@@ -1,11 +1,151 @@
import { createRouter, createWebHistory } from 'vue-router'
import {
createRouter,
createWebHistory,
type RouteLocationNormalized,
type RouteLocationRaw,
} from 'vue-router'
import { useSessionStore } from '@/stores/session'
export type AuthRequirement = 'public' | 'session' | 'verified' | 'any'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/', component: () => import('@/views/HomeView.vue') },
{ path: '/article', component: () => import('@/views/ArticleView.vue') },
{ path: '/', component: () => import('@/views/RootView.vue'), meta: { auth: 'any' } },
{
path: '/login',
component: () => import('@/views/LoginView.vue'),
meta: { auth: 'public' },
},
{
path: '/register',
component: () => import('@/views/RegisterView.vue'),
meta: { auth: 'public' },
},
{
path: '/register/pending',
component: () => import('@/views/PendingView.vue'),
meta: { auth: 'session' },
},
{
path: '/register/validate',
component: () => import('@/views/VerifyView.vue'),
meta: { auth: 'public' },
},
{
path: '/register/reset/request',
component: () => import('@/views/ResetRequestView.vue'),
meta: { auth: 'public' },
},
{
path: '/register/reset/validate',
component: () => import('@/views/ResetConfirmView.vue'),
meta: { auth: 'public' },
},
{
path: '/home',
component: () => import('@/views/HomeView.vue'),
meta: { auth: 'verified', title: 'Home' },
},
{
path: '/article',
component: () => import('@/views/ArticleView.vue'),
meta: { auth: 'verified', title: 'Articles' },
},
{
path: '/article/new',
component: () => import('@/views/ArticleView.vue'),
meta: { auth: 'verified', title: 'Articles' },
},
{
path: '/article/:id/edit',
component: () => import('@/views/EditorView.vue'),
meta: { auth: 'verified', title: 'Editor' },
},
{
path: '/integration',
component: () => import('@/views/IntegrationView.vue'),
meta: { auth: 'verified', title: 'Integration' },
},
{
path: '/:pathMatch(.*)*',
component: () => import('@/views/NotFoundView.vue'),
meta: { auth: 'any' },
},
],
})
export function resolveAuthRedirect(
to: RouteLocationNormalized,
session: ReturnType<typeof useSessionStore>,
): RouteLocationRaw | true {
const auth = to.meta.auth as AuthRequirement
if (auth === 'public') {
return true
}
if (session.status !== 'ready') {
return true
}
const account = session.session
if (!account) {
if (auth === 'any') {
return { path: '/login' }
}
return { path: '/login', query: { redirect: to.fullPath, reason: 'expired' } }
}
if (!account.verified) {
if (auth === 'verified' || auth === 'any') {
return { path: '/register/pending' }
}
return true
}
if (auth === 'any') {
return { path: '/home' }
}
return true
}
router.beforeEach((to) => {
if (to.meta.auth === 'public') {
return true
}
const session = useSessionStore()
if (session.status === 'idle') {
session.check()
return true
}
if (session.status === 'checking') {
return true
}
return resolveAuthRedirect(to, session)
})
router.afterEach((to) => {
if (to.meta.auth === 'public') {
return
}
const session = useSessionStore()
const settled =
session.status === 'idle' || session.status === 'checking'
? session.check()
: Promise.resolve()
settled.then(() => {
const destination = resolveAuthRedirect(router.currentRoute.value, session)
if (destination !== true) {
router.replace(destination)
}
})
})
export default router