873 lines
28 KiB
TypeScript
873 lines
28 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 App from '@/App.vue'
|
|
import { useSessionStore, type Session } from '@/stores/session'
|
|
import { ApiError, NetworkError, authRedirectPending, useApi } from '@/composables/useApi'
|
|
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
|
|
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
|
return { ...actual, useApi: vi.fn() }
|
|
})
|
|
|
|
let toastAdd: ReturnType<typeof vi.fn>
|
|
let overlayMock: ReturnType<typeof createOverlayMock>
|
|
|
|
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
|
return {
|
|
...actual,
|
|
useToast: () => ({ add: toastAdd }),
|
|
useOverlay: () => overlayMock,
|
|
}
|
|
})
|
|
|
|
function createOverlayMock() {
|
|
let props: Record<string, unknown> | null = null
|
|
let resolveOpen: ((value: unknown) => void) | null = null
|
|
const create = vi.fn((_component: unknown, options: { props: Record<string, unknown> }) => {
|
|
props = options.props
|
|
return {
|
|
open: () =>
|
|
new Promise((resolve) => {
|
|
resolveOpen = resolve
|
|
}),
|
|
}
|
|
})
|
|
return {
|
|
create,
|
|
getProps: () => props,
|
|
resolveOpen: (value: unknown) => resolveOpen?.(value),
|
|
}
|
|
}
|
|
|
|
const MARTA: Session = {
|
|
id: 1,
|
|
verified: true,
|
|
username: 'marta',
|
|
email: 'marta@renew.example',
|
|
displayName: '',
|
|
}
|
|
|
|
const DISPLAY_NAME_PATH = '/v1/user/me'
|
|
const RESET_PATH = '/v1/auth/password-reset'
|
|
const LOGOUT_PATH = '/v1/auth/logout'
|
|
const LIMIT_BODY = 'Display name must be 60 characters or fewer.'
|
|
const SAVE_FAILED_COPY =
|
|
"Couldn't save your display name. Your text is still here. Try again in a moment."
|
|
|
|
type Handler = (init?: RequestInit) => Promise<unknown>
|
|
|
|
interface Mounted {
|
|
wrapper: VueWrapper
|
|
request: ReturnType<typeof vi.fn>
|
|
getMe: ReturnType<typeof vi.fn>
|
|
}
|
|
|
|
function deferred<T = unknown>() {
|
|
let resolve!: (value: T) => void
|
|
let reject!: (reason: unknown) => void
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res
|
|
reject = rej
|
|
})
|
|
return { promise, resolve, reject }
|
|
}
|
|
|
|
interface MountOptions {
|
|
session?: Session | null
|
|
status?: 'idle' | 'ready'
|
|
getMe?: () => Promise<Session>
|
|
handlers?: Record<string, Handler>
|
|
path?: string
|
|
}
|
|
|
|
let mountedWrapper: VueWrapper | null = null
|
|
|
|
async function mountApp(options: MountOptions = {}): Promise<Mounted> {
|
|
const { session = MARTA, status = 'ready', handlers = {}, path = '/profile' } = options
|
|
setViewportWidth(DESKTOP_WIDTH)
|
|
setActivePinia(createPinia())
|
|
const store = useSessionStore()
|
|
store.session = session ? { ...session } : null
|
|
store.status = status
|
|
const request = vi.fn((requestPath: string, init?: RequestInit) => {
|
|
const key = `${init?.method ?? 'GET'} ${requestPath}`
|
|
const handler = handlers[key]
|
|
if (handler) {
|
|
return handler(init)
|
|
}
|
|
if (requestPath === LOGOUT_PATH) {
|
|
return Promise.resolve(undefined)
|
|
}
|
|
return Promise.resolve([])
|
|
})
|
|
const getMe = vi.fn(options.getMe ?? (() => Promise.resolve({ ...MARTA })))
|
|
vi.mocked(useApi).mockReturnValue({ getMe, request } as unknown as ReturnType<typeof useApi>)
|
|
await router.push(path)
|
|
const wrapper = mount(App, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, uiPlugin] },
|
|
})
|
|
mountedWrapper = wrapper
|
|
await flushPromises()
|
|
return { wrapper, request, getMe }
|
|
}
|
|
|
|
function displayNameField() {
|
|
return document.body.querySelector('input[placeholder="e.g. Marta Alves"]') as HTMLInputElement
|
|
}
|
|
|
|
async function typeInto(value: string) {
|
|
const input = displayNameField()
|
|
input.value = value
|
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
await flushPromises()
|
|
}
|
|
|
|
function pressEnter() {
|
|
displayNameField().dispatchEvent(
|
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
|
)
|
|
}
|
|
|
|
function buttonByText(pattern: RegExp) {
|
|
return Array.from(document.body.querySelectorAll('button')).find((button) =>
|
|
pattern.test(button.textContent?.trim() ?? ''),
|
|
) as HTMLButtonElement | undefined
|
|
}
|
|
|
|
function saveButton() {
|
|
return buttonByText(/^(Save|Saving…)$/)!
|
|
}
|
|
|
|
function resetButton() {
|
|
return buttonByText(/^(Send reset link|Sending…|Send again)$/)!
|
|
}
|
|
|
|
function byTestId(id: string) {
|
|
return document.body.querySelector(`[data-testid="${id}"]`) as HTMLElement | null
|
|
}
|
|
|
|
function counter() {
|
|
return byTestId('display-name-counter')!
|
|
}
|
|
|
|
function footer() {
|
|
return byTestId('display-name-status')!
|
|
}
|
|
|
|
function isEnabled(button: HTMLButtonElement) {
|
|
return !button.disabled
|
|
}
|
|
|
|
async function clickSave() {
|
|
saveButton().click()
|
|
await flushPromises()
|
|
}
|
|
|
|
async function clickReset() {
|
|
resetButton().click()
|
|
await flushPromises()
|
|
}
|
|
|
|
function patchBodies(request: ReturnType<typeof vi.fn>) {
|
|
return request.mock.calls
|
|
.filter(([, init]) => (init as RequestInit | undefined)?.method === 'PATCH')
|
|
.map(([, init]) => JSON.parse((init as RequestInit).body as string))
|
|
}
|
|
|
|
function callsTo(request: ReturnType<typeof vi.fn>, path: string, method?: string) {
|
|
return request.mock.calls.filter(
|
|
([requestPath, init]) =>
|
|
requestPath === path &&
|
|
(method === undefined || (init as RequestInit)?.method === method),
|
|
)
|
|
}
|
|
|
|
function chip() {
|
|
return document.body.querySelector('button[aria-haspopup]') as HTMLButtonElement
|
|
}
|
|
|
|
function activate(element: HTMLElement) {
|
|
element.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
|
|
element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
}
|
|
|
|
function savedResponse(displayName: string) {
|
|
return Promise.resolve({ id: 1, username: 'marta', display_name: displayName })
|
|
}
|
|
|
|
const NETWORK_ERROR = () => Promise.reject(new NetworkError())
|
|
const SERVER_ERROR = () => Promise.reject(new ApiError(500))
|
|
|
|
beforeEach(() => {
|
|
toastAdd = vi.fn()
|
|
overlayMock = createOverlayMock()
|
|
localStorage.clear()
|
|
authRedirectPending.value = false
|
|
})
|
|
|
|
afterEach(async () => {
|
|
vi.useRealTimers()
|
|
mountedWrapper?.unmount()
|
|
mountedWrapper = null
|
|
document.body.innerHTML = ''
|
|
await router.push('/login')
|
|
})
|
|
|
|
describe('Route guards for /profile', () => {
|
|
it('renders ProfilePage for a resolved, verified session', async () => {
|
|
await mountApp()
|
|
|
|
expect(router.currentRoute.value.path).toBe('/profile')
|
|
expect(document.body.textContent).toContain('Your account on this RenewCMS workspace.')
|
|
})
|
|
|
|
it('redirects a resolved, unverified session to /register/pending', async () => {
|
|
await mountApp({ session: { ...MARTA, verified: false } })
|
|
await flushPromises()
|
|
|
|
expect(router.currentRoute.value.path).toBe('/register/pending')
|
|
})
|
|
|
|
it('redirects a 401 from /auth/me to /login?redirect=/profile&reason=expired', async () => {
|
|
await mountApp({
|
|
session: null,
|
|
status: 'idle',
|
|
getMe: () => Promise.reject(new ApiError(401)),
|
|
})
|
|
await flushPromises()
|
|
|
|
expect(router.currentRoute.value.fullPath).toBe('/login?redirect=/profile&reason=expired')
|
|
})
|
|
})
|
|
|
|
describe('Default: the usual first visit with no display name', () => {
|
|
it('shows the header, the empty field, the account details and the reset button', async () => {
|
|
const { request } = await mountApp()
|
|
const text = document.body.textContent ?? ''
|
|
|
|
expect(document.querySelector('nav[aria-label="breadcrumb" i]')?.textContent).toContain(
|
|
'Profile',
|
|
)
|
|
expect(document.querySelector('h1')?.textContent?.trim()).toBe('Profile')
|
|
expect(text).toContain('Your account on this RenewCMS workspace.')
|
|
expect(displayNameField().value).toBe('')
|
|
expect(displayNameField().getAttribute('placeholder')).toBe('e.g. Marta Alves')
|
|
expect(counter().textContent?.trim()).toBe('0 / 60')
|
|
expect(footer().textContent?.trim()).toBe('You appear as “marta”, your username.')
|
|
expect(isEnabled(saveButton())).toBe(false)
|
|
expect(text).toContain('Display name (optional)')
|
|
|
|
const accountInputs = Array.from(
|
|
document.body.querySelectorAll<HTMLInputElement>('input:disabled'),
|
|
)
|
|
expect(accountInputs.map((input) => input.value)).toEqual([
|
|
'marta',
|
|
'marta@renew.example',
|
|
'••••••••••••',
|
|
])
|
|
expect(text).toContain(
|
|
"To change it, we'll email a reset link to marta@renew.example. The link works once, within 1 hour.",
|
|
)
|
|
expect(resetButton().textContent?.trim()).toBe('Send reset link')
|
|
expect(byTestId('reset-note')).toBeNull()
|
|
expect(request).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('marks none of the five navigation items as the current page', async () => {
|
|
await mountApp()
|
|
|
|
const links = document.body.querySelectorAll('nav[aria-label="Main"] a')
|
|
expect(links).toHaveLength(5)
|
|
links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull())
|
|
})
|
|
|
|
it('places the three cards in order Display name, Account, Password', async () => {
|
|
await mountApp()
|
|
|
|
const titles = Array.from(document.body.querySelectorAll('section h2')).map((el) =>
|
|
el.textContent?.trim(),
|
|
)
|
|
expect(titles).toEqual(['Display name', 'Account', 'Password'])
|
|
})
|
|
})
|
|
|
|
describe('Loading: opened by URL before /auth/me has returned', () => {
|
|
it('shows the header and three skeleton cards without AppBoot, then the Default state', async () => {
|
|
const pending = deferred<Session>()
|
|
await mountApp({
|
|
session: null,
|
|
status: 'idle',
|
|
getMe: () => pending.promise,
|
|
})
|
|
|
|
expect(document.querySelector('nav[aria-label="breadcrumb" i]')?.textContent).toContain(
|
|
'Profile',
|
|
)
|
|
expect(document.body.textContent).toContain('Your account on this RenewCMS workspace.')
|
|
const busy = document.body.querySelector('[aria-busy="true"]')!
|
|
expect(busy.querySelectorAll('[data-testid="profile-skeleton-card"]')).toHaveLength(3)
|
|
expect(document.body.querySelector('[aria-label="Loading"]')).toBeNull()
|
|
expect(displayNameField()).toBeNull()
|
|
|
|
pending.resolve({ ...MARTA })
|
|
await flushPromises()
|
|
|
|
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
|
|
expect(displayNameField()).not.toBeNull()
|
|
expect(counter().textContent?.trim()).toBe('0 / 60')
|
|
})
|
|
})
|
|
|
|
describe('Opened from the menu, the cached user renders at once', () => {
|
|
it('navigates from /home through the account menu with no skeleton and no second /auth/me', async () => {
|
|
const { getMe } = await mountApp({ path: '/home' })
|
|
|
|
chip().click()
|
|
await flushPromises()
|
|
const profileItem = Array.from(
|
|
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
|
).find((item) => item.textContent?.trim() === 'Profile')!
|
|
activate(profileItem)
|
|
await flushPromises()
|
|
|
|
expect(router.currentRoute.value.path).toBe('/profile')
|
|
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
|
|
expect(displayNameField()).not.toBeNull()
|
|
expect(getMe).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('Editing: typing turns Save on', () => {
|
|
it('shows the count, Unsaved changes in the warning color and an enabled Save, sending nothing', async () => {
|
|
const { request } = await mountApp()
|
|
|
|
await typeInto('Marta Alves')
|
|
|
|
expect(counter().textContent?.trim()).toBe('11 / 60')
|
|
expect(footer().textContent?.trim()).toBe('Unsaved changes')
|
|
expect(footer().classList.contains('text-warning')).toBe(true)
|
|
expect(isEnabled(saveButton())).toBe(true)
|
|
expect(request).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('The counter counts Unicode characters after trimming', () => {
|
|
it.each([
|
|
['"Marta Alves"', 'Marta Alves', 11, 'text-muted', true],
|
|
['" Marta Alves "', ' Marta Alves ', 11, 'text-muted', true],
|
|
['60 repetitions of "a"', 'a'.repeat(60), 60, 'text-muted', true],
|
|
['61 repetitions of "a"', 'a'.repeat(61), 61, 'text-error', false],
|
|
['60 repetitions of an emoji', '😀'.repeat(60), 60, 'text-muted', true],
|
|
['61 repetitions of an emoji', '😀'.repeat(61), 61, 'text-error', false],
|
|
['"Cafe" followed by U+0301', 'Café', 5, 'text-muted', true],
|
|
['5 spaces, 60 "a", 5 spaces', ` ${'a'.repeat(60)} `, 60, 'text-muted', true],
|
|
])('%s counts %i, %s, Save enabled: %s', async (_label, value, count, colorClass, enabled) => {
|
|
await mountApp()
|
|
|
|
await typeInto(value)
|
|
|
|
expect(counter().textContent?.trim()).toBe(`${count} / 60`)
|
|
expect(counter().classList.contains(colorClass)).toBe(true)
|
|
expect(isEnabled(saveButton())).toBe(enabled)
|
|
})
|
|
})
|
|
|
|
describe('Too long: over 60 characters shows a field error and keeps Save off', () => {
|
|
it('shows the error, ignores Enter and recovers once shortened', async () => {
|
|
const { request } = await mountApp()
|
|
|
|
await typeInto('Marta Alves, Head of Renovations, Brussels and Wallonia office')
|
|
|
|
expect(counter().textContent?.trim()).toBe('62 / 60')
|
|
expect(counter().classList.contains('text-error')).toBe(true)
|
|
const error = byTestId('display-name-error')!
|
|
expect(error.textContent).toContain('Use 60 characters or fewer.')
|
|
expect(error.querySelector('svg, [class*="i-lucide-circle-alert"]')).not.toBeNull()
|
|
expect(displayNameField().getAttribute('aria-invalid')).toBe('true')
|
|
expect(footer().textContent?.trim()).toBe('Unsaved changes')
|
|
expect(footer().classList.contains('text-warning')).toBe(true)
|
|
expect(isEnabled(saveButton())).toBe(false)
|
|
|
|
pressEnter()
|
|
await flushPromises()
|
|
|
|
expect(patchBodies(request)).toHaveLength(0)
|
|
|
|
await typeInto('Marta Alves')
|
|
|
|
expect(byTestId('display-name-error')).toBeNull()
|
|
expect(counter().textContent?.trim()).toBe('11 / 60')
|
|
expect(isEnabled(saveButton())).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('Dirty is measured on the trimmed draft against the saved name', () => {
|
|
it.each([
|
|
['', ' ', false, 'You appear as “marta”, your username.'],
|
|
['Marta Alves', 'Marta Alves', false, 'You appear as “Marta Alves”.'],
|
|
['Marta Alves', 'Marta Alves ', false, 'You appear as “Marta Alves”.'],
|
|
['Marta Alves', '', true, 'Unsaved changes'],
|
|
['Marta Alves', 'Marta Alves', true, 'Unsaved changes'],
|
|
])(
|
|
'saved %j and typed %j gives Save enabled: %s and %s',
|
|
async (saved, typed, enabled, text) => {
|
|
await mountApp({ session: { ...MARTA, displayName: saved } })
|
|
|
|
await typeInto(typed)
|
|
|
|
expect(isEnabled(saveButton())).toBe(enabled)
|
|
expect(footer().textContent?.trim()).toBe(text)
|
|
},
|
|
)
|
|
})
|
|
|
|
describe('Enter saves when Save is enabled', () => {
|
|
it('sends PATCH /v1/user/me once with the trimmed display name', async () => {
|
|
const { request } = await mountApp({
|
|
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
|
|
})
|
|
await typeInto('Marta Alves')
|
|
|
|
pressEnter()
|
|
await flushPromises()
|
|
|
|
expect(patchBodies(request)).toEqual([{ display_name: 'Marta Alves' }])
|
|
})
|
|
|
|
it('does nothing when the draft is not dirty', async () => {
|
|
const { request } = await mountApp()
|
|
|
|
pressEnter()
|
|
await flushPromises()
|
|
|
|
expect(patchBodies(request)).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('Saving: the field is disabled and a second submit is ignored', () => {
|
|
it('sends the trimmed value, disables and dims the field, and ignores Enter and a second click', async () => {
|
|
const pending = deferred()
|
|
const { request } = await mountApp({
|
|
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => pending.promise },
|
|
})
|
|
await typeInto(' Marta Alves ')
|
|
|
|
await clickSave()
|
|
|
|
expect(patchBodies(request)).toEqual([{ display_name: 'Marta Alves' }])
|
|
expect(displayNameField().disabled).toBe(true)
|
|
expect(saveButton().textContent?.trim()).toBe('Saving…')
|
|
expect(saveButton().querySelector('[class*="i-lucide-loader-circle"], svg')).not.toBeNull()
|
|
expect(footer().textContent?.trim()).not.toBe('Unsaved changes')
|
|
|
|
pressEnter()
|
|
saveButton().click()
|
|
await flushPromises()
|
|
|
|
expect(patchBodies(request)).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
describe('Saved: a 200 updates the session, the chip, the footer and raises a toast', () => {
|
|
it('replaces the session name, syncs the field, and raises the success toast', async () => {
|
|
const { request } = await mountApp({
|
|
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
|
|
})
|
|
await typeInto('Marta Alves')
|
|
|
|
await clickSave()
|
|
|
|
expect(useSessionStore().session?.displayName).toBe('Marta Alves')
|
|
expect(chip().textContent).toContain('Marta Alves')
|
|
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
|
|
expect(displayNameField().value).toBe('Marta Alves')
|
|
expect(displayNameField().disabled).toBe(false)
|
|
expect(counter().textContent?.trim()).toBe('11 / 60')
|
|
expect(footer().textContent?.trim()).toBe('You appear as “Marta Alves”.')
|
|
expect(isEnabled(saveButton())).toBe(false)
|
|
expect(toastAdd).toHaveBeenCalledWith({
|
|
id: 'display-name-saved',
|
|
color: 'success',
|
|
icon: 'i-lucide-circle-check',
|
|
title: 'Display name saved',
|
|
description: 'You now appear as Marta Alves across RenewCMS and in emails.',
|
|
})
|
|
expect(request).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('opens no leave confirmation when navigating away afterwards', async () => {
|
|
await mountApp({
|
|
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
|
|
})
|
|
await typeInto('Marta Alves')
|
|
await clickSave()
|
|
|
|
await router.push('/users')
|
|
await flushPromises()
|
|
|
|
expect(overlayMock.create).not.toHaveBeenCalled()
|
|
expect(router.currentRoute.value.path).toBe('/users')
|
|
})
|
|
})
|
|
|
|
describe("The server's value wins over what was typed", () => {
|
|
it('uses the response display_name in the field, the chip and the toast', async () => {
|
|
await mountApp({
|
|
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
|
|
})
|
|
await typeInto(' Marta Alves ')
|
|
|
|
await clickSave()
|
|
|
|
expect(displayNameField().value).toBe('Marta Alves')
|
|
expect(chip().textContent).toContain('Marta Alves')
|
|
expect(toastAdd.mock.calls[0]![0].description).toContain('Marta Alves across RenewCMS')
|
|
})
|
|
})
|
|
|
|
describe('Clearing the display name goes back to the username', () => {
|
|
it('sends an empty display_name and shows the username with the removed toast', async () => {
|
|
const { request } = await mountApp({
|
|
session: { ...MARTA, displayName: 'Marta Alves' },
|
|
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('') },
|
|
})
|
|
await typeInto('')
|
|
|
|
await clickSave()
|
|
|
|
expect(patchBodies(request)).toEqual([{ display_name: '' }])
|
|
expect(chip().textContent).toContain('marta')
|
|
expect(chip().textContent).not.toContain('Marta Alves')
|
|
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
|
|
expect(footer().textContent?.trim()).toBe('You appear as “marta”, your username.')
|
|
expect(toastAdd).toHaveBeenCalledWith({
|
|
id: 'display-name-saved',
|
|
color: 'success',
|
|
icon: 'i-lucide-circle-check',
|
|
title: 'Display name removed',
|
|
description: 'You now appear as marta, your username.',
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Save failed: a 500 or a network failure keeps the text and offers a retry', () => {
|
|
it.each([
|
|
['a 500 response', SERVER_ERROR],
|
|
['a network failure', NETWORK_ERROR],
|
|
])(
|
|
'after %s shows the alert, keeps the value and retries with the same body',
|
|
async (_label, failure) => {
|
|
const attempts = [failure, () => savedResponse('Marta Alves')]
|
|
const { request } = await mountApp({
|
|
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => attempts.shift()!() },
|
|
})
|
|
await typeInto('Marta Alves')
|
|
|
|
await clickSave()
|
|
|
|
const alert = document.body.querySelector('[role="alert"]')!
|
|
expect(alert.textContent).toContain(SAVE_FAILED_COPY)
|
|
expect(displayNameField().value).toBe('Marta Alves')
|
|
expect(displayNameField().disabled).toBe(false)
|
|
expect(footer().textContent?.trim()).toBe('Unsaved changes')
|
|
expect(isEnabled(saveButton())).toBe(true)
|
|
expect(useSessionStore().session?.displayName).toBe('')
|
|
expect(toastAdd).not.toHaveBeenCalled()
|
|
|
|
await clickSave()
|
|
|
|
expect(patchBodies(request)).toEqual([
|
|
{ display_name: 'Marta Alves' },
|
|
{ display_name: 'Marta Alves' },
|
|
])
|
|
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
|
},
|
|
)
|
|
|
|
it('hides the alert while the retry is in flight', async () => {
|
|
const retry = deferred()
|
|
const attempts = [SERVER_ERROR, () => retry.promise]
|
|
await mountApp({
|
|
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => attempts.shift()!() },
|
|
})
|
|
await typeInto('Marta Alves')
|
|
await clickSave()
|
|
expect(document.body.querySelector('[role="alert"]')).not.toBeNull()
|
|
|
|
await clickSave()
|
|
|
|
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
|
expect(saveButton().textContent?.trim()).toBe('Saving…')
|
|
})
|
|
})
|
|
|
|
describe('A 400 from the API', () => {
|
|
it('shows the field error and disables Save for the display-name message', async () => {
|
|
await mountApp({
|
|
handlers: {
|
|
[`PATCH ${DISPLAY_NAME_PATH}`]: () =>
|
|
Promise.reject(new ApiError(400, undefined, LIMIT_BODY)),
|
|
},
|
|
})
|
|
await typeInto('Marta Alves')
|
|
|
|
await clickSave()
|
|
|
|
expect(byTestId('display-name-error')?.textContent).toContain('Use 60 characters or fewer.')
|
|
expect(isEnabled(saveButton())).toBe(false)
|
|
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
|
})
|
|
|
|
it('shows the Save failed alert and keeps the text for any other 400', async () => {
|
|
await mountApp({
|
|
handlers: {
|
|
[`PATCH ${DISPLAY_NAME_PATH}`]: () =>
|
|
Promise.reject(new ApiError(400, undefined, 'invalid request body')),
|
|
},
|
|
})
|
|
await typeInto('Marta Alves')
|
|
|
|
await clickSave()
|
|
|
|
expect(document.body.querySelector('[role="alert"]')?.textContent).toContain(
|
|
SAVE_FAILED_COPY,
|
|
)
|
|
expect(displayNameField().value).toBe('Marta Alves')
|
|
expect(byTestId('display-name-error')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('Leaving with unsaved changes asks for confirmation', () => {
|
|
it('opens a neutral ConfirmDialog, stays on Keep editing and leaves on Discard changes', async () => {
|
|
await mountApp()
|
|
await typeInto('Marta Alves')
|
|
|
|
const first = router.push('/users')
|
|
await flushPromises()
|
|
|
|
const props = overlayMock.getProps()!
|
|
expect(props.title).toBe('Leave without saving?')
|
|
expect(props.description).toBe("Your display name change hasn't been saved.")
|
|
expect(props.confirmLabel).toBe('Keep editing')
|
|
expect(props.cancelLabel).toBe('Discard changes')
|
|
expect(props.tone).toBe('neutral')
|
|
|
|
overlayMock.resolveOpen(true)
|
|
await first
|
|
|
|
expect(router.currentRoute.value.path).toBe('/profile')
|
|
expect(displayNameField().value).toBe('Marta Alves')
|
|
|
|
const second = router.push('/users')
|
|
await flushPromises()
|
|
overlayMock.resolveOpen(false)
|
|
await second
|
|
|
|
expect(router.currentRoute.value.path).toBe('/users')
|
|
expect(useSessionStore().session?.displayName).toBe('')
|
|
})
|
|
|
|
it('opens nothing when nothing is unsaved', async () => {
|
|
await mountApp()
|
|
|
|
await router.push('/users')
|
|
await flushPromises()
|
|
|
|
expect(overlayMock.create).not.toHaveBeenCalled()
|
|
expect(router.currentRoute.value.path).toBe('/users')
|
|
})
|
|
|
|
it('opens the dialog when Log out is used with a dirty draft', async () => {
|
|
const { request } = await mountApp()
|
|
await typeInto('Marta Alves')
|
|
|
|
chip().click()
|
|
await flushPromises()
|
|
const logOut = Array.from(
|
|
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
|
).find((item) => item.textContent?.trim() === 'Log out')!
|
|
activate(logOut)
|
|
await flushPromises()
|
|
|
|
expect(callsTo(request, LOGOUT_PATH, 'POST')).toHaveLength(1)
|
|
expect(overlayMock.create).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('redirects without the dialog when a 401 while saving is handled globally', async () => {
|
|
await mountApp({
|
|
handlers: {
|
|
[`PATCH ${DISPLAY_NAME_PATH}`]: () => {
|
|
authRedirectPending.value = true
|
|
router.push({
|
|
path: '/login',
|
|
query: { redirect: '/profile', reason: 'expired' },
|
|
})
|
|
return Promise.reject(new ApiError(401))
|
|
},
|
|
},
|
|
})
|
|
await typeInto('Marta Alves')
|
|
|
|
await clickSave()
|
|
await flushPromises()
|
|
|
|
expect(overlayMock.create).not.toHaveBeenCalled()
|
|
expect(router.currentRoute.value.fullPath).toBe('/login?redirect=/profile&reason=expired')
|
|
expect(document.body.textContent).not.toContain(SAVE_FAILED_COPY)
|
|
})
|
|
})
|
|
|
|
describe('The Home greeting follows a display name saved on /profile', () => {
|
|
it('shows the saved name on /home without a reload', async () => {
|
|
vi.spyOn(Date.prototype, 'getHours').mockReturnValue(9)
|
|
await mountApp({
|
|
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
|
|
})
|
|
await typeInto('Marta Alves')
|
|
await clickSave()
|
|
|
|
await router.push('/home')
|
|
await flushPromises()
|
|
|
|
expect(document.body.querySelector('[data-testid="greeting"]')?.textContent?.trim()).toBe(
|
|
'Good morning, Marta Alves',
|
|
)
|
|
expect(chip().textContent).toContain('Marta Alves')
|
|
vi.restoreAllMocks()
|
|
})
|
|
})
|
|
|
|
describe('Reset: sending, sent and Send again', () => {
|
|
it('posts the session email with no confirmation and shows Sending… with no note', async () => {
|
|
const pending = deferred()
|
|
const { request } = await mountApp({
|
|
session: { ...MARTA, displayName: 'Marta Alves' },
|
|
handlers: { [`POST ${RESET_PATH}`]: () => pending.promise },
|
|
})
|
|
|
|
await clickReset()
|
|
|
|
const [call] = callsTo(request, RESET_PATH, 'POST')
|
|
expect(JSON.parse((call![1] as RequestInit).body as string)).toEqual({
|
|
email: 'marta@renew.example',
|
|
})
|
|
expect(resetButton().textContent?.trim()).toBe('Sending…')
|
|
expect(resetButton().disabled).toBe(true)
|
|
expect(byTestId('reset-note')).toBeNull()
|
|
expect(chip().textContent).toContain('Marta Alves')
|
|
})
|
|
|
|
it('shows the sent note, sends again with the note hidden meanwhile, and resets on remount', async () => {
|
|
const second = deferred()
|
|
const responses = [() => Promise.resolve({ message: 'ok' }), () => second.promise]
|
|
const { request } = await mountApp({
|
|
handlers: { [`POST ${RESET_PATH}`]: () => responses.shift()!() },
|
|
})
|
|
|
|
await clickReset()
|
|
|
|
expect(resetButton().textContent?.trim()).toBe('Send again')
|
|
expect(resetButton().disabled).toBe(false)
|
|
const note = byTestId('reset-note')!
|
|
expect(note.getAttribute('role')).toBe('status')
|
|
expect(note.textContent).toContain('Reset link sent')
|
|
expect(note.textContent).toContain(
|
|
'Check marta@renew.example. The link works once, within 1 hour.',
|
|
)
|
|
|
|
await clickReset()
|
|
|
|
expect(callsTo(request, RESET_PATH, 'POST')).toHaveLength(2)
|
|
expect(byTestId('reset-note')).toBeNull()
|
|
|
|
second.resolve({ message: 'ok' })
|
|
await flushPromises()
|
|
|
|
expect(byTestId('reset-note')?.textContent).toContain('Reset link sent')
|
|
|
|
await router.push('/home')
|
|
await flushPromises()
|
|
await router.push('/profile')
|
|
await flushPromises()
|
|
|
|
expect(resetButton().textContent?.trim()).toBe('Send reset link')
|
|
expect(byTestId('reset-note')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('Reset: too many requests disables the button for the Retry-After window', () => {
|
|
it.each([
|
|
['a Retry-After of 42', 42, 42],
|
|
['a Retry-After of 300', 300, 300],
|
|
['no Retry-After header', undefined, 300],
|
|
['a Retry-After of 0', 0, 300],
|
|
])(
|
|
'with %s the button is enabled again after %i seconds',
|
|
async (_label, retryAfter, seconds) => {
|
|
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
|
|
await mountApp({
|
|
handlers: {
|
|
[`POST ${RESET_PATH}`]: () => Promise.reject(new ApiError(429, retryAfter)),
|
|
},
|
|
})
|
|
|
|
await clickReset()
|
|
|
|
const note = byTestId('reset-note')!
|
|
expect(note.getAttribute('role')).toBe('status')
|
|
expect(note.textContent).toContain('Too many requests')
|
|
expect(note.textContent).toContain(
|
|
"You've asked for several links recently. Wait a few minutes, then try again.",
|
|
)
|
|
expect(resetButton().textContent?.trim()).toBe('Send reset link')
|
|
expect(resetButton().disabled).toBe(true)
|
|
|
|
vi.advanceTimersByTime((seconds - 1) * 1000)
|
|
await flushPromises()
|
|
|
|
expect(resetButton().disabled).toBe(true)
|
|
|
|
vi.advanceTimersByTime(1000)
|
|
await flushPromises()
|
|
|
|
expect(resetButton().disabled).toBe(false)
|
|
expect(byTestId('reset-note')).toBeNull()
|
|
},
|
|
)
|
|
})
|
|
|
|
describe('Reset: failed leaves the button pressable', () => {
|
|
it.each([
|
|
['a 500 response', SERVER_ERROR],
|
|
['a network failure', NETWORK_ERROR],
|
|
])(
|
|
'after %s shows the error note and recovers on a successful retry',
|
|
async (_label, failure) => {
|
|
const responses = [failure, () => Promise.resolve({ message: 'ok' })]
|
|
await mountApp({
|
|
handlers: { [`POST ${RESET_PATH}`]: () => responses.shift()!() },
|
|
})
|
|
|
|
await clickReset()
|
|
|
|
const note = byTestId('reset-note')!
|
|
expect(note.getAttribute('role')).toBe('status')
|
|
expect(note.textContent).toContain("Couldn't send the link")
|
|
expect(note.textContent).toContain('Check your connection and try again.')
|
|
expect(resetButton().textContent?.trim()).toBe('Send reset link')
|
|
expect(resetButton().disabled).toBe(false)
|
|
|
|
await clickReset()
|
|
|
|
expect(byTestId('reset-note')?.textContent).toContain('Reset link sent')
|
|
expect(resetButton().textContent?.trim()).toBe('Send again')
|
|
},
|
|
)
|
|
})
|