Files
RenewCMS/web/src/views/__tests__/HomeUpdateBanner.spec.ts
T

194 lines
6.2 KiB
TypeScript

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()
})
})