import { afterEach, describe, expect, it, vi } from 'vitest' import { mount, flushPromises } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import uiPlugin from '@nuxt/ui/vue-plugin' import router from '@/router' import { useSessionStore } from '@/stores/session' import type { Article } from '@/composables/useArticles' import HomeView from '../HomeView.vue' let toastAdd: ReturnType vi.mock('@nuxt/ui/composables', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useToast: () => ({ add: toastAdd }), } }) function mockResponse(status: number, body: unknown = null, headers: Record = {}) { return { ok: status >= 200 && status < 300, status, headers: { get: (name: string) => headers[name] ?? null }, json: async () => body, } as unknown as Response } function routeFor(url: string, init?: RequestInit) { const method = init?.method ?? 'GET' return `${method} ${url}` } function fetchRouter(handlers: Record Response | Promise>) { return vi.fn((url: string, init?: RequestInit) => { const key = routeFor(url, init) const handler = handlers[key] if (!handler) { throw new Error(`Unexpected fetch call: ${key}`) } return handler() }) } const sixArticles: Article[] = [ { id: 1, title: 'How we price a bathroom renovation', body: '', images: [], is_online: true, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-06T00:00:00Z', }, { id: 2, title: 'Winter checklist for timber windows', body: '', images: [], is_online: true, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-05T00:00:00Z', }, { id: 3, title: 'Choosing tiles for a small bathroom', body: '', images: [], is_online: false, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-04T00:00:00Z', }, { id: 4, title: 'A guide to underfloor heating', body: '', images: [], is_online: true, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-03T00:00:00Z', }, { id: 5, title: 'When to reseal a stone worktop', body: '', images: [], is_online: false, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-02T00:00:00Z', }, { id: 6, title: 'Small bathroom lighting ideas', body: '', images: [], is_online: true, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }, ] let currentWrapper: Awaited> | null = null async function mountHome(handlers: Record Response | Promise>) { setActivePinia(createPinia()) toastAdd = vi.fn() const session = useSessionStore() session.session = { verified: true, username: 'marta' } session.status = 'ready' global.fetch = fetchRouter({ 'GET /v1/activity?size=5': () => Promise.resolve(mockResponse(200, { items: [], total: 0, page: 1, size: 5 })), ...handlers, }) as unknown as typeof fetch await router.push('/home') const wrapper = mount(HomeView, { attachTo: document.body, global: { plugins: [router, uiPlugin], }, }) currentWrapper = wrapper await flushPromises() return wrapper } function findButton(wrapper: Awaited>, label: string) { return wrapper.findAll('button').find((button) => button.text() === label) } afterEach(async () => { currentWrapper?.unmount() currentWrapper = null vi.useRealTimers() await router.push('/login') }) describe('Skeletons appear only after 150 ms, never for a fast response', () => { it('shows no skeleton before 150 ms, a skeleton after, then Default once resolved', async () => { vi.useFakeTimers() let resolveList: (() => void) | null = null const wrapper = await mountHome({ 'GET /v1/article/?format=html': () => new Promise((resolve) => { resolveList = () => resolve(mockResponse(200, sixArticles)) }), }) expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false) await vi.advanceTimersByTimeAsync(150) expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true) resolveList?.() await vi.advanceTimersByTimeAsync(0) await flushPromises() expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false) expect(wrapper.text()).toContain('6') expect(wrapper.text()).toContain('articles') }) it('never shows a skeleton when the response resolves in under 150 ms', async () => { vi.useFakeTimers() const wrapper = await mountHome({ 'GET /v1/article/?format=html': () => Promise.resolve(mockResponse(200, sixArticles)), }) await vi.advanceTimersByTimeAsync(0) await flushPromises() expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false) expect(wrapper.text()).toContain('6') }) }) describe('Default — the list loaded with a mix of online and draft articles', () => { it('shows the subtitle, stat tiles and the first 5 articles in order', async () => { const wrapper = await mountHome({ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles), }) expect(wrapper.text()).toContain("Here's where things stand on your site.") const rows = wrapper.find('[data-testid="recent-list"]').findAll('li') expect(rows).toHaveLength(5) expect(rows[0].text()).toContain('How we price a bathroom renovation') expect(rows[4].text()).toContain('When to reseal a stone worktop') expect(wrapper.text()).not.toContain('Small bathroom lighting ideas') expect(rows[0].find('a').attributes('href')).toBe('/article/1/edit') expect(rows[0].text()).toContain('Online') expect(rows[2].text()).toContain('Draft') expect(wrapper.text()).toContain('6') expect(wrapper.text()).toContain('articles') expect(wrapper.text()).toContain('4') expect(wrapper.text()).toContain('online') expect(wrapper.text()).toContain('2') expect(wrapper.text()).toContain('drafts') const guideLink = wrapper.findAll('a').find((a) => a.text() === 'Open the guide') expect(guideLink?.attributes('href')).toBe('/integration') await findButton(wrapper, 'New article')?.trigger('click') await flushPromises() expect(document.body.querySelector('[role="dialog"]')).toBeTruthy() }) }) describe('A successful create hands off to the editor with a toast', () => { it('navigates to /article/{id}/edit and raises the Draft created toast', async () => { const created = { id: 42, title: 'Choosing tiles for a small bathroom', body: 'Small rooms show every grout line, so we start with the floor.', images: [], is_online: false, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', } const wrapper = await mountHome({ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles), 'POST /v1/article/?format=html': () => mockResponse(201, created), }) await findButton(wrapper, 'New article')?.trigger('click') await flushPromises() const titleInput = document.body.querySelector( 'input[placeholder="e.g. Choosing tiles for a small bathroom"]', )! titleInput.value = created.title titleInput.dispatchEvent(new Event('input', { bubbles: true })) await flushPromises() const confirmButton = Array.from(document.body.querySelectorAll('button')).find( (button) => button.textContent?.trim() === 'Create draft', ) confirmButton?.click() await flushPromises() await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article/42/edit')) expect(toastAdd).toHaveBeenCalledWith({ id: 'article-created-42', color: 'success', icon: 'i-lucide-circle-check', title: 'Draft created', description: '"Choosing tiles for a small bathroom" is saved as a draft.', }) }) }) describe('Empty, first run — zero articles', () => { it('shows the empty state and opens NewArticleModal', async () => { const wrapper = await mountHome({ 'GET /v1/article/?format=html': () => mockResponse(200, []), }) expect(wrapper.text()).toContain('Welcome to RenewCMS.') expect(wrapper.text()).toContain('Write your first article') expect(wrapper.text()).toContain( 'It starts as a draft, so nothing appears on your site until you publish it.', ) expect(wrapper.find('[data-testid="stat-tile"]').exists()).toBe(false) await findButton(wrapper, 'New article')?.trigger('click') await flushPromises() expect(document.body.querySelector('[role="dialog"]')).toBeTruthy() }) }) describe('Error — a 500 or a network failure shows the same inline alert', () => { it.each([ { name: 'a 500 response', handler: () => mockResponse(500) }, { name: 'a network failure', handler: () => Promise.reject(new TypeError('Failed to fetch')), }, ])('shows the alert with identical copy for $name', async ({ handler }) => { const wrapper = await mountHome({ 'GET /v1/article/?format=html': handler, }) expect(wrapper.text()).toContain("Here's where things stand on your site.") expect(wrapper.text()).toContain('Good') const alert = wrapper.find('[role="alert"]') expect(alert.exists()).toBe(true) expect(alert.text()).toContain("Couldn't load your articles") expect(alert.text()).toContain('Check your connection and try again.') expect(findButton(wrapper, 'Try again')).toBeTruthy() }) }) describe('Retry re-issues the request and recovers into the Default state', () => { it('replaces the alert with the stat tiles and recent list once the retry succeeds', async () => { const listCall = vi .fn() .mockResolvedValueOnce(mockResponse(500)) .mockResolvedValueOnce(mockResponse(200, sixArticles)) const wrapper = await mountHome({ 'GET /v1/article/?format=html': listCall, }) expect(wrapper.find('[role="alert"]').exists()).toBe(true) await findButton(wrapper, 'Try again')?.trigger('click') await flushPromises() expect(listCall).toHaveBeenCalledTimes(2) expect(wrapper.find('[role="alert"]').exists()).toBe(false) expect(wrapper.text()).toContain('6') expect(wrapper.text()).toContain('articles') }) }) describe('The list is fetched exactly once per navigation', () => { it('calls GET /v1/article/ exactly once and derives both stats and the list from it', async () => { const listCall = vi.fn(() => mockResponse(200, sixArticles)) const wrapper = await mountHome({ 'GET /v1/article/?format=html': listCall, }) expect(listCall).toHaveBeenCalledTimes(1) expect(wrapper.text()).toContain('6') expect(wrapper.find('[data-testid="recent-list"]').findAll('li')).toHaveLength(5) }) }) describe('The greeting matches the hour of day', () => { it.each([ { hour: 0, greeting: 'Good morning' }, { hour: 11, greeting: 'Good morning' }, { hour: 12, greeting: 'Good afternoon' }, { hour: 17, greeting: 'Good afternoon' }, { hour: 18, greeting: 'Good evening' }, { hour: 23, greeting: 'Good evening' }, ])('reads "$greeting, marta" at hour $hour', async ({ hour, greeting }) => { vi.useFakeTimers() vi.setSystemTime(new Date(2026, 0, 1, hour, 0, 0)) const wrapper = await mountHome({ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles), }) expect(wrapper.text()).toContain(`${greeting}, marta`) }) }) describe('The greeting is identical across every load state at a fixed hour', () => { it('reads "Good morning, marta" while loading, in Default, Empty and Error', async () => { vi.useFakeTimers() vi.setSystemTime(new Date(2026, 0, 1, 9, 0, 0)) const loadingWrapper = await mountHome({ 'GET /v1/article/?format=html': () => new Promise(() => {}), }) await vi.advanceTimersByTimeAsync(150) expect(loadingWrapper.text()).toContain('Good morning, marta') const defaultWrapper = await mountHome({ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles), }) expect(defaultWrapper.text()).toContain('Good morning, marta') const emptyWrapper = await mountHome({ 'GET /v1/article/?format=html': () => mockResponse(200, []), }) expect(emptyWrapper.text()).toContain('Good morning, marta') const errorWrapper = await mountHome({ 'GET /v1/article/?format=html': () => mockResponse(500), }) expect(errorWrapper.text()).toContain('Good morning, marta') }) }) describe('The greeting names the person by display name, else username', () => { afterEach(() => { vi.restoreAllMocks() }) it.each([ { displayName: '', name: 'marta' }, { displayName: 'Marta Alves', name: 'Marta Alves' }, ])('reads the name "$name" for display name "$displayName"', async ({ displayName, name }) => { vi.spyOn(Date.prototype, 'getHours').mockReturnValue(9) setActivePinia(createPinia()) const session = useSessionStore() session.session = { verified: true, username: 'marta', displayName } session.status = 'ready' global.fetch = fetchRouter({ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles), }) as unknown as typeof fetch await router.push('/home') const wrapper = mount(HomeView, { attachTo: document.body, global: { plugins: [router, uiPlugin] }, }) currentWrapper = wrapper await flushPromises() expect(wrapper.find('[data-testid="greeting"]').text()).toBe(`Good morning, ${name}`) }) it('shows a display name saved after the page was mounted without a reload', async () => { vi.spyOn(Date.prototype, 'getHours').mockReturnValue(9) setActivePinia(createPinia()) const session = useSessionStore() session.session = { verified: true, username: 'marta', displayName: '' } session.status = 'ready' global.fetch = fetchRouter({ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles), }) as unknown as typeof fetch await router.push('/home') const wrapper = mount(HomeView, { attachTo: document.body, global: { plugins: [router, uiPlugin] }, }) currentWrapper = wrapper await flushPromises() session.setDisplayName('Marta Alves') await flushPromises() expect(wrapper.find('[data-testid="greeting"]').text()).toBe('Good morning, Marta Alves') }) }) describe('The greeting handles a missing username', () => { it('renders the plain greeting without a stray comma when the session has no username', async () => { vi.useFakeTimers() vi.setSystemTime(new Date(2026, 0, 1, 9, 0, 0)) setActivePinia(createPinia()) const session = useSessionStore() session.session = { verified: true } session.status = 'ready' global.fetch = fetchRouter({ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles), }) as unknown as typeof fetch await router.push('/home') const wrapper = mount(HomeView, { attachTo: document.body, global: { plugins: [router, uiPlugin] }, }) currentWrapper = wrapper await flushPromises() const heading = wrapper.find('[data-testid="greeting"]') expect(heading.text()).toBe('Good morning') expect(heading.text()).not.toContain('undefined') expect(heading.text()).not.toMatch(/,\s*$/) }) }) describe('The "Connect your website" card renders in every state', () => { it('renders the same card while loading, in Default, Empty and Error', async () => { vi.useFakeTimers() const wrapper = await mountHome({ 'GET /v1/article/?format=html': () => new Promise(() => {}), }) await vi.advanceTimersByTimeAsync(150) expect(wrapper.text()).toContain('Connect your website') expect(wrapper.text()).toContain( 'Your site reads published articles from RenewCMS. Share this guide with whoever builds it.', ) vi.useRealTimers() const defaultWrapper = await mountHome({ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles), }) expect(defaultWrapper.text()).toContain('Connect your website') const emptyWrapper = await mountHome({ 'GET /v1/article/?format=html': () => mockResponse(200, []), }) expect(emptyWrapper.text()).toContain('Connect your website') const errorWrapper = await mountHome({ 'GET /v1/article/?format=html': () => mockResponse(500), }) expect(errorWrapper.text()).toContain('Connect your website') }) })