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 router from '@/router' import type { UserRole } from '@/composables/useApi' import type { UpdateStatus } from '@/composables/useUpdate' import { DISMISSED_VERSION_KEY, useUpdateStore } from '@/stores/update' import { useSessionStore } from '@/stores/session' import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport' import HomeView from '../HomeView.vue' import { NEW_TAG, newerAvailable, upToDate } from './updateTestKit' function mockResponse(status: number, body: unknown = null) { return { ok: status >= 200 && status < 300, status, headers: { get: () => null }, json: async () => body, } as unknown as Response } let wrapper: VueWrapper | null = null interface MountOptions { role: UserRole status: UpdateStatus width?: number } async function mountHome({ role, status, width = DESKTOP_WIDTH }: MountOptions) { setViewportWidth(width) setActivePinia(createPinia()) const session = useSessionStore() session.session = { verified: true, username: 'marta', role } session.status = 'ready' global.fetch = vi.fn((url: string) => { if (url === '/v1/article/?format=html') { return Promise.resolve(mockResponse(200, [])) } if (url === '/v1/system/update') { return Promise.resolve(mockResponse(200, status)) } throw new Error(`Unexpected fetch call: ${url}`) }) as unknown as typeof fetch await router.push('/home') wrapper = mount(HomeView, { attachTo: document.body, global: { plugins: [router, uiPlugin] }, }) await flushPromises() return wrapper } function banner() { return document.body.querySelector('[aria-label="Update available"][role="region"]') } function settingsNavItem() { return document.body.querySelector('nav[aria-label="Main"] a[href="/settings"]')! } function mobileDot() { return document.body.querySelector('[role="img"][aria-label="Update available"]') } function buttonByLabel(label: string) { return Array.from(document.body.querySelectorAll('button')).find( (button) => button.textContent?.trim() === label || button.getAttribute('aria-label') === label, ) } beforeEach(() => { localStorage.clear() }) afterEach(async () => { wrapper?.unmount() wrapper = null await router.push('/login') document.body.innerHTML = '' }) describe('Home banner for the owner, update available', () => { it('shows the info region above the content with copy and both buttons', async () => { await mountHome({ role: 'owner', status: newerAvailable() }) const region = banner()! expect(region).not.toBeNull() expect(region.textContent).toContain('Version 2.3.0-beta is available') expect(region.textContent).toContain( 'Update it from Settings. It takes about 2 minutes and the site stays online.', ) expect(region.textContent).toContain('Go to Settings') expect(region.querySelector('button[aria-label="Dismiss"]')).not.toBeNull() const stats = document.body.querySelector('[data-testid="greeting"]')! expect( stats.compareDocumentPosition(region) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy() }) it('goes to /settings from Go to Settings', async () => { await mountHome({ role: 'owner', status: newerAvailable() }) buttonByLabel('Go to Settings')!.click() await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/settings')) }) it('hides on dismiss, remembers the version, and stays hidden after a remount', async () => { await mountHome({ role: 'owner', status: newerAvailable() }) buttonByLabel('Dismiss')!.click() await flushPromises() expect(banner()).toBeNull() expect(localStorage.getItem(DISMISSED_VERSION_KEY)).toBe(NEW_TAG) wrapper?.unmount() document.body.innerHTML = '' await mountHome({ role: 'owner', status: newerAvailable() }) expect(banner()).toBeNull() }) it('shows again when latest changes to a newer version', async () => { localStorage.setItem(DISMISSED_VERSION_KEY, NEW_TAG) await mountHome({ role: 'owner', status: newerAvailable({ latest: 'v2.4.0-beta' }) }) expect(banner()).not.toBeNull() expect(banner()!.textContent).toContain('Version 2.4.0-beta is available') }) it('is absent for an admin and when nothing is newer', async () => { await mountHome({ role: 'admin', status: newerAvailable() }) expect(banner()).toBeNull() wrapper?.unmount() document.body.innerHTML = '' await mountHome({ role: 'owner', status: upToDate() }) expect(banner()).toBeNull() }) }) describe('Settings sidebar pill and mobile dot', () => { it('shows the Update pill and the dot for the owner when available', async () => { await mountHome({ role: 'owner', status: newerAvailable() }) expect(settingsNavItem().textContent).toContain('Update') expect(mobileDot()).not.toBeNull() }) it('keeps the pill and the dot after the banner is dismissed', async () => { await mountHome({ role: 'owner', status: newerAvailable() }) buttonByLabel('Dismiss')!.click() await flushPromises() expect(banner()).toBeNull() expect(settingsNavItem().textContent).toContain('Update') expect(mobileDot()).not.toBeNull() }) it('shows the dot on the mobile menu icon', async () => { await mountHome({ role: 'owner', status: newerAvailable(), width: MOBILE_WIDTH }) expect(mobileDot()).not.toBeNull() expect(banner()).not.toBeNull() }) it.each([ { name: 'an admin', role: 'admin' as const, status: newerAvailable() }, { name: 'an owner who is up to date', role: 'owner' as const, status: upToDate() }, ])('shows no pill and no dot for $name', async ({ role, status }) => { await mountHome({ role, status }) expect(settingsNavItem().textContent).not.toContain('Update') expect(mobileDot()).toBeNull() expect(banner()).toBeNull() }) }) describe('Update store cue rules', () => { it('does not request the status for a non-owner on any page', async () => { await mountHome({ role: 'admin', status: newerAvailable() }) const requested = vi .mocked(global.fetch) .mock.calls.some(([url]) => url === '/v1/system/update') expect(requested).toBe(false) expect(useUpdateStore().status).toBeNull() }) })