-
+
-
+
+
diff --git a/web/src/components/ProfileCard.vue b/web/src/components/ProfileCard.vue
new file mode 100644
index 0000000..220361e
--- /dev/null
+++ b/web/src/components/ProfileCard.vue
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
{{ title }}
+
{{ description }}
+
+
+
+
+
+
+
+
diff --git a/web/src/components/UserMenu.vue b/web/src/components/UserMenu.vue
index 4063785..72ce86c 100644
--- a/web/src/components/UserMenu.vue
+++ b/web/src/components/UserMenu.vue
@@ -1,43 +1,65 @@
-
+
@@ -78,12 +105,18 @@ const items = computed(() => [
size="sm"
:ui="{
root: 'size-6.5 bg-secondary-300',
- fallback:
- 'text-[0.6875rem] font-semibold text-primary-900',
+ fallback: 'text-[0.6875rem] font-semibold text-primary-900',
}"
/>
- {{ username }}
+ {{ name }}
+
+
+
+ {{ name }}
+ {{ email }}
+
+
diff --git a/web/src/components/__tests__/CharacterCounter.spec.ts b/web/src/components/__tests__/CharacterCounter.spec.ts
index 9f3822b..e5c1e10 100644
--- a/web/src/components/__tests__/CharacterCounter.spec.ts
+++ b/web/src/components/__tests__/CharacterCounter.spec.ts
@@ -15,16 +15,8 @@ describe('Below the warning threshold', () => {
const wrapper = mountCounter(4318)
expect(wrapper.text()).toContain('4 318 / 10 000')
- expect(
- wrapper
- .find('[data-testid="character-counter-warning-icon"]')
- .exists(),
- ).toBe(false)
- expect(
- wrapper
- .find('[data-testid="character-counter-over-icon"]')
- .exists(),
- ).toBe(false)
+ expect(wrapper.find('[data-testid="character-counter-warning-icon"]').exists()).toBe(false)
+ expect(wrapper.find('[data-testid="character-counter-over-icon"]').exists()).toBe(false)
})
})
@@ -33,11 +25,7 @@ describe('Near the limit — 90% or more', () => {
const wrapper = mountCounter(9240)
expect(wrapper.text()).toContain('9 240 / 10 000')
- expect(
- wrapper
- .find('[data-testid="character-counter-warning-icon"]')
- .exists(),
- ).toBe(true)
+ expect(wrapper.find('[data-testid="character-counter-warning-icon"]').exists()).toBe(true)
expect(wrapper.text()).not.toContain('characters over')
})
})
@@ -47,13 +35,31 @@ describe('Too long — over the max', () => {
const wrapper = mountCounter(10214)
expect(wrapper.text()).toContain('10 214 / 10 000')
- expect(
- wrapper
- .find('[data-testid="character-counter-over-icon"]')
- .exists(),
- ).toBe(true)
+ expect(wrapper.find('[data-testid="character-counter-over-icon"]').exists()).toBe(true)
expect(wrapper.text()).toContain(
'214 characters over. Shorten the text or remove an image. Formatting counts too.',
)
})
})
+
+describe('The field variant is a bare mono count', () => {
+ function mountField(value: number) {
+ return mount(CharacterCounter, {
+ props: { value, max: 60, variant: 'field' },
+ global: { plugins: [uiPlugin] },
+ })
+ }
+
+ it.each([
+ [0, 'text-muted'],
+ [60, 'text-muted'],
+ [61, 'text-error'],
+ ])('shows %i / 60 with the %s color and no icon or message', (value, colorClass) => {
+ const wrapper = mountField(value)
+
+ expect(wrapper.text()).toBe(`${value} / 60`)
+ expect(wrapper.classes()).toContain(colorClass)
+ expect(wrapper.classes()).toContain('font-mono')
+ expect(wrapper.find('svg').exists()).toBe(false)
+ })
+})
diff --git a/web/src/components/__tests__/DashboardShell.spec.ts b/web/src/components/__tests__/DashboardShell.spec.ts
index 6407753..d83c95a 100644
--- a/web/src/components/__tests__/DashboardShell.spec.ts
+++ b/web/src/components/__tests__/DashboardShell.spec.ts
@@ -1,10 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { mount, flushPromises } from '@vue/test-utils'
+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 { useApi } from '@/composables/useApi'
import DashboardShell from '../DashboardShell.vue'
+import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from './viewport'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal
()
@@ -14,10 +15,27 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
}
})
-async function mountShell(path: string, username = 'marta') {
+interface ShellOptions {
+ username?: string
+ displayName?: string
+ email?: string
+ width?: number
+}
+
+let mountedShell: VueWrapper | null = null
+
+async function mountShell(path: string, options: ShellOptions = {}) {
+ const {
+ username = 'marta',
+ displayName = '',
+ email = 'marta@renew.example',
+ width = DESKTOP_WIDTH,
+ } = options
+ localStorage.clear()
+ setViewportWidth(width)
setActivePinia(createPinia())
vi.mocked(useApi).mockReturnValue({
- getMe: vi.fn().mockResolvedValue({ verified: true, username }),
+ getMe: vi.fn().mockResolvedValue({ verified: true, username, email, displayName }),
request: vi.fn().mockResolvedValue(undefined),
})
await router.push(path)
@@ -27,11 +45,14 @@ async function mountShell(path: string, username = 'marta') {
plugins: [router, uiPlugin],
},
})
+ mountedShell = wrapper
await flushPromises()
return wrapper
}
afterEach(async () => {
+ mountedShell?.unmount()
+ mountedShell = null
await router.push('/login')
document.body.innerHTML = ''
})
@@ -166,3 +187,112 @@ describe('Collapsed sidebar hides the wordmark and username text but keeps an ac
expect(footer.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
})
})
+
+describe('The account chip shows the display name and no item is current on /profile', () => {
+ it('shows the display name and its initials in the sidebar chip', async () => {
+ await mountShell('/home', { displayName: 'Marta Alves' })
+
+ const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement
+ expect(footer.textContent).toContain('Marta Alves')
+ expect(footer.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
+ })
+
+ it('keeps the collapsed accessible name on the display name', async () => {
+ await mountShell('/home', { displayName: 'Marta Alves' })
+
+ const collapseButton = document.body.querySelector(
+ '[data-testid="sidebar-collapse"]',
+ ) as HTMLButtonElement
+ collapseButton.click()
+ await flushPromises()
+
+ const trigger = document.body.querySelector('[data-slot="footer"] button[aria-haspopup]')
+ expect(trigger?.getAttribute('aria-label')).toBe('Marta Alves — account menu')
+ })
+
+ it('marks none of the four navigation items current on /profile', async () => {
+ const wrapper = await mountShell('/profile')
+
+ const links = wrapper.findAll('nav[aria-label="Main"] a')
+ expect(links).toHaveLength(4)
+ links.forEach((link) => expect(link.attributes('aria-current')).toBeUndefined())
+ })
+})
+
+describe('Account menu: opened from the drawer on mobile', () => {
+ it('closes the drawer and opens a bottom sheet with a grab handle, the header and 48 px items', async () => {
+ await mountShell('/home', { width: MOBILE_WIDTH })
+
+ const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
+ toggle.click()
+ await flushPromises()
+
+ const accountRow = document.body.querySelector(
+ '[role="dialog"] button[aria-haspopup]',
+ ) as HTMLButtonElement
+ expect(accountRow).not.toBeNull()
+ accountRow.click()
+ await flushPromises()
+
+ const sheets = Array.from(document.body.querySelectorAll('[role="dialog"]'))
+ expect(sheets).toHaveLength(1)
+ const menu = document.body.querySelector('[role="menu"]') as HTMLElement
+ expect(
+ menu.querySelector('[aria-hidden="true"].w-9.h-1, [aria-hidden="true"].h-1'),
+ ).not.toBeNull()
+ expect(menu.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
+ expect(menu.textContent).toContain('marta@renew.example')
+ const labels = Array.from(
+ menu.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]'),
+ ).map((item) => item.textContent?.trim())
+ expect(labels).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out'])
+ menu.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]').forEach((item) =>
+ expect(item.className).toContain('h-12'),
+ )
+ })
+
+ it('navigates to /profile and closes the sheet from Profile', async () => {
+ await mountShell('/home', { width: MOBILE_WIDTH })
+ ;(document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement).click()
+ await flushPromises()
+ ;(
+ document.body.querySelector(
+ '[role="dialog"] button[aria-haspopup]',
+ ) as HTMLButtonElement
+ ).click()
+ await flushPromises()
+
+ const profile = Array.from(
+ document.body.querySelectorAll('[role="menu"] [role="menuitem"]'),
+ ).find((item) => item.textContent?.trim() === 'Profile')!
+ profile.click()
+ await flushPromises()
+
+ expect(router.currentRoute.value.path).toBe('/profile')
+ expect(document.body.querySelector('[role="menu"]')).toBeNull()
+ })
+
+ it('logs out from the sheet', async () => {
+ await mountShell('/home', { width: MOBILE_WIDTH })
+ ;(document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement).click()
+ await flushPromises()
+ ;(
+ document.body.querySelector(
+ '[role="dialog"] button[aria-haspopup]',
+ ) as HTMLButtonElement
+ ).click()
+ await flushPromises()
+
+ const logOut = Array.from(
+ document.body.querySelectorAll('[role="menu"] [role="menuitem"]'),
+ ).find((item) => item.textContent?.trim() === 'Log out')!
+ logOut.click()
+ await flushPromises()
+
+ expect(vi.mocked(useApi)().request).toHaveBeenCalledWith(
+ '/v1/auth/logout',
+ expect.objectContaining({ method: 'POST' }),
+ )
+ expect(router.currentRoute.value.path).toBe('/login')
+ })
+})
diff --git a/web/src/components/__tests__/UserMenu.spec.ts b/web/src/components/__tests__/UserMenu.spec.ts
index dd556e6..10b3e1b 100644
--- a/web/src/components/__tests__/UserMenu.spec.ts
+++ b/web/src/components/__tests__/UserMenu.spec.ts
@@ -1,175 +1,304 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
+import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
+import { useSessionStore } from '@/stores/session'
+import router from '@/router'
import UserMenu from '../UserMenu.vue'
import { useColorMode } from '@/composables/useColorMode'
+import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from './viewport'
-function mountMenu(props: { username: string; collapsed: boolean }) {
+interface MenuProps {
+ username: string
+ displayName?: string
+ email?: string
+ collapsed: boolean
+}
+
+const baseProps: MenuProps = {
+ username: 'marta',
+ displayName: '',
+ email: 'marta@renew.example',
+ collapsed: false,
+}
+
+async function mountMenu(props: Partial = {}, path = '/home') {
+ setActivePinia(createPinia())
+ const session = useSessionStore()
+ session.session = { verified: true, username: 'marta' }
+ session.status = 'ready'
+ await router.push(path)
return mount(UserMenu, {
attachTo: document.body,
- props,
- global: {
- plugins: [uiPlugin],
- },
+ props: { ...baseProps, ...props },
+ global: { plugins: [router, uiPlugin] },
})
}
-function findTriggerButton() {
- return document.body.querySelector('button')
+function chip() {
+ return document.body.querySelector('button[aria-haspopup]') as HTMLButtonElement
}
async function openMenu() {
- findTriggerButton()?.click()
+ chip().click()
await flushPromises()
}
-function findMenuItemsByRole(role: string) {
- return Array.from(document.body.querySelectorAll(`[role="${role}"]`))
+function menu() {
+ return document.body.querySelector('[role="menu"]') as HTMLElement | null
}
-function findCheckboxItem(label: string) {
- return findMenuItemsByRole('menuitemcheckbox').find(
- (el) => el.textContent?.trim() === label,
- )
+function itemsIn(container: ParentNode) {
+ return Array.from(
+ container.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]'),
+ ) as HTMLElement[]
+}
+
+function itemLabels() {
+ return itemsIn(menu()!).map((el) => el.textContent?.trim())
+}
+
+function findItem(label: string) {
+ return itemsIn(menu()!).find((el) => el.textContent?.trim() === label) as HTMLElement
+}
+
+function activate(element: HTMLElement) {
+ element.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
+ element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
}
beforeEach(() => {
localStorage.clear()
document.documentElement.classList.remove('dark', 'light')
+ setViewportWidth(DESKTOP_WIDTH)
})
-afterEach(() => {
+afterEach(async () => {
document.body.innerHTML = ''
+ await router.push('/login')
})
-describe("UserMenu's dropdown lists the account label, color-mode checkboxes, then Log out", () => {
- it('shows avatar initials MA, the username, and the three ordered groups', async () => {
- const wrapper = mountMenu({ username: 'marta', collapsed: false })
+describe('The chip opens the account menu instead of logging out', () => {
+ it('exposes aria-haspopup, aria-expanded, the title and the name, and opens on activation', async () => {
+ const wrapper = await mountMenu()
- const avatar = document.body.querySelector('[data-slot="fallback"]')
- expect(avatar?.textContent).toBe('MA')
- expect(avatar?.getAttribute('aria-hidden')).toBe('true')
- expect(wrapper.text()).toContain('marta')
+ expect(chip().getAttribute('aria-haspopup')).toBe('menu')
+ expect(chip().getAttribute('aria-expanded')).toBe('false')
+ expect(chip().getAttribute('title')).toBe('Account')
+ expect(chip().textContent).toContain('marta')
+ expect(wrapper.emitted('logout')).toBeUndefined()
await openMenu()
- const groups = findMenuItemsByRole('group')
- expect(groups.length).toBe(3)
-
- expect(groups[0].textContent).toContain('marta')
-
- const checkboxLabels = findMenuItemsByRole('menuitemcheckbox').map(
- (el) => el.textContent?.trim(),
- )
- expect(checkboxLabels).toEqual(['System', 'Light', 'Dark'])
-
- expect(groups[2].textContent?.trim()).toBe('Log out')
-
- wrapper.unmount()
- })
-
- it('derives initials from the first letter of the first two words for a multi-word username', () => {
- const wrapper = mountMenu({ username: 'jane doe', collapsed: false })
-
- const avatar = document.body.querySelector('[data-slot="fallback"]')
- expect(avatar?.textContent).toBe('JD')
-
- wrapper.unmount()
- })
-
- it('checks exactly the item matching the current color-mode preference', async () => {
- const colorMode = useColorMode()
- colorMode.preference = 'dark'
-
- const wrapper = mountMenu({ username: 'marta', collapsed: false })
- await openMenu()
-
- expect(findCheckboxItem('Dark')?.getAttribute('aria-checked')).toBe(
- 'true',
- )
- expect(findCheckboxItem('Light')?.getAttribute('aria-checked')).toBe(
- 'false',
- )
- expect(findCheckboxItem('System')?.getAttribute('aria-checked')).toBe(
- 'false',
- )
-
- wrapper.unmount()
+ expect(chip().getAttribute('aria-expanded')).toBe('true')
+ expect(menu()).not.toBeNull()
+ expect(wrapper.emitted('logout')).toBeUndefined()
})
})
-describe('Selecting a color-mode item updates the preference and its checked state', () => {
- it.each([
- ['system', 'light'],
- ['light', 'dark'],
- ['dark', 'system'],
- ])('moves from %s to %s', async (from, to) => {
- const colorMode = useColorMode()
- colorMode.preference = from as 'system' | 'light' | 'dark'
-
- const wrapper = mountMenu({ username: 'marta', collapsed: false })
+describe('Account menu: opened on desktop', () => {
+ it('lists the header, Profile, System, Light, Dark and Log out with a divider between its three groups', async () => {
+ await mountMenu()
await openMenu()
- const label = to.charAt(0).toUpperCase() + to.slice(1)
- const fromLabel = from.charAt(0).toUpperCase() + from.slice(1)
-
- findCheckboxItem(label)?.dispatchEvent(
- new MouseEvent('pointerup', { bubbles: true }),
- )
- findCheckboxItem(label)?.dispatchEvent(
- new MouseEvent('click', { bubbles: true }),
- )
- await flushPromises()
-
- expect(colorMode.preference).toBe(to)
-
- await openMenu()
- expect(findCheckboxItem(label)?.getAttribute('aria-checked')).toBe(
- 'true',
- )
- expect(findCheckboxItem(fromLabel)?.getAttribute('aria-checked')).toBe(
- 'false',
- )
-
- wrapper.unmount()
- })
-})
-
-describe('A successful logout clears the session and navigates to /login', () => {
- it('emits logout when the user activates Log out', async () => {
- const wrapper = mountMenu({ username: 'marta', collapsed: false })
- await openMenu()
-
- const logOutItem = Array.from(
- document.body.querySelectorAll('[role="menuitem"]'),
- ).find((el) => el.textContent?.trim() === 'Log out')
- ;(logOutItem as HTMLElement)?.click()
- await flushPromises()
-
- expect(wrapper.emitted('logout')).toBeTruthy()
-
- wrapper.unmount()
- })
-})
-
-describe('Collapsed sidebar hides the wordmark and username text but keeps an accessible name', () => {
- it('hides the visible username but keeps it in the accessible name, and opens the same dropdown', async () => {
- const wrapper = mountMenu({ username: 'marta', collapsed: true })
-
- expect(wrapper.text()).not.toContain('marta')
- expect(findTriggerButton()?.getAttribute('aria-label')).toBe(
- 'marta — account menu',
- )
+ const header = menu()!.querySelector('[role="group"]')!
+ expect(header.textContent).toContain('marta')
+ expect(header.textContent).toContain('marta@renew.example')
+ expect(itemLabels()).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out'])
expect(
- document.body.querySelector('[data-slot="fallback"]')?.textContent,
- ).toBe('MA')
+ menu()!.querySelector('[data-slot="viewport"]')!.classList.contains('divide-y'),
+ ).toBe(true)
+ expect(menu()!.querySelectorAll('[role="group"]')).toHaveLength(3)
+ })
+
+ it('shows the display name in the header and the chip when there is one', async () => {
+ await mountMenu({ displayName: 'Marta Alves' })
+ await openMenu()
+
+ expect(chip().textContent).toContain('Marta Alves')
+ expect(menu()!.textContent).toContain('Marta Alves')
+ expect(menu()!.textContent).toContain('marta@renew.example')
+ })
+
+ it('does not highlight Profile away from /profile', async () => {
+ await mountMenu()
+ await openMenu()
+
+ expect(findItem('Profile').classList.contains('bg-elevated')).toBe(false)
+ })
+
+ it('gives the chip the sand fill only while the menu is open', async () => {
+ await mountMenu()
+
+ expect(chip().className).not.toContain('bg-secondary-200')
await openMenu()
- const groups = findMenuItemsByRole('group')
- expect(groups.length).toBe(3)
- expect(groups[0].textContent).toContain('marta')
+ expect(chip().className).toContain('bg-secondary-200')
+ expect(chip().className).toContain('text-primary-900')
+ })
+})
- wrapper.unmount()
+describe('The chip is sand while on /profile even with the menu closed', () => {
+ it('keeps the sand fill on /profile and drops it elsewhere', async () => {
+ await mountMenu({}, '/profile')
+
+ expect(chip().className).toContain('bg-secondary-200')
+ expect(chip().getAttribute('aria-expanded')).toBe('false')
+
+ await router.push('/home')
+ await flushPromises()
+
+ expect(chip().className).not.toContain('bg-secondary-200')
+ })
+})
+
+describe('Closing the menu', () => {
+ it('closes on Escape and returns focus to the chip', async () => {
+ await mountMenu()
+ await openMenu()
+
+ menu()!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
+ await flushPromises()
+
+ expect(menu()).toBeNull()
+ expect(chip().getAttribute('aria-expanded')).toBe('false')
+ expect(document.activeElement).toBe(chip())
+ })
+
+ it('closes on a click outside', async () => {
+ await mountMenu()
+ await openMenu()
+
+ document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
+ document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ await flushPromises()
+
+ expect(menu()).toBeNull()
+ expect(chip().getAttribute('aria-expanded')).toBe('false')
+ })
+
+ it('moves between items with the arrow keys', async () => {
+ await mountMenu()
+ await openMenu()
+
+ const first = itemsIn(menu()!)[0]!
+ first.focus()
+ first.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
+ await flushPromises()
+
+ expect(document.activeElement).toBe(itemsIn(menu()!)[1])
+
+ document.activeElement?.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }),
+ )
+ await flushPromises()
+
+ expect(document.activeElement).toBe(itemsIn(menu()!)[0])
+ })
+})
+
+describe('Menu items', () => {
+ it('navigates to /profile and closes the menu from Profile', async () => {
+ await mountMenu()
+ await openMenu()
+
+ activate(findItem('Profile'))
+ await flushPromises()
+
+ expect(router.currentRoute.value.path).toBe('/profile')
+ expect(menu()).toBeNull()
+ })
+
+ it('highlights Profile on /profile and only closes when activated there', async () => {
+ await mountMenu({}, '/profile')
+ await openMenu()
+
+ expect(findItem('Profile').classList.contains('bg-elevated')).toBe(true)
+
+ activate(findItem('Profile'))
+ await flushPromises()
+
+ expect(router.currentRoute.value.path).toBe('/profile')
+ expect(menu()).toBeNull()
+ })
+
+ it('emits logout from Log out', async () => {
+ const wrapper = await mountMenu()
+ await openMenu()
+
+ activate(findItem('Log out'))
+ await flushPromises()
+
+ expect(wrapper.emitted('logout')).toHaveLength(1)
+ })
+})
+
+describe('Theme choice stays in the menu', () => {
+ it('checks the current preference, sets Dark, closes, and shows Dark checked on reopening', async () => {
+ const colorMode = useColorMode()
+ colorMode.preference = 'system'
+ await mountMenu()
+ await openMenu()
+
+ expect(findItem('System').getAttribute('aria-checked')).toBe('true')
+
+ activate(findItem('Dark'))
+ await flushPromises()
+
+ expect(colorMode.preference).toBe('dark')
+ expect(menu()).toBeNull()
+
+ await openMenu()
+
+ expect(findItem('Dark').getAttribute('aria-checked')).toBe('true')
+ expect(findItem('System').getAttribute('aria-checked')).toBe('false')
+ })
+})
+
+describe('Avatar initials', () => {
+ it.each([
+ ['marta', '', 'MA', 'marta'],
+ ['marta', 'Marta Alves', 'MA', 'Marta Alves'],
+ ['marta', 'Marta Alves Costa', 'MA', 'Marta Alves Costa'],
+ ['ines', 'Inès Duarte', 'ID', 'Inès Duarte'],
+ ['elodie', 'Élodie Martin', 'ÉM', 'Élodie Martin'],
+ ['marta', 'Marta', 'MA', 'Marta'],
+ ['m.alves', '', 'M.', 'm.alves'],
+ ])('shows %s / %j as %s and the name %s', async (username, displayName, initials, shown) => {
+ await mountMenu({ username, displayName })
+
+ expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe(initials)
+ expect(chip().textContent).toContain(shown)
+
+ await openMenu()
+
+ expect(menu()!.textContent).toContain(shown)
+ })
+})
+
+describe('The collapsed rail keeps the menu and an accessible name', () => {
+ it('shows only the avatar, names the chip and opens the same menu', async () => {
+ await mountMenu({ displayName: 'Marta Alves', collapsed: true })
+
+ expect(chip().textContent).not.toContain('Marta Alves')
+ expect(chip().getAttribute('aria-label')).toBe('Marta Alves — account menu')
+ expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
+
+ await openMenu()
+
+ expect(itemLabels()).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out'])
+ })
+})
+
+describe('Below lg the chip hands the menu to the bottom sheet', () => {
+ it('emits open-sheet instead of opening the popover', async () => {
+ setViewportWidth(MOBILE_WIDTH)
+ const wrapper = await mountMenu()
+
+ await openMenu()
+
+ expect(wrapper.emitted('open-sheet')).toHaveLength(1)
+ expect(menu()).toBeNull()
})
})
diff --git a/web/src/components/__tests__/viewport.ts b/web/src/components/__tests__/viewport.ts
new file mode 100644
index 0000000..6cf0996
--- /dev/null
+++ b/web/src/components/__tests__/viewport.ts
@@ -0,0 +1,30 @@
+const REM_PX = 16
+
+function widthLimit(query: string, feature: 'min-width' | 'max-width') {
+ const match = query.match(new RegExp(`\\(${feature}:\\s*([\\d.]+)(rem|px)\\)`))
+ if (!match) {
+ return null
+ }
+ return Number(match[1]) * (match[2] === 'rem' ? REM_PX : 1)
+}
+
+export function setViewportWidth(width: number) {
+ window.matchMedia = ((query: string) => {
+ const min = widthLimit(query, 'min-width')
+ const max = widthLimit(query, 'max-width')
+ const matches = (min === null || width >= min) && (max === null || width <= max)
+ return {
+ matches,
+ media: query,
+ onchange: null,
+ addEventListener: () => undefined,
+ removeEventListener: () => undefined,
+ addListener: () => undefined,
+ removeListener: () => undefined,
+ dispatchEvent: () => false,
+ }
+ }) as unknown as typeof window.matchMedia
+}
+
+export const DESKTOP_WIDTH = 1440
+export const MOBILE_WIDTH = 390
diff --git a/web/src/composables/__tests__/useApi.spec.ts b/web/src/composables/__tests__/useApi.spec.ts
index 903dfed..cf13395 100644
--- a/web/src/composables/__tests__/useApi.spec.ts
+++ b/web/src/composables/__tests__/useApi.spec.ts
@@ -5,11 +5,7 @@ import router from '@/router'
import { useSessionStore } from '@/stores/session'
import { ApiError, NetworkError, isOffline, useApi } from '@/composables/useApi'
-function mockResponse(
- status: number,
- body: unknown = null,
- headers: Record = {},
-) {
+function mockResponse(status: number, body: unknown = null, headers: Record = {}) {
return {
ok: status >= 200 && status < 300,
status,
@@ -22,10 +18,7 @@ function setOnline(value: boolean) {
Object.defineProperty(navigator, 'onLine', { configurable: true, value })
}
-async function expectApiError(
- call: Promise,
- matcher: Record,
-) {
+async function expectApiError(call: Promise, matcher: Record) {
await expect(call).rejects.toBeInstanceOf(ApiError)
await expect(call).rejects.toMatchObject(matcher)
}
@@ -109,9 +102,7 @@ describe('useApi global 401/403/429/offline handling', () => {
await expectApiError(call, { status: 403 })
- await vi.waitFor(() =>
- expect(router.currentRoute.value.path).toBe('/register/pending'),
- )
+ await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
expect(session.session).toEqual({ verified: false })
})
@@ -159,9 +150,7 @@ describe('useApi global 401/403/429/offline handling', () => {
})
it('A 429 on a rate-limited endpoint exposes the Retry-After seconds', async () => {
- fetchMock.mockResolvedValue(
- mockResponse(429, null, { 'Retry-After': '30' }),
- )
+ fetchMock.mockResolvedValue(mockResponse(429, null, { 'Retry-After': '30' }))
const call = useApi().request('/v1/auth/login', { method: 'POST' })
@@ -209,3 +198,44 @@ describe('useApi global 401/403/429/offline handling', () => {
expect(isOffline.value).toBe(false)
})
})
+
+describe('getMe maps the account fields', () => {
+ it('maps email and display_name onto email and displayName', async () => {
+ fetchMock.mockResolvedValue(
+ mockResponse(200, {
+ id: 1,
+ username: 'marta',
+ email: 'marta@renew.example',
+ is_verified: true,
+ display_name: 'Marta Alves',
+ }),
+ )
+
+ await expect(useApi().getMe()).resolves.toEqual({
+ id: 1,
+ verified: true,
+ username: 'marta',
+ email: 'marta@renew.example',
+ displayName: 'Marta Alves',
+ })
+ })
+
+ it.each([
+ ['absent', undefined],
+ ['null', null],
+ ])('reads a %s display_name as an empty string', async (_label, value) => {
+ fetchMock.mockResolvedValue(
+ mockResponse(200, {
+ id: 1,
+ username: 'marta',
+ email: 'marta@renew.example',
+ is_verified: true,
+ display_name: value,
+ }),
+ )
+
+ const me = await useApi().getMe()
+
+ expect(me.displayName).toBe('')
+ })
+})
diff --git a/web/src/composables/useAccountMenu.ts b/web/src/composables/useAccountMenu.ts
new file mode 100644
index 0000000..2e137e5
--- /dev/null
+++ b/web/src/composables/useAccountMenu.ts
@@ -0,0 +1,30 @@
+import { computed } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { useColorMode, type ColorModePreference } from '@/composables/useColorMode'
+import { accountInitials, accountName } from '@/lib/accountName'
+
+const PROFILE_PATH = '/profile'
+
+export const colorModeOptions: { label: string; value: ColorModePreference }[] = [
+ { label: 'System', value: 'system' },
+ { label: 'Light', value: 'light' },
+ { label: 'Dark', value: 'dark' },
+]
+
+export function useAccountMenu(account: () => { username: string; displayName?: string }) {
+ const route = useRoute()
+ const router = useRouter()
+ const colorMode = useColorMode()
+
+ const name = computed(() => accountName(account().username, account().displayName))
+ const initials = computed(() => accountInitials(account().username, account().displayName))
+ const onProfile = computed(() => route.path === PROFILE_PATH)
+
+ function goToProfile() {
+ if (!onProfile.value) {
+ router.push(PROFILE_PATH)
+ }
+ }
+
+ return { colorMode, name, initials, onProfile, goToProfile }
+}
diff --git a/web/src/composables/useApi.ts b/web/src/composables/useApi.ts
index 2c510a3..40d24b1 100644
--- a/web/src/composables/useApi.ts
+++ b/web/src/composables/useApi.ts
@@ -25,6 +25,8 @@ export interface MeResult {
id?: number
verified: boolean
username?: string
+ email?: string
+ displayName: string
}
export type OnUnauthorized = 'redirect' | 'passthrough'
@@ -127,11 +129,20 @@ export function useApi() {
}
async function getMe(): Promise {
- const body = await request<{ id?: number; is_verified: boolean; username?: string }>(
- '/v1/auth/me',
- { onUnauthorized: 'passthrough' },
- )
- return { id: body.id, verified: Boolean(body.is_verified), username: body.username }
+ const body = await request<{
+ id?: number
+ is_verified: boolean
+ username?: string
+ email?: string
+ display_name?: string | null
+ }>('/v1/auth/me', { onUnauthorized: 'passthrough' })
+ return {
+ id: body.id,
+ verified: Boolean(body.is_verified),
+ username: body.username,
+ email: body.email,
+ displayName: body.display_name ?? '',
+ }
}
return { getMe, request }
diff --git a/web/src/lib/__tests__/accountName.spec.ts b/web/src/lib/__tests__/accountName.spec.ts
new file mode 100644
index 0000000..76acfa4
--- /dev/null
+++ b/web/src/lib/__tests__/accountName.spec.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest'
+import { accountInitials, accountName } from '@/lib/accountName'
+
+describe('accountName', () => {
+ it.each([
+ ['marta', '', 'marta'],
+ ['marta', 'Marta Alves', 'Marta Alves'],
+ ['marta', undefined, 'marta'],
+ ['', '', ''],
+ ])('shows %s with display name %j as %s', (username, display, expected) => {
+ expect(accountName(username, display)).toBe(expected)
+ })
+})
+
+describe('Avatar initials', () => {
+ it.each([
+ ['marta', '', 'MA'],
+ ['marta', 'Marta Alves', 'MA'],
+ ['marta', 'Marta Alves Costa', 'MA'],
+ ['ines', 'Inès Duarte', 'ID'],
+ ['elodie', 'Élodie Martin', 'ÉM'],
+ ['marta', 'Marta', 'MA'],
+ ['m.alves', '', 'M.'],
+ ['marta', ' Marta Alves ', 'MA'],
+ ['', '', ''],
+ ])('gives %s with display name %j the initials %s', (username, display, expected) => {
+ expect(accountInitials(username, display)).toBe(expected)
+ })
+
+ it('counts an emoji as one character', () => {
+ expect(accountInitials('x', '😀 Smile')).toBe('😀S')
+ })
+})
diff --git a/web/src/lib/accountName.ts b/web/src/lib/accountName.ts
new file mode 100644
index 0000000..93ee2e7
--- /dev/null
+++ b/web/src/lib/accountName.ts
@@ -0,0 +1,22 @@
+const WHITESPACE = /\s+/
+
+function firstCharacters(value: string, count: number) {
+ return Array.from(value).slice(0, count).join('')
+}
+
+export function accountName(username: string, displayName?: string | null) {
+ return displayName ? displayName : username
+}
+
+export function accountInitials(username: string, displayName?: string | null) {
+ const words = (displayName ?? '').trim().split(WHITESPACE).filter(Boolean)
+ if (words.length >= 2) {
+ return words
+ .slice(0, 2)
+ .map((word) => firstCharacters(word, 1))
+ .join('')
+ .toUpperCase()
+ }
+ const source = words[0] ?? username
+ return firstCharacters(source, 2).toUpperCase()
+}
diff --git a/web/src/router/index.ts b/web/src/router/index.ts
index f53d48e..889ff71 100644
--- a/web/src/router/index.ts
+++ b/web/src/router/index.ts
@@ -76,6 +76,11 @@ const router = createRouter({
component: () => import('@/views/UsersView.vue'),
meta: { auth: 'verified', title: 'Users' },
},
+ {
+ path: '/profile',
+ component: () => import('@/views/ProfileView.vue'),
+ meta: { auth: 'verified', title: 'Profile' },
+ },
{
path: '/integration',
component: () => import('@/views/IntegrationView.vue'),
diff --git a/web/src/stores/__tests__/session.spec.ts b/web/src/stores/__tests__/session.spec.ts
index e68325b..99d418d 100644
--- a/web/src/stores/__tests__/session.spec.ts
+++ b/web/src/stores/__tests__/session.spec.ts
@@ -18,3 +18,31 @@ describe('session store clear action', () => {
expect(session.status).toBe('ready')
})
})
+
+describe('session store setDisplayName action', () => {
+ it('replaces the session display name with the given value', () => {
+ const session = useSessionStore()
+ session.session = { verified: true, username: 'marta', displayName: '' }
+
+ session.setDisplayName('Marta Alves')
+
+ expect(session.session?.displayName).toBe('Marta Alves')
+ })
+
+ it.each([[null], [undefined]])('reads %s as an empty display name', (value) => {
+ const session = useSessionStore()
+ session.session = { verified: true, username: 'marta', displayName: 'Marta Alves' }
+
+ session.setDisplayName(value)
+
+ expect(session.session?.displayName).toBe('')
+ })
+
+ it('does nothing without a session', () => {
+ const session = useSessionStore()
+
+ session.setDisplayName('Marta Alves')
+
+ expect(session.session).toBeNull()
+ })
+})
diff --git a/web/src/stores/session.ts b/web/src/stores/session.ts
index 3338b52..d4227dc 100644
--- a/web/src/stores/session.ts
+++ b/web/src/stores/session.ts
@@ -8,6 +8,8 @@ export interface Session {
id?: number
verified: boolean
username?: string
+ email?: string
+ displayName: string
}
export const useSessionStore = defineStore('session', () => {
@@ -53,5 +55,11 @@ export const useSessionStore = defineStore('session', () => {
status.value = 'ready'
}
- return { status, session, check, retry, clear }
+ function setDisplayName(displayName: string | null | undefined) {
+ if (session.value) {
+ session.value = { ...session.value, displayName: displayName ?? '' }
+ }
+ }
+
+ return { status, session, check, retry, clear, setDisplayName }
})
diff --git a/web/src/views/HomeView.vue b/web/src/views/HomeView.vue
index 7b22cc6..2578069 100644
--- a/web/src/views/HomeView.vue
+++ b/web/src/views/HomeView.vue
@@ -7,6 +7,7 @@ import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import NewArticleModal from '@/components/NewArticleModal.vue'
import { useArticles, type Article } from '@/composables/useArticles'
import { useSessionStore } from '@/stores/session'
+import { accountName } from '@/lib/accountName'
import { formatRelativeDate } from '@/lib/relativeDate'
const SKELETON_DELAY_MS = 150
@@ -37,8 +38,8 @@ function greetingForHour(hour: number) {
const greeting = computed(() => {
const timeOfDayGreeting = greetingForHour(new Date().getHours())
- const username = session.session?.username
- return username ? `${timeOfDayGreeting}, ${username}` : timeOfDayGreeting
+ const name = accountName(session.session?.username ?? '', session.session?.displayName)
+ return name ? `${timeOfDayGreeting}, ${name}` : timeOfDayGreeting
})
const subtitle = computed(() =>
diff --git a/web/src/views/ProfileView.vue b/web/src/views/ProfileView.vue
new file mode 100644
index 0000000..b03c0a1
--- /dev/null
+++ b/web/src/views/ProfileView.vue
@@ -0,0 +1,398 @@
+
+
+
+
+
+
+
Profile
+
Your account on this RenewCMS workspace.
+
+
+
+
+
+
+
+
+
+
+
+ Display name (optional)
+
+
+
+
+
+ {{ DISPLAY_NAME_LIMIT_COPY }}
+
+
+
+
+
+
+
+ {{ statusText }}
+
+ {{ saving ? 'Saving…' : 'Save' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ resetButtonLabel }}
+
+
+
+
+
+
+
+
diff --git a/web/src/views/__tests__/HomeView.spec.ts b/web/src/views/__tests__/HomeView.spec.ts
index eb068a6..7e07c72 100644
--- a/web/src/views/__tests__/HomeView.spec.ts
+++ b/web/src/views/__tests__/HomeView.spec.ts
@@ -379,6 +379,58 @@ describe('The greeting is identical across every load state at a fixed hour', ()
})
})
+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/': () => 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/': () => 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()
diff --git a/web/src/views/__tests__/PendingView.spec.ts b/web/src/views/__tests__/PendingView.spec.ts
index 563844f..3c1445d 100644
--- a/web/src/views/__tests__/PendingView.spec.ts
+++ b/web/src/views/__tests__/PendingView.spec.ts
@@ -6,11 +6,7 @@ import router from '@/router'
import { useSessionStore } from '@/stores/session'
import PendingView from '../PendingView.vue'
-function mockResponse(
- status: number,
- body: unknown = null,
- headers: Record = {},
-) {
+function mockResponse(status: number, body: unknown = null, headers: Record = {}) {
return {
ok: status >= 200 && status < 300,
status,
@@ -24,9 +20,7 @@ function routeFor(url: string, init?: RequestInit) {
return `${method} ${url}`
}
-function fetchRouter(
- handlers: Record Response | Promise>,
-) {
+function fetchRouter(handlers: Record Response | Promise>) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
@@ -40,17 +34,15 @@ function fetchRouter(
function createOverlayMock() {
let props: Record | null = null
let resolveOpen: ((value: unknown) => void) | null = null
- const create = vi.fn(
- (_component: unknown, options: { props: Record }) => {
- props = options.props
- return {
- open: () =>
- new Promise((resolve) => {
- resolveOpen = resolve
- }),
- }
- },
- )
+ const create = vi.fn((_component: unknown, options: { props: Record }) => {
+ props = options.props
+ return {
+ open: () =>
+ new Promise((resolve) => {
+ resolveOpen = resolve
+ }),
+ }
+ })
return {
create,
getProps: () => props,
@@ -148,24 +140,16 @@ describe('Logging out', () => {
await findButton(wrapper, 'Log out')?.trigger('click')
await flushPromises()
+ expect((findButton(wrapper, 'Log out')?.element as HTMLButtonElement).disabled).toBe(true)
expect(
- (findButton(wrapper, 'Log out')?.element as HTMLButtonElement)
- .disabled,
- ).toBe(true)
- expect(
- (
- findButton(wrapper, 'Cancel registration')
- ?.element as HTMLButtonElement
- ).disabled,
+ (findButton(wrapper, 'Cancel registration')?.element as HTMLButtonElement).disabled,
).toBe(true)
resolveLogout?.()
await flushPromises()
expect(session.session).toBeNull()
- await vi.waitFor(() =>
- expect(router.currentRoute.value.path).toBe('/login'),
- )
+ await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
})
})
@@ -245,9 +229,7 @@ describe('Cancelled — 200 navigates to /register with a toast', () => {
overlayMock.resolveOpen(true)
await flushPromises()
- await vi.waitFor(() =>
- expect(router.currentRoute.value.path).toBe('/register'),
- )
+ await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register'))
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
@@ -275,7 +257,7 @@ describe('Already verified — 403 shows the verified panel and toast, then navi
const props = overlayMock.getProps() as { action: () => Promise }
await props.action()
- expect(session.session).toEqual({ verified: true })
+ expect(session.session).toMatchObject({ verified: true, displayName: '' })
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
overlayMock.resolveOpen(true)
@@ -291,12 +273,9 @@ describe('Already verified — 403 shows the verified panel and toast, then navi
})
expect(router.currentRoute.value.path).not.toBe('/home')
- await vi.waitFor(
- () => expect(router.currentRoute.value.path).toBe('/home'),
- {
- timeout: 2000,
- },
- )
+ await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
+ timeout: 2000,
+ })
expect(toastAdd).toHaveBeenCalledTimes(1)
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
pushSpy.mockRestore()
@@ -310,23 +289,20 @@ describe('Cancel failed — the dialog stays open and shows the failure inline',
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
- ])(
- 'rejects the action instead of navigating for $name',
- async ({ handler }) => {
- const { wrapper } = await mountPending({
- 'DELETE /v1/auth/register': handler,
- })
+ ])('rejects the action instead of navigating for $name', async ({ handler }) => {
+ const { wrapper } = await mountPending({
+ 'DELETE /v1/auth/register': handler,
+ })
- await findButton(wrapper, 'Cancel registration')?.trigger('click')
- await flushPromises()
+ await findButton(wrapper, 'Cancel registration')?.trigger('click')
+ await flushPromises()
- const props = overlayMock.getProps() as {
- action: () => Promise
- }
- await expect(props.action()).rejects.toThrow()
+ const props = overlayMock.getProps() as {
+ action: () => Promise
+ }
+ await expect(props.action()).rejects.toThrow()
- expect(router.currentRoute.value.path).toBe('/register/pending')
- expect(toastAdd).not.toHaveBeenCalled()
- },
- )
+ expect(router.currentRoute.value.path).toBe('/register/pending')
+ expect(toastAdd).not.toHaveBeenCalled()
+ })
})
diff --git a/web/src/views/__tests__/ProfileView.spec.ts b/web/src/views/__tests__/ProfileView.spec.ts
new file mode 100644
index 0000000..6b3ba17
--- /dev/null
+++ b/web/src/views/__tests__/ProfileView.spec.ts
@@ -0,0 +1,872 @@
+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()
+ return { ...actual, useApi: vi.fn() }
+})
+
+let toastAdd: ReturnType
+let overlayMock: ReturnType
+
+vi.mock('@nuxt/ui/composables', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useToast: () => ({ add: toastAdd }),
+ useOverlay: () => overlayMock,
+ }
+})
+
+function createOverlayMock() {
+ let props: Record | null = null
+ let resolveOpen: ((value: unknown) => void) | null = null
+ const create = vi.fn((_component: unknown, options: { props: Record }) => {
+ 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
+
+interface Mounted {
+ wrapper: VueWrapper
+ request: ReturnType
+ getMe: ReturnType
+}
+
+function deferred() {
+ let resolve!: (value: T) => void
+ let reject!: (reason: unknown) => void
+ const promise = new Promise((res, rej) => {
+ resolve = res
+ reject = rej
+ })
+ return { promise, resolve, reject }
+}
+
+interface MountOptions {
+ session?: Session | null
+ status?: 'idle' | 'ready'
+ getMe?: () => Promise
+ handlers?: Record
+ path?: string
+}
+
+let mountedWrapper: VueWrapper | null = null
+
+async function mountApp(options: MountOptions = {}): Promise {
+ 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)
+ 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) {
+ 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, 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('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 four navigation items as the current page', async () => {
+ await mountApp()
+
+ const links = document.body.querySelectorAll('nav[aria-label="Main"] a')
+ expect(links).toHaveLength(4)
+ 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()
+ 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('[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('[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')
+ },
+ )
+})
diff --git a/web/src/views/__tests__/UsersView.spec.ts b/web/src/views/__tests__/UsersView.spec.ts
index ca21cb1..1d7574b 100644
--- a/web/src/views/__tests__/UsersView.spec.ts
+++ b/web/src/views/__tests__/UsersView.spec.ts
@@ -288,6 +288,32 @@ describe('Default — the list loads newest first, with display name, "you" tag
})
})
+describe('Display names in the Users list', () => {
+ it('shows each display name with its username beside it and the chip with the session name', async () => {
+ const fixture = defaultFixture().map((item) =>
+ item.kind === 'user' && item.username === 'marta'
+ ? { ...item, display_name: 'Marta Alves' }
+ : item,
+ )
+ await mountPage({ list: () => Promise.resolve(fixture) })
+ useSessionStore().session = {
+ verified: true,
+ username: 'marta',
+ id: 1,
+ displayName: 'Marta Alves',
+ }
+ await flushPromises()
+
+ expect(rowTexts().slice(2)).toEqual([
+ 'Inès Duarte ines Accepted 18 Jun 2026',
+ 'jonas Accepted 2 Apr 2026',
+ 'Marta Alves marta you Accepted 12 Mar 2026',
+ ])
+ const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement
+ expect(footer.textContent).toContain('Marta Alves')
+ })
+})
+
describe('Loading — skeleton rows after 150 ms', () => {
it('draws nothing until 150 ms, then five skeleton rows, then the rows', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
diff --git a/web/src/views/__tests__/VerifyView.spec.ts b/web/src/views/__tests__/VerifyView.spec.ts
index ee6965c..ac91773 100644
--- a/web/src/views/__tests__/VerifyView.spec.ts
+++ b/web/src/views/__tests__/VerifyView.spec.ts
@@ -6,11 +6,7 @@ import router from '@/router'
import { useSessionStore } from '@/stores/session'
import VerifyView from '../VerifyView.vue'
-function mockResponse(
- status: number,
- body: unknown = null,
- headers: Record = {},
-) {
+function mockResponse(status: number, body: unknown = null, headers: Record = {}) {
return {
ok: status >= 200 && status < 300,
status,
@@ -24,9 +20,7 @@ function routeFor(url: string, init?: RequestInit) {
return `${method} ${url}`
}
-function fetchRouter(
- handlers: Record Response | Promise>,
-) {
+function fetchRouter(handlers: Record Response | Promise>) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
@@ -81,16 +75,11 @@ afterEach(async () => {
describe('Verifying — the code submits automatically on load', () => {
it('shows the full-card spinner and calls verify once with the code', async () => {
const verify = vi.fn(() => new Promise(() => {}))
- const { wrapper, fetchMock } = await mountVerify(
- '/register/validate?c=abc123',
- {
- 'POST /v1/auth/verify': verify,
- },
- )
+ const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
+ 'POST /v1/auth/verify': verify,
+ })
- expect(wrapper.find('[role="status"]').text()).toContain(
- 'Verifying your account…',
- )
+ expect(wrapper.find('[role="status"]').text()).toContain('Verifying your account…')
expect(verify).toHaveBeenCalledTimes(1)
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/verify',
@@ -119,16 +108,13 @@ describe('Success — 200 refreshes the session and navigates to /home with a to
description: 'Welcome to RenewCMS.',
})
- await vi.waitFor(
- () => expect(router.currentRoute.value.path).toBe('/home'),
- {
- timeout: 2000,
- },
- )
+ await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
+ timeout: 2000,
+ })
expect(toastAdd).toHaveBeenCalledTimes(1)
const session = useSessionStore()
- expect(session.session).toEqual({ verified: true })
+ expect(session.session).toMatchObject({ verified: true, displayName: '' })
})
})
@@ -152,9 +138,7 @@ describe('Invalid or expired — 403 offers a way to start over', () => {
expect(wrapper.text()).toContain(
'Cancel your registration and start over to get a new link.',
)
- expect(
- findButton(wrapper, 'Cancel registration and start over'),
- ).toBeTruthy()
+ expect(findButton(wrapper, 'Cancel registration and start over')).toBeTruthy()
expect(findButton(wrapper, 'Log out')).toBeTruthy()
expect(router.currentRoute.value.path).toBe('/register/validate')
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
@@ -165,27 +149,19 @@ describe('Invalid or expired — 403 offers a way to start over', () => {
describe('Cancel registration and start over — success', () => {
it('sends DELETE with onForbidden passthrough, navigates to /register and toasts', async () => {
const deleteRegister = vi.fn(() => mockResponse(200))
- const { wrapper, fetchMock } = await mountVerify(
- '/register/validate?c=abc123',
- {
- 'POST /v1/auth/verify': () => mockResponse(403),
- 'DELETE /v1/auth/register': deleteRegister,
- },
- )
+ const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
+ 'POST /v1/auth/verify': () => mockResponse(403),
+ 'DELETE /v1/auth/register': deleteRegister,
+ })
- await findButton(
- wrapper,
- 'Cancel registration and start over',
- )?.trigger('click')
+ await findButton(wrapper, 'Cancel registration and start over')?.trigger('click')
await flushPromises()
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/register',
expect.objectContaining({ method: 'DELETE' }),
)
- await vi.waitFor(() =>
- expect(router.currentRoute.value.path).toBe('/register'),
- )
+ await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register'))
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
@@ -206,38 +182,25 @@ describe('Cancel registration and start over — failure shows an alert, not a r
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
- ])(
- 'shows the failure alert and keeps the panel for $name',
- async ({ handler }) => {
- const { wrapper } = await mountVerify(
- '/register/validate?c=abc123',
- {
- 'POST /v1/auth/verify': () => mockResponse(403),
- 'DELETE /v1/auth/register': handler,
- },
- )
+ ])('shows the failure alert and keeps the panel for $name', async ({ handler }) => {
+ const { wrapper } = await mountVerify('/register/validate?c=abc123', {
+ 'POST /v1/auth/verify': () => mockResponse(403),
+ 'DELETE /v1/auth/register': handler,
+ })
- await findButton(
- wrapper,
- 'Cancel registration and start over',
- )?.trigger('click')
- await flushPromises()
+ await findButton(wrapper, 'Cancel registration and start over')?.trigger('click')
+ await flushPromises()
- expect(wrapper.text()).toContain("Couldn't cancel registration")
- expect(wrapper.text()).toContain(
- 'Something went wrong on the server. Try again in a moment.',
- )
- expect(wrapper.text()).toContain(
- "This verification link doesn't work",
- )
- expect(
- findButton(wrapper, 'Cancel registration and start over'),
- ).toBeTruthy()
- expect(findButton(wrapper, 'Log out')).toBeTruthy()
- expect(router.currentRoute.value.path).toBe('/register/validate')
- expect(toastAdd).not.toHaveBeenCalled()
- },
- )
+ expect(wrapper.text()).toContain("Couldn't cancel registration")
+ expect(wrapper.text()).toContain(
+ 'Something went wrong on the server. Try again in a moment.',
+ )
+ expect(wrapper.text()).toContain("This verification link doesn't work")
+ expect(findButton(wrapper, 'Cancel registration and start over')).toBeTruthy()
+ expect(findButton(wrapper, 'Log out')).toBeTruthy()
+ expect(router.currentRoute.value.path).toBe('/register/validate')
+ expect(toastAdd).not.toHaveBeenCalled()
+ })
})
describe('Log out from the Invalid-or-expired state', () => {
@@ -254,9 +217,7 @@ describe('Log out from the Invalid-or-expired state', () => {
await flushPromises()
expect(session.session).toBeNull()
- await vi.waitFor(() =>
- expect(router.currentRoute.value.path).toBe('/login'),
- )
+ await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
})
})
@@ -270,15 +231,9 @@ describe('No session — the inline log-in form replaces the spinner', () => {
expect(wrapper.text()).toContain(
"You opened this link somewhere you're not logged in. Log in here and we'll verify your account right after.",
)
- expect(
- wrapper.find('input[placeholder="Your username"]').exists(),
- ).toBe(true)
- expect(
- wrapper.find('input[autocomplete="current-password"]').exists(),
- ).toBe(true)
- expect(wrapper.find('button[type="submit"]').text()).toBe(
- 'Log in and verify',
- )
+ expect(wrapper.find('input[placeholder="Your username"]').exists()).toBe(true)
+ expect(wrapper.find('input[autocomplete="current-password"]').exists()).toBe(true)
+ expect(wrapper.find('button[type="submit"]').text()).toBe('Log in and verify')
expect(wrapper.text()).not.toContain('Forgot password?')
expect(wrapper.text()).not.toContain('First time here?')
expect(router.currentRoute.value.path).toBe('/register/validate')
@@ -310,26 +265,17 @@ describe('No session — successful login retries verification with the same cod
const verify = vi.fn()
verify.mockResolvedValueOnce(mockResponse(401))
verify.mockImplementationOnce(() => new Promise(() => {}))
- const { wrapper, fetchMock } = await mountVerify(
- '/register/validate?c=abc123',
- {
- 'POST /v1/auth/verify': verify,
- 'POST /v1/auth/login': () => mockResponse(200),
- },
- )
+ const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
+ 'POST /v1/auth/verify': verify,
+ 'POST /v1/auth/login': () => mockResponse(200),
+ })
- await wrapper
- .find('input[placeholder="Your username"]')
- .setValue('marta')
- await wrapper
- .find('input[autocomplete="current-password"]')
- .setValue('limewash2026')
+ await wrapper.find('input[placeholder="Your username"]').setValue('marta')
+ await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
- expect(wrapper.find('[role="status"]').text()).toContain(
- 'Verifying your account…',
- )
+ expect(wrapper.find('[role="status"]').text()).toContain('Verifying your account…')
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/verify',
expect.objectContaining({
@@ -349,24 +295,14 @@ describe('No session — wrong credentials during the inline login', () => {
'POST /v1/auth/login': () => mockResponse(403),
})
- await wrapper
- .find('input[placeholder="Your username"]')
- .setValue('marta')
- await wrapper
- .find('input[autocomplete="current-password"]')
- .setValue('wrong-password')
+ await wrapper.find('input[placeholder="Your username"]').setValue('marta')
+ await wrapper.find('input[autocomplete="current-password"]').setValue('wrong-password')
await wrapper.find('form').trigger('submit')
await flushPromises()
- expect(wrapper.text()).toContain(
- "That username and password don't match",
- )
- expect(wrapper.text()).toContain(
- 'Check both and try again. Passwords are case-sensitive.',
- )
- const passwordInput = wrapper.find(
- 'input[autocomplete="current-password"]',
- )
+ expect(wrapper.text()).toContain("That username and password don't match")
+ expect(wrapper.text()).toContain('Check both and try again. Passwords are case-sensitive.')
+ const passwordInput = wrapper.find('input[autocomplete="current-password"]')
expect((passwordInput.element as HTMLInputElement).value).toBe('')
expect(passwordInput.element).toBe(document.activeElement)
expect(verify).toHaveBeenCalledTimes(1)
@@ -378,23 +314,16 @@ describe('No session — too many login attempts', () => {
vi.useFakeTimers()
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(401),
- 'POST /v1/auth/login': () =>
- mockResponse(429, null, { 'Retry-After': '30' }),
+ 'POST /v1/auth/login': () => mockResponse(429, null, { 'Retry-After': '30' }),
})
- await wrapper
- .find('input[placeholder="Your username"]')
- .setValue('marta')
- await wrapper
- .find('input[autocomplete="current-password"]')
- .setValue('limewash2026')
+ await wrapper.find('input[placeholder="Your username"]').setValue('marta')
+ await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('Too many attempts')
- expect(wrapper.text()).toContain(
- 'For your security, wait a moment before trying again.',
- )
+ expect(wrapper.text()).toContain('For your security, wait a moment before trying again.')
const button = wrapper.find('button[type="submit"]')
expect(button.text()).toBe('Try again in 0:30')
expect((button.element as HTMLButtonElement).disabled).toBe(true)
@@ -402,9 +331,7 @@ describe('No session — too many login attempts', () => {
await vi.advanceTimersByTimeAsync(30000)
await flushPromises()
- expect(wrapper.find('button[type="submit"]').text()).toBe(
- 'Log in and verify',
- )
+ expect(wrapper.find('button[type="submit"]').text()).toBe('Log in and verify')
})
})
@@ -415,50 +342,31 @@ describe('No session — server or network error during the inline login', () =>
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
- ])(
- 'shows the network/server alert and keeps the fields for $name',
- async ({ handler }) => {
- const { wrapper } = await mountVerify(
- '/register/validate?c=abc123',
- {
- 'POST /v1/auth/verify': () => mockResponse(401),
- 'POST /v1/auth/login': handler,
- },
- )
+ ])('shows the network/server alert and keeps the fields for $name', async ({ handler }) => {
+ const { wrapper } = await mountVerify('/register/validate?c=abc123', {
+ 'POST /v1/auth/verify': () => mockResponse(401),
+ 'POST /v1/auth/login': handler,
+ })
- await wrapper
- .find('input[placeholder="Your username"]')
- .setValue('marta')
- await wrapper
- .find('input[autocomplete="current-password"]')
- .setValue('limewash2026')
- await wrapper.find('form').trigger('submit')
- await flushPromises()
+ await wrapper.find('input[placeholder="Your username"]').setValue('marta')
+ await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
+ await wrapper.find('form').trigger('submit')
+ await flushPromises()
- expect(wrapper.text()).toContain("Couldn't reach RenewCMS")
- expect(wrapper.text()).toContain(
- 'Check your connection and try again.',
- )
- expect(
- (
- wrapper.find('input[placeholder="Your username"]')
- .element as HTMLInputElement
- ).value,
- ).toBe('marta')
- expect(
- (
- wrapper.find('input[autocomplete="current-password"]')
- .element as HTMLInputElement
- ).value,
- ).toBe('limewash2026')
- expect(
- (
- wrapper.find('input[placeholder="Your username"]')
- .element as HTMLInputElement
- ).disabled,
- ).toBe(false)
- },
- )
+ expect(wrapper.text()).toContain("Couldn't reach RenewCMS")
+ expect(wrapper.text()).toContain('Check your connection and try again.')
+ expect(
+ (wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement).value,
+ ).toBe('marta')
+ expect(
+ (wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
+ .value,
+ ).toBe('limewash2026')
+ expect(
+ (wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement)
+ .disabled,
+ ).toBe(false)
+ })
})
describe('Missing code — no request is made', () => {
@@ -475,8 +383,6 @@ describe('Missing code — no request is made', () => {
expect(action).toBeTruthy()
await action?.trigger('click')
- await vi.waitFor(() =>
- expect(router.currentRoute.value.path).toBe('/login'),
- )
+ await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
})
})
diff --git a/web/visual/states.ts b/web/visual/states.ts
index b0ed46a..45ae684 100644
--- a/web/visual/states.ts
+++ b/web/visual/states.ts
@@ -152,7 +152,13 @@ const sixArticlesFixture = [
const verifiedSession: ApiMock = {
status: 200,
- body: { id: 1, is_verified: true, username: 'marta' },
+ body: {
+ id: 1,
+ is_verified: true,
+ username: 'marta',
+ email: 'marta@renew.example',
+ display_name: '',
+ },
}
const editorNow = '2026-09-26T11:02:00Z'
@@ -667,7 +673,165 @@ function submitAcceptInvite(username: string, password: string) {
}
}
+const profileDisplayName = 'Marta Alves'
+const profileFieldPlaceholder = 'e.g. Marta Alves'
+const profileTooLongName = 'Marta Alves, Head of Renovations, Brussels and Wallonia office'
+const profileSavedBody = {
+ id: 1,
+ username: 'marta',
+ display_name: profileDisplayName,
+}
+
+function profileSession(displayName = ''): ApiMock {
+ return {
+ status: 200,
+ body: {
+ id: 1,
+ is_verified: true,
+ username: 'marta',
+ email: 'marta@renew.example',
+ display_name: displayName,
+ },
+ }
+}
+
+function profileApi(
+ displayName = '',
+ overrides: Record = {},
+): Record {
+ return {
+ 'GET /v1/auth/me': profileSession(displayName),
+ ...overrides,
+ }
+}
+
+function profileState(
+ displayName = '',
+ overrides: Record = {},
+ steps?: (page: Page) => Promise,
+): VisualState {
+ return { path: '/profile', api: profileApi(displayName, overrides), steps }
+}
+
+async function typeDisplayName(page: Page, value: string) {
+ await page.getByPlaceholder(profileFieldPlaceholder).fill(value)
+}
+
+async function scrollToTop(page: Page) {
+ await page.evaluate(() => {
+ window.scrollTo(0, 0)
+ document.querySelectorAll('[data-slot="body"]').forEach((element) => {
+ element.scrollTop = 0
+ })
+ })
+}
+
+async function pressSave(page: Page) {
+ await page.getByRole('button', { name: 'Save', exact: true }).click()
+}
+
+async function saveDisplayName(page: Page, value: string) {
+ await typeDisplayName(page, value)
+ await pressSave(page)
+ await page.mouse.move(0, 0)
+}
+
+async function pressSendResetLink(page: Page) {
+ await page.getByRole('button', { name: 'Send reset link', exact: true }).click()
+ await page.mouse.move(0, 0)
+ await scrollToTop(page)
+}
+
+function resetState(response: ApiMock): VisualState {
+ return profileState(
+ profileDisplayName,
+ { 'POST /v1/auth/password-reset': response },
+ pressSendResetLink,
+ )
+}
+
+async function openAccountMenu(page: Page) {
+ const isMobile = (page.viewportSize()?.width ?? 0) < 1024
+ if (isMobile) {
+ await page.locator('[data-slot="toggle"]').click()
+ await page.locator('[role="dialog"] button[aria-haspopup]').click()
+ await page.locator('[role="menu"]').waitFor()
+ } else {
+ await page.locator('[data-slot="footer"] button[aria-haspopup]').click()
+ await page.locator('[role="menu"]').waitFor()
+ }
+ await page.mouse.move(0, 0)
+}
+
+const usersWithDisplayNames = usersFixture.map((item) =>
+ item.kind === 'user' && item.username === 'marta'
+ ? { ...item, display_name: profileDisplayName }
+ : item,
+)
+
export const visualStates: Record> = {
+ profile: {
+ 'Account menu': {
+ path: '/home',
+ now: boardNow,
+ api: {
+ 'GET /v1/auth/me': verifiedSession,
+ 'GET /v1/article/': { status: 200, body: sixArticlesFixture },
+ },
+ steps: openAccountMenu,
+ exception: {
+ maxPercent: 26,
+ reason: 'Mobile sheet is ~153px taller than the board because the System/Light/Dark items are kept by user decision, and the board draws Profile highlighted on /home while the feature highlights it only on /profile',
+ approvedBy: 'Florian Sylvain, 2026-09-29',
+ },
+ },
+ Loading: {
+ path: '/profile',
+ api: { 'GET /v1/auth/me': { status: 200, hang: true } },
+ },
+ 'Default, no display name': profileState(),
+ Editing: profileState('', {}, async (page) => {
+ await typeDisplayName(page, profileDisplayName)
+ await page.mouse.move(0, 0)
+ }),
+ 'Too long': profileState('', {}, async (page) => {
+ await typeDisplayName(page, profileTooLongName)
+ await page.mouse.move(0, 0)
+ }),
+ Saving: profileState(
+ '',
+ { 'PATCH /v1/user/me': { status: 200, body: profileSavedBody, hang: true } },
+ async (page) => {
+ await saveDisplayName(page, profileDisplayName)
+ },
+ ),
+ Saved: profileState(
+ '',
+ { 'PATCH /v1/user/me': { status: 200, body: profileSavedBody } },
+ async (page) => {
+ await saveDisplayName(page, profileDisplayName)
+ await page.getByText('Display name saved').first().waitFor()
+ },
+ ),
+ 'Save failed': profileState('', { 'PATCH /v1/user/me': { status: 500 } }, async (page) => {
+ await saveDisplayName(page, profileDisplayName)
+ await page.getByText("Couldn't save your display name").waitFor()
+ }),
+ 'Reset: sending': resetState({ status: 200, hang: true }),
+ 'Reset: sent': resetState({ status: 200, body: { message: 'ok' } }),
+ 'Reset: too many requests (429)': resetState({
+ status: 429,
+ headers: { 'Retry-After': '300' },
+ }),
+ 'Reset: failed': resetState({ status: 500 }),
+ 'Display names in the Users list': {
+ path: '/users',
+ api: {
+ 'GET /v1/auth/me': profileSession(profileDisplayName),
+ 'GET /v1/user/': { status: 200, body: usersWithDisplayNames },
+ },
+ },
+ },
users: {
Default: { path: '/users', api: usersApi() },
Loading: {