feat: change email, export my data, delete account (with data anonymization)

This commit is contained in:
Florian Sylvain committed 2026-10-30 20:19:07 +01:00
1 parent bb191c389a
commit 10a66cd979
123 files changed
+10789 -76

No files matched your search

+11 -2
View File
@@ -202,6 +202,15 @@ describe('Updated by lookup', () => {
expect(cells(rows()[2]!).at(-1)).toBe('Jonas Pereira')
})
it('names an updater the lookup does not return Deleted user', async () => {
await mountList('/content/events', {
list: (_, query) => listPage([{ ...eventEntries[0]!, updated_by: 9 }], 1, query),
lookup: () => [{ id: 3, display_name: 'Marta Alves' }],
})
expect(cells(rows()[0]!).at(-1)).toBe('Deleted user')
})
it('sends no lookup before the entries answer', async () => {
const gate = pending()
await mountList('/content/events', { list: () => gate.promise })
@@ -228,11 +237,11 @@ describe('Updated by lookup', () => {
expect(main().querySelector('[role="alert"]')).toBeNull()
})
it('shows a dash for an id the lookup does not know', async () => {
it('names an id the lookup does not know Deleted user', async () => {
await mountList('/content/events', { lookup: () => lookupNames([3]) })
expect(cells(rows()[0]!).at(-1)).toBe('Marta Alves')
expect(cells(rows()[1]!).at(-1)).toBe('–')
expect(cells(rows()[1]!).at(-1)).toBe('Deleted user')
})
it('leaves entries without an author out of the lookup', async () => {
@@ -0,0 +1,268 @@
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 { useSessionStore, type Session } from '@/stores/session'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import EmailConfirmView from '../EmailConfirmView.vue'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: toastAdd }) }
})
const CONFIRM_PATH = '/v1/auth/email/confirm'
const LOGIN_PATH = '/v1/auth/login'
const NEW = 'marta.alves@atelier-renew.example'
const LOGGED_IN: Session = {
verified: true,
username: 'marta',
email: 'marta@renew.example',
displayName: '',
}
const SERVER_COPY = 'Something went wrong on our side. Nothing was changed. Try again in a moment.'
type Handler = (init?: RequestInit) => Promise<unknown>
let mounted: VueWrapper | null = null
async function mountLanding(
path: string,
handlers: Record<string, Handler> = {},
session: Session | null = null,
) {
setActivePinia(createPinia())
if (session) {
const store = useSessionStore()
store.session = { ...session }
store.status = 'ready'
}
const request = vi.fn((requestPath: string, init?: RequestInit) => {
const handler = handlers[`${init?.method ?? 'GET'} ${requestPath}`]
if (!handler) {
throw new Error(`Unexpected request: ${init?.method ?? 'GET'} ${requestPath}`)
}
return handler(init)
})
const getMe = vi.fn(() =>
Promise.resolve({ verified: true, username: 'marta', email: NEW, displayName: '' }),
)
vi.mocked(useApi).mockReturnValue({ request, getMe } as unknown as ReturnType<typeof useApi>)
await router.push(path)
mounted = mount(EmailConfirmView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return { wrapper: mounted, request, getMe }
}
function confirmCalls(request: ReturnType<typeof vi.fn>) {
return request.mock.calls.filter(([path]) => path === CONFIRM_PATH)
}
function button(wrapper: VueWrapper, label: string) {
return wrapper.findAll('button').find((candidate) => candidate.text() === label)
}
beforeEach(() => {
toastAdd = vi.fn()
})
afterEach(async () => {
vi.useRealTimers()
mounted?.unmount()
mounted = null
document.body.innerHTML = ''
await router.push('/login')
})
describe('Confirming: the code is sent on load', () => {
it('shows the spinner copy and sends the code once', async () => {
const { wrapper, request } = await mountLanding('/account/email/confirm?c=abc123', {
[`POST ${CONFIRM_PATH}`]: () => new Promise(() => {}),
})
expect(wrapper.find('[role="status"]').text()).toContain('Confirming your new email…')
expect(confirmCalls(request)).toHaveLength(1)
const [, init] = confirmCalls(request)[0]!
expect(JSON.parse((init as RequestInit).body as string)).toEqual({ code: 'abc123' })
})
})
describe('Success: 200 replaces the session email, toasts and goes to the profile', () => {
it('shows the new address, raises the toast, updates the session and navigates after 800 ms', async () => {
const { wrapper } = await mountLanding(
'/account/email/confirm?c=abc123',
{ [`POST ${CONFIRM_PATH}`]: () => Promise.resolve({ email: NEW }) },
LOGGED_IN,
)
expect(wrapper.find('h1').text()).toBe('Your email address has changed')
expect(wrapper.text()).toContain(
`Your account now uses ${NEW}. Use it the next time you log in.`,
)
expect(button(wrapper, 'Go to profile')).toBeDefined()
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Email address changed',
description: `Your account now uses ${NEW}.`,
})
expect(useSessionStore().session?.email).toBe(NEW)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/profile'), {
timeout: 2000,
})
})
})
describe('Invalid or expired (403)', () => {
it('shows the dead-link copy and the profile action', async () => {
const { wrapper } = await mountLanding(
'/account/email/confirm?c=abc123',
{
[`POST ${CONFIRM_PATH}`]: () =>
Promise.reject(new ApiError(403, undefined, 'invalid_email_change_code')),
},
LOGGED_IN,
)
expect(wrapper.find('h1').text()).toBe("This link doesn't work")
expect(wrapper.text()).toContain(
'It may have expired (links last 2 hours), been cancelled or already been used.',
)
expect(wrapper.text()).toContain('Request a new link from your profile.')
await button(wrapper, 'Go to profile')!.trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/profile')
})
})
describe('Address unavailable (409)', () => {
it('shows one message whether the address is taken or rejected', async () => {
const { wrapper } = await mountLanding('/account/email/confirm?c=abc123', {
[`POST ${CONFIRM_PATH}`]: () =>
Promise.reject(new ApiError(409, undefined, 'email_unavailable')),
})
expect(wrapper.find('h1').text()).toBe("We couldn't change your email address")
expect(wrapper.text()).toContain(
"This address can't be used for your account. Go back to your profile and try a different one.",
)
expect(button(wrapper, 'Go to profile')).toBeDefined()
})
})
describe('No session (401): the inline log-in', () => {
it('shows the log-in form with the confirm copy', async () => {
const { wrapper } = await mountLanding('/account/email/confirm?c=abc123', {
[`POST ${CONFIRM_PATH}`]: () => Promise.reject(new ApiError(401)),
})
expect(wrapper.find('h1').text()).toBe('Log in to confirm your new email')
expect(wrapper.text()).toContain(
"You opened this link somewhere you're not logged in. Log in here and we'll confirm the change right after.",
)
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in and confirm')
})
it('logs in, then sends the confirmation once with the same code and reaches Success', async () => {
const confirm = vi.fn()
confirm.mockRejectedValueOnce(new ApiError(401))
confirm.mockResolvedValueOnce({ email: NEW })
const { wrapper, request } = await mountLanding('/account/email/confirm?c=abc123', {
[`POST ${CONFIRM_PATH}`]: confirm,
[`POST ${LOGIN_PATH}`]: () => Promise.resolve({}),
})
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(confirm).toHaveBeenCalledTimes(2)
expect(wrapper.find('h1').text()).toBe('Your email address has changed')
const bodies = confirmCalls(request).map(([, init]) => (init as RequestInit).body)
expect(bodies).toEqual([
JSON.stringify({ code: 'abc123' }),
JSON.stringify({ code: 'abc123' }),
])
expect(router.currentRoute.value.fullPath).toBe('/account/email/confirm')
const [, loginInit] = request.mock.calls.find(([path]) => path === LOGIN_PATH)!
expect((loginInit as RequestInit).body).not.toContain('abc123')
})
})
describe('Missing code', () => {
it('sends no request and offers Go to log in', async () => {
const { wrapper, request } = await mountLanding('/account/email/confirm')
expect(wrapper.find('h1').text()).toBe('This link is incomplete')
expect(wrapper.text()).toContain(
'The confirmation code is missing. Open the link from your email again, or copy the full address into your browser.',
)
expect(request).not.toHaveBeenCalled()
await button(wrapper, 'Go to log in')!.trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/login')
})
})
describe('Server error', () => {
it.each([
['a 500', () => Promise.reject(new ApiError(500))],
['a network failure', () => Promise.reject(new NetworkError())],
])('after %s offers Try again, which sends the POST again', async (_label, failure) => {
const confirm = vi.fn()
confirm.mockImplementationOnce(failure)
confirm.mockResolvedValueOnce({ email: NEW })
const { wrapper } = await mountLanding('/account/email/confirm?c=abc123', {
[`POST ${CONFIRM_PATH}`]: confirm,
})
expect(wrapper.find('h1').text()).toBe("Couldn't confirm the change")
expect(wrapper.text()).toContain(SERVER_COPY)
await button(wrapper, 'Try again')!.trigger('click')
await flushPromises()
expect(confirm).toHaveBeenCalledTimes(2)
expect(wrapper.find('h1').text()).toBe('Your email address has changed')
})
})
describe('The code never stays in the URL', () => {
it('removes ?c= from the address while still confirming with it, and Try again reuses it', async () => {
const confirm = vi.fn()
confirm.mockRejectedValueOnce(new ApiError(500))
confirm.mockResolvedValueOnce({ email: NEW })
const { wrapper } = await mountLanding('/account/email/confirm?c=abc123', {
[`POST ${CONFIRM_PATH}`]: confirm,
})
expect(router.currentRoute.value.fullPath).toBe('/account/email/confirm')
expect(router.currentRoute.value.query).toEqual({})
await button(wrapper, 'Try again')!.trigger('click')
await flushPromises()
const bodies = confirm.mock.calls.map(([init]) => (init as RequestInit).body)
expect(bodies).toEqual([
JSON.stringify({ code: 'abc123' }),
JSON.stringify({ code: 'abc123' }),
])
})
})
@@ -119,13 +119,13 @@ describe('2 Editing', () => {
])
})
it('reads a dash and keeps the date when the lookup fails or lacks an id', async () => {
it('names a creator the lookup does not return Deleted user and keeps the date', async () => {
await mountEditor('/content/events/42', {
lookup: () => [{ id: 3, display_name: 'Marta Alves' }],
})
expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toContain(
'Created by – · 12 Oct 2026, 09:14 Updated by Marta Alves · 15 Oct 2026, 11:02',
'Created by Deleted user · 12 Oct 2026, 09:14 Updated by Marta Alves · 15 Oct 2026, 11:02',
)
})
+24
View File
@@ -265,6 +265,30 @@ describe('Password changed — arrived from a successful reset', () => {
})
})
describe('Account deleted — arrived from the delete dialog', () => {
it('shows the success notice once and removes the query', async () => {
const wrapper = await mountLogin('/login?reason=account-deleted', {})
expect(wrapper.text()).toContain('Your account has been deleted')
expect(wrapper.text()).toContain('Your articles and media were kept without your name.')
expect(wrapper.find('[role="status"]').exists()).toBe(true)
expect(wrapper.findComponent({ name: 'Alert' }).props('icon')).toBe('i-lucide-circle-check')
expect(router.currentRoute.value.fullPath).toBe('/login')
wrapper.unmount()
const reloaded = await mountLogin('/login', {})
expect(reloaded.text()).not.toContain('Your account has been deleted')
})
it('shows no notice for any other reason', async () => {
const wrapper = await mountLogin('/login?reason=anything-else', {})
expect(wrapper.text()).not.toContain('Your account has been deleted')
expect(wrapper.text()).not.toContain('Your articles and media were kept')
expect(router.currentRoute.value.query.reason).toBe('anything-else')
})
})
describe('Server or network error — the fields keep their values', () => {
it.each([
{
@@ -77,6 +77,7 @@ describe('Profile: the Author profile card sits between Account and Password', (
'Your role',
'Author profile',
'Password',
'Your data',
])
})
})
@@ -236,7 +237,7 @@ describe('Profile: editing the own author', () => {
)
expect(text).toContain('Save changes')
expect(text).not.toContain('Unlink')
expect(textOf(document.body)).not.toContain('Delete')
expect(text).not.toContain('Delete')
})
it('sends one PATCH with only the changed fields', async () => {
@@ -0,0 +1,242 @@
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 { downloadBlob } from '@/lib/downloadBlob'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import { accessFor } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
vi.mock('@/lib/downloadBlob', () => ({ downloadBlob: vi.fn() }))
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: toastAdd }) }
})
const EXPORT_PATH = '/v1/user/me/export'
const FILENAME = 'renewcms-export-marta-2026-10-29.zip'
const BUTTON = 'Download my data'
const MARTA: Session = {
id: 1,
verified: true,
access: accessFor('noschema'),
username: 'marta',
email: 'marta@renew.example',
displayName: '',
}
function deferred<T = unknown>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
let mountedWrapper: VueWrapper | null = null
async function mountProfile(requestFile: () => Promise<unknown>) {
setViewportWidth(DESKTOP_WIDTH)
setActivePinia(createPinia())
const store = useSessionStore()
store.session = { ...MARTA }
store.status = 'ready'
const download = vi.fn(requestFile)
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn(() => Promise.resolve({ ...MARTA })),
request: vi.fn(() => Promise.resolve([])),
requestFile: download,
} as unknown as ReturnType<typeof useApi>)
await router.push('/profile')
mountedWrapper = mount(App, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return download
}
function button(label: string) {
return Array.from(document.body.querySelectorAll('button')).find((candidate) =>
candidate.textContent?.trim().includes(label),
) as HTMLButtonElement | undefined
}
async function click(target: HTMLElement | undefined) {
target!.click()
await flushPromises()
}
function text() {
return document.body.textContent ?? ''
}
function toastTexts() {
return toastAdd.mock.calls.map(([source]) => {
const content = typeof source === 'function' ? source() : source
return [content.title, content.description]
})
}
const archive = () =>
Promise.resolve({ blob: new Blob(['zip']), filename: FILENAME as string | undefined })
beforeEach(() => {
toastAdd = vi.fn()
vi.mocked(downloadBlob).mockReset()
localStorage.clear()
authRedirectPending.value = false
})
afterEach(async () => {
vi.useRealTimers()
mountedWrapper?.unmount()
mountedWrapper = null
document.body.innerHTML = ''
await router.push('/login')
})
describe('Your data: Direct (recommended): default', () => {
it('follows the Password card and lists the four contents with the button and hint', async () => {
const download = await mountProfile(archive)
const content = text()
expect(content.indexOf('Password')).toBeLessThan(content.indexOf('Your data'))
expect(content).toContain(
'Download a copy of the personal data RenewCMS holds about you, as JSON files in a ZIP.',
)
for (const row of [
'Profile',
'Authored content',
'Your articles and entries.',
'Uploaded media',
'References to the files you uploaded.',
'Account activity',
'Your log-ins and the actions recorded under your name.',
'Username, display name, email, role and author profile.',
]) {
expect(content).toContain(row)
}
expect(button(BUTTON)!.disabled).toBe(false)
expect(content).toContain('Ready in a few seconds.')
expect(document.body.querySelector('[data-testid="data-export-notice"]')).toBeNull()
expect(download).not.toHaveBeenCalled()
})
})
describe('Your data: Direct: preparing', () => {
it('disables the button and shows Preparing download… while the request is open', async () => {
const pending = deferred()
const download = await mountProfile(() => pending.promise)
await click(button(BUTTON))
const preparing = button('Preparing download…')
expect(preparing).toBeDefined()
expect(preparing!.disabled).toBe(true)
expect(download).toHaveBeenCalledTimes(1)
await click(preparing)
expect(download).toHaveBeenCalledTimes(1)
})
})
describe('Your data: Direct: started', () => {
it('saves the file under the header name, toasts and returns to the default', async () => {
const pending = deferred()
await mountProfile(() => pending.promise)
await click(button(BUTTON))
pending.resolve({ blob: new Blob(['zip']), filename: FILENAME })
await flushPromises()
expect(downloadBlob).toHaveBeenCalledWith(expect.any(Blob), FILENAME)
expect(toastTexts()).toEqual([['Download started', `${FILENAME} is in your downloads.`]])
expect(button(BUTTON)!.disabled).toBe(false)
expect(text()).toContain('Ready in a few seconds.')
})
})
describe('Your data: Direct: rate limited (429)', () => {
it('shows the notice and the countdown from Retry-After', async () => {
await mountProfile(() => Promise.reject(new ApiError(429, 720)))
await click(button(BUTTON))
expect(text()).toContain('You can export once every 15 minutes')
expect(text()).toMatch(/Your last export was at \d{2}:\d{2}\. Try again in 12 minutes\./)
const waiting = button('Available in 12:00')
expect(waiting).toBeDefined()
expect(waiting!.disabled).toBe(true)
expect(downloadBlob).not.toHaveBeenCalled()
})
it('enables the button again when the countdown ends', async () => {
vi.useFakeTimers()
await mountProfile(() => Promise.reject(new ApiError(429, 2)))
await click(button(BUTTON))
expect(button('Available in 0:02')).toBeDefined()
await vi.advanceTimersByTimeAsync(2000)
await flushPromises()
expect(button(BUTTON)!.disabled).toBe(false)
expect(text()).not.toContain('You can export once every 15 minutes')
})
})
describe('Your data: Direct: error', () => {
it.each([
['a 500', () => Promise.reject(new ApiError(500))],
['a network failure', () => Promise.reject(new NetworkError())],
])('shows the notice and a button to retry after %s', async (_name, failure) => {
let attempt = 0
const download = await mountProfile(() => (attempt++ === 0 ? failure() : archive()))
await click(button(BUTTON))
expect(text()).toContain("Couldn't prepare your data")
expect(text()).toContain('Nothing was changed. Try again in a moment.')
expect(button(BUTTON)!.disabled).toBe(false)
await click(button(BUTTON))
expect(download).toHaveBeenCalledTimes(2)
expect(text()).not.toContain("Couldn't prepare your data")
expect(downloadBlob).toHaveBeenCalledWith(expect.any(Blob), FILENAME)
})
it('shows no local copy for a 401 or 403, which the client handles globally', async () => {
await mountProfile(() => Promise.reject(new ApiError(403)))
await click(button(BUTTON))
expect(text()).not.toContain("Couldn't prepare your data")
expect(button(BUTTON)!.disabled).toBe(false)
})
})
describe('Your data: export path', () => {
it('asks the API client for the export route', async () => {
const requestFile = vi.fn(archive)
const { useProfile } = await import('@/composables/useProfile')
vi.mocked(useApi).mockReturnValue({ requestFile } as unknown as ReturnType<typeof useApi>)
await useProfile().downloadMyData()
expect(requestFile).toHaveBeenCalledWith(EXPORT_PATH)
})
})
@@ -0,0 +1,514 @@
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,
redirectToLogin,
useApi,
} from '@/composables/useApi'
import { accountDeletedRedirect } from '@/lib/accountDeletion'
import { downloadBlob } from '@/lib/downloadBlob'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import { accessFor } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
vi.mock('@/lib/downloadBlob', () => ({ downloadBlob: vi.fn() }))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: vi.fn() }) }
})
const MARTA: Session = {
id: 7,
verified: true,
access: accessFor('noschema'),
username: 'marta',
email: 'marta@renew.example',
displayName: '',
}
const PASSWORD = 'limewash2026'
const CONFIRM = 'Delete my account'
const KEEP = 'Keep my account'
const WRONG_PASSWORD = "That password isn't correct. Try again."
const RATE_LIMITED = 'Too many requests. Try again in a few minutes.'
const SERVER_ERROR = "Couldn't delete your account. Nothing was deleted. Try again in a moment."
const BLOCKED_TITLE = "You can't delete your account yet"
const ADMIN_BODY =
'You are the last administrator. RenewCMS needs at least one. Promote another user first, then come back to delete your account.'
const OWNER_BODY =
'You are the last Owner. RenewCMS needs at least one. Transfer ownership or promote another user first, then come back to delete your account.'
const PROTECTED_TITLE = "This account can't be deleted"
const PROTECTED_BODY =
"RenewCMS can't delete this account right now. Ask an administrator to help, then try again."
function deferred<T = unknown>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
type DeleteHandler = (body: { current_password: string; confirmation: string }) => Promise<unknown>
let mountedWrapper: VueWrapper | null = null
let request: ReturnType<typeof vi.fn>
let requestFile: ReturnType<typeof vi.fn>
async function mountProfile(handler: DeleteHandler = () => Promise.resolve(undefined)) {
setViewportWidth(DESKTOP_WIDTH)
setActivePinia(createPinia())
const store = useSessionStore()
store.session = { ...MARTA, access: accessFor('owner') }
store.status = 'ready'
request = vi.fn((path: string, init?: RequestInit) => {
if (path === '/v1/user/me' && init?.method === 'DELETE') {
return handler(JSON.parse(init.body as string))
}
return Promise.resolve([])
})
requestFile = vi.fn(() =>
Promise.resolve({ blob: new Blob(['zip']), filename: 'renewcms-export.zip' }),
)
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn(() => Promise.resolve({ ...MARTA })),
request,
requestFile,
} as unknown as ReturnType<typeof useApi>)
await router.push('/profile')
mountedWrapper = mount(App, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
}
function deleteCalls() {
return request.mock.calls.filter(
([path, init]) => path === '/v1/user/me' && init?.method === 'DELETE',
)
}
function dialog() {
return document.body.querySelector<HTMLElement>('[role="alertdialog"]')
}
function buttonIn(root: ParentNode, label: string) {
return Array.from(root.querySelectorAll('button')).find((candidate) =>
candidate.textContent?.trim().includes(label),
) as HTMLButtonElement | undefined
}
function confirmButton() {
return buttonIn(dialog()!, CONFIRM)!
}
function passwordInput() {
return dialog()!.querySelector<HTMLInputElement>('input[type="password"]')!
}
function wordInput() {
return dialog()!.querySelector<HTMLInputElement>('input[autocomplete="off"]')!
}
async function click(target: HTMLElement | undefined) {
target!.click()
await flushPromises()
}
async function fill(input: HTMLInputElement, value: string) {
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
async function openDialog() {
await click(buttonIn(document.body, CONFIRM))
}
async function fillBoth(password = PASSWORD, word = 'DELETE') {
await fill(passwordInput(), password)
await fill(wordInput(), word)
}
async function submit(password = PASSWORD, word = 'DELETE') {
await openDialog()
await fillBoth(password, word)
await click(confirmButton())
}
function dialogText() {
return dialog()?.textContent ?? ''
}
function refusal(status: number, body: string, code?: string, lastRole?: string) {
return () =>
Promise.reject(new ApiError(status, undefined, body, { code: code as never, lastRole }))
}
beforeEach(() => {
vi.mocked(downloadBlob).mockReset()
localStorage.clear()
authRedirectPending.value = false
accountDeletedRedirect.value = false
})
afterEach(async () => {
vi.restoreAllMocks()
mountedWrapper?.unmount()
mountedWrapper = null
document.body.innerHTML = ''
await router.push('/login')
})
describe('Delete account: Danger zone', () => {
it('is the last card with the title, the text and the button, and sends no request', async () => {
await mountProfile()
const card = document.body.querySelector('[data-testid="danger-zone"]')!
expect(card.textContent).toContain('Danger zone')
expect(card.textContent).toContain(
"Delete your account and your personal data. This can't be undone.",
)
expect(buttonIn(card, CONFIRM)).toBeDefined()
const cards = Array.from(
document.body.querySelectorAll('section, [data-testid="danger-zone"]'),
)
expect(cards[cards.length - 1]).toBe(card)
expect(deleteCalls()).toHaveLength(0)
expect(dialog()).toBeNull()
})
})
describe('Delete account: the dialog', () => {
it('shows the design content', async () => {
await mountProfile()
await openDialog()
const content = dialogText()
for (const copy of [
'Delete your account?',
"This permanently deletes your account. It can't be undone.",
'Your username, display name and email address',
'Your author profile and API keys',
"Your sessions: you'll be logged out everywhere",
'Your articles and media stay',
'They stay on the site, credited to "Deleted user". Your name is removed from them.',
'Export my data first',
'Current password',
'Type DELETE to confirm',
KEEP,
CONFIRM,
]) {
expect(content).toContain(copy)
}
expect(dialog()!.querySelector('[aria-label="Close"]')).not.toBeNull()
})
it('Confirm: empty keeps Delete my account disabled', async () => {
await mountProfile()
await openDialog()
expect(confirmButton().disabled).toBe(true)
})
it('Confirm: typing keeps it disabled with a password and a partial word', async () => {
await mountProfile()
await openDialog()
await fillBoth(PASSWORD, 'DEL')
expect(confirmButton().disabled).toBe(true)
})
it('Confirm: ready enables it for exactly DELETE and a password', async () => {
await mountProfile()
await openDialog()
await fillBoth(PASSWORD, 'DELETE')
expect(confirmButton().disabled).toBe(false)
})
it.each(['delete', 'Delete', 'DELETE '])('keeps it disabled for the word %j', async (word) => {
await mountProfile()
await openDialog()
await fillBoth(PASSWORD, word)
expect(confirmButton().disabled).toBe(true)
})
it('keeps it disabled without a password', async () => {
await mountProfile()
await openDialog()
await fillBoth('', 'DELETE')
expect(confirmButton().disabled).toBe(true)
})
it('Keep my account closes the dialog and clears the fields', async () => {
await mountProfile()
await openDialog()
await fillBoth()
await click(buttonIn(dialog()!, KEEP))
await openDialog()
expect(passwordInput().value).toBe('')
expect(wordInput().value).toBe('')
})
it('sends the password and the typed word', async () => {
await mountProfile()
await submit()
expect(deleteCalls()).toHaveLength(1)
expect(JSON.parse(deleteCalls()[0]![1].body)).toEqual({
current_password: PASSWORD,
confirmation: 'DELETE',
})
})
})
describe('Delete account: refusals', () => {
it('Wrong password (403) shows the message, clears and focuses the password and keeps the word', async () => {
await mountProfile(refusal(403, 'wrong_password', 'wrong_password'))
await submit()
expect(dialogText()).toContain(WRONG_PASSWORD)
expect(passwordInput().value).toBe('')
expect(passwordInput()).toBe(document.activeElement)
expect(wordInput().value).toBe('DELETE')
})
it('Deleting shows the pending label and locks everything', async () => {
const pending = deferred()
await mountProfile(() => pending.promise)
await submit()
expect(buttonIn(dialog()!, 'Deleting…')).toBeDefined()
expect(passwordInput().disabled).toBe(true)
expect(wordInput().disabled).toBe(true)
expect(buttonIn(dialog()!, KEEP)!.disabled).toBe(true)
expect(dialog()!.querySelector('[aria-label="Close"]')).toBeNull()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await flushPromises()
expect(dialog()).not.toBeNull()
pending.resolve(undefined)
await flushPromises()
})
it('Rate limited (429) shows the message and keeps Confirm enabled', async () => {
await mountProfile(() => Promise.reject(new ApiError(429, 300)))
await submit()
expect(dialogText()).toContain(RATE_LIMITED)
expect(confirmButton().disabled).toBe(false)
})
it.each([
['a 500', () => Promise.reject(new ApiError(500))],
['a network failure', () => Promise.reject(new NetworkError())],
])('Server error (%s) shows the message and keeps the values', async (_name, handler) => {
await mountProfile(handler)
await submit()
expect(dialogText()).toContain(SERVER_ERROR)
expect(passwordInput().value).toBe(PASSWORD)
expect(wordInput().value).toBe('DELETE')
expect(confirmButton().disabled).toBe(false)
})
})
describe('Delete account: blocked dialogs', () => {
it('Blocked: last administrator shows the copy and both next steps', async () => {
await mountProfile(refusal(409, 'last_admin', 'last_admin', 'Admin'))
await submit()
const content = dialogText()
expect(content).toContain(BLOCKED_TITLE)
expect(content).toContain(ADMIN_BODY)
expect(buttonIn(dialog()!, 'Back to profile')).toBeDefined()
expect(buttonIn(dialog()!, 'Go to Users')).toBeDefined()
expect(content).not.toContain('Current password')
})
it('Blocked: last Owner names the Owner and the transfer', async () => {
await mountProfile(refusal(409, 'last_admin', 'last_admin', 'Owner'))
await submit()
expect(dialogText()).toContain(BLOCKED_TITLE)
expect(dialogText()).toContain(OWNER_BODY)
expect(buttonIn(dialog()!, 'Go to Users')).toBeDefined()
})
it('Blocked: cannot be deleted offers only Back to profile', async () => {
await mountProfile(refusal(409, 'account_not_deletable', 'account_not_deletable'))
await submit()
expect(dialogText()).toContain(PROTECTED_TITLE)
expect(dialogText()).toContain(PROTECTED_BODY)
expect(buttonIn(dialog()!, 'Back to profile')).toBeDefined()
expect(buttonIn(dialog()!, 'Go to Users')).toBeUndefined()
})
function pressEscape() {
const target = dialog() ?? document.body
target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
}
function clickBackdrop() {
document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }))
document.body.click()
}
it.each([
['Escape', pressEscape],
['a click on the backdrop', clickBackdrop],
])('cannot be dismissed with %s and shows no close button', async (_name, dismiss) => {
await mountProfile(refusal(409, 'last_admin', 'last_admin', 'Admin'))
await submit()
dismiss()
await flushPromises()
expect(dialog()).not.toBeNull()
expect(dialog()!.querySelector('[aria-label="Close"]')).toBeNull()
})
it('the regular dialog does close with Escape, so the dismissal helper is sound', async () => {
await mountProfile()
await openDialog()
pressEscape()
await flushPromises()
expect(dialog()).toBeNull()
})
it('Back to profile closes the dialog', async () => {
await mountProfile(refusal(409, 'last_admin', 'last_admin', 'Admin'))
await submit()
await click(buttonIn(dialog()!, 'Back to profile'))
expect(dialog()).toBeNull()
expect(router.currentRoute.value.path).toBe('/profile')
})
it('Go to Users navigates to /users', async () => {
await mountProfile(refusal(409, 'last_admin', 'last_admin', 'Admin'))
await submit()
await click(buttonIn(dialog()!, 'Go to Users'))
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/users'))
})
})
describe('Delete account: Export my data first', () => {
it('starts the download without closing the dialog', async () => {
await mountProfile()
await openDialog()
await click(buttonIn(dialog()!, 'Export my data first'))
expect(requestFile).toHaveBeenCalledWith('/v1/user/me/export')
expect(downloadBlob).toHaveBeenCalled()
expect(dialog()).not.toBeNull()
expect(deleteCalls()).toHaveLength(0)
})
})
describe('Delete account: success', () => {
it('clears the session without logging out and goes to /login?reason=account-deleted', async () => {
const push = vi.spyOn(router, 'push')
await mountProfile()
await submit()
expect(request.mock.calls.some(([path]) => path === '/v1/auth/logout')).toBe(false)
expect(useSessionStore().session).toBeNull()
expect(push).toHaveBeenCalledWith({ path: '/login', query: { reason: 'account-deleted' } })
})
it('does not open the leave-page confirmation of Profile', async () => {
await mountProfile()
const name = mountedWrapper!.find('input[placeholder="Your username"], input[type="text"]')
await name.setValue('Marta A.')
await submit()
expect(document.body.textContent).not.toContain('Leave without saving?')
expect(router.currentRoute.value.path).toBe('/login')
})
})
describe('Delete account: sessions that end during the call', () => {
it('sends the deletion without the automatic login redirect', async () => {
await mountProfile()
await submit()
expect(deleteCalls()[0]![1].onUnauthorized).toBe('passthrough')
})
it('goes to the expired-session login when the deletion answers 401', async () => {
await mountProfile(() => Promise.reject(new ApiError(401)))
await submit()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
expect(router.currentRoute.value.query.reason).toBe('expired')
})
it('keeps the account-deleted login when a stray 401 redirect fires afterwards', async () => {
await mountProfile()
await submit()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
await flushPromises()
redirectToLogin()
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/login')
expect(document.body.textContent).toContain('Your account has been deleted')
})
it('survives a navigation failure after the deletion', async () => {
await mountProfile()
const push = vi.spyOn(router, 'push').mockRejectedValue(new Error('navigation failed'))
await submit()
expect(push).toHaveBeenCalled()
expect(useSessionStore().session).toBeNull()
expect(authRedirectPending.value).toBe(false)
})
})
@@ -0,0 +1,410 @@
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'
import { accessFor } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: toastAdd }) }
})
const OLD = 'marta@renew.example'
const NEW = 'marta.alves@atelier-renew.example'
const CHANGE_PATH = '/v1/auth/email/change'
const RESEND_PATH = '/v1/auth/email/resend'
const SERVER_COPY = 'Something went wrong on our side. Nothing was changed. Try again in a moment.'
const RATE_COPY = 'Too many requests. Try again in a few minutes.'
const MARTA: Session = {
id: 1,
verified: true,
access: accessFor('noschema'),
username: 'marta',
email: OLD,
displayName: '',
}
type Handler = (init?: RequestInit) => Promise<unknown>
function deferred<T = unknown>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
let mountedWrapper: VueWrapper | null = null
async function mountProfile(session: Session = MARTA, handlers: Record<string, Handler> = {}) {
setViewportWidth(DESKTOP_WIDTH)
setActivePinia(createPinia())
const store = useSessionStore()
store.session = { ...session }
store.status = 'ready'
const request = vi.fn((path: string, init?: RequestInit) => {
const handler = handlers[`${init?.method ?? 'GET'} ${path}`]
return handler ? handler(init) : Promise.resolve([])
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn(() => Promise.resolve({ ...session })),
request,
} as unknown as ReturnType<typeof useApi>)
await router.push('/profile')
mountedWrapper = mount(App, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return { request, store }
}
function button(label: string) {
return Array.from(document.body.querySelectorAll('button')).find(
(candidate) => candidate.textContent?.trim() === label,
) as HTMLButtonElement | undefined
}
function field(label: string) {
const labelEl = Array.from(document.body.querySelectorAll('label')).find(
(candidate) => candidate.textContent?.trim() === label,
)
return document.getElementById(labelEl!.getAttribute('for')!) as HTMLInputElement
}
async function type(input: HTMLInputElement, value: string) {
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
async function click(target: HTMLElement | undefined) {
target!.click()
await flushPromises()
}
async function openForm() {
await click(button('Change email'))
}
async function fillAndSend(email: string, password: string) {
await type(field('New email address'), email)
await type(field('Current password'), password)
await click(button('Send verification link'))
}
function text() {
return document.body.textContent ?? ''
}
function posts(request: ReturnType<typeof vi.fn>, path: string) {
return request.mock.calls.filter(
([requestPath, init]) =>
requestPath === path && (init as RequestInit | undefined)?.method === 'POST',
)
}
function toastTexts() {
return toastAdd.mock.calls.map(([source]) => {
const content = typeof source === 'function' ? source() : source
return [content.title, content.description]
})
}
const sent = () => Promise.resolve({ pending_email: NEW })
beforeEach(() => {
toastAdd = vi.fn()
localStorage.clear()
authRedirectPending.value = false
})
afterEach(async () => {
vi.useRealTimers()
mountedWrapper?.unmount()
mountedWrapper = null
document.body.innerHTML = ''
await router.push('/login')
})
describe('Change email: Default', () => {
it('shows the Email field and the Change email link, no form and no request', async () => {
const { request } = await mountProfile()
expect(field('Email').value).toBe(OLD)
expect(button('Change email')).toBeDefined()
expect(document.body.querySelector('[data-testid="pending-email-notice"]')).toBeNull()
expect(posts(request, CHANGE_PATH)).toHaveLength(0)
})
})
describe('Change email: Form open', () => {
it('shows both fields, the hint and both buttons with focus on the email field', async () => {
await mountProfile()
await openForm()
expect(text()).toContain('New email address')
expect(text()).toContain('Current password')
expect(text()).toContain(
"We'll send a verification link to the new address. Your email stays the same until you open it.",
)
expect(button('Cancel')).toBeDefined()
expect(button('Send verification link')).toBeDefined()
expect(button('Change email')).toBeUndefined()
expect(document.activeElement).toBe(field('New email address'))
})
it('Cancel closes the form and brings the link back', async () => {
await mountProfile()
await openForm()
await click(button('Cancel'))
expect(button('Change email')).toBeDefined()
expect(button('Send verification link')).toBeUndefined()
})
})
describe('Change email: Invalid format', () => {
it('flags a malformed address on blur and sends nothing', async () => {
const { request } = await mountProfile()
await openForm()
const input = field('New email address')
await type(input, 'marta.alves@atelier')
input.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
input.dispatchEvent(new FocusEvent('blur'))
await flushPromises()
expect(text()).toContain('Enter a full email address, like name@example.com.')
expect(posts(request, CHANGE_PATH)).toHaveLength(0)
})
it('flags it on submit', async () => {
const { request } = await mountProfile()
await openForm()
await fillAndSend('marta.alves@atelier', 'limewash2026')
expect(text()).toContain('Enter a full email address, like name@example.com.')
expect(posts(request, CHANGE_PATH)).toHaveLength(0)
})
})
describe('Change email: Same as current', () => {
it('refuses the current address, whatever its case, before any request', async () => {
const { request } = await mountProfile()
await openForm()
await fillAndSend('Marta@Renew.example', 'limewash2026')
expect(text()).toContain("That's already your email address.")
expect(posts(request, CHANGE_PATH)).toHaveLength(0)
})
})
describe('Change email: Password missing', () => {
it('asks for the password and focuses it without sending', async () => {
const { request } = await mountProfile()
await openForm()
await fillAndSend(NEW, '')
expect(text()).toContain('Enter your current password.')
expect(document.activeElement).toBe(field('Current password'))
expect(posts(request, CHANGE_PATH)).toHaveLength(0)
})
})
describe('Change email: Wrong password (403)', () => {
it('shows the error under the password, clears and focuses it, after one POST', async () => {
const { request } = await mountProfile(MARTA, {
[`POST ${CHANGE_PATH}`]: () =>
Promise.reject(new ApiError(403, undefined, 'wrong_password')),
})
await openForm()
await fillAndSend(NEW, 'nope')
expect(text()).toContain("That password isn't correct. Try again.")
expect(field('Current password').value).toBe('')
expect(document.activeElement).toBe(field('Current password'))
expect(field('New email address').value).toBe(NEW)
expect(posts(request, CHANGE_PATH)).toHaveLength(1)
})
})
describe('Change email: Sending', () => {
it('disables the fields and the button and reads Sending… while the POST is open', async () => {
const pending = deferred()
const { request } = await mountProfile(MARTA, {
[`POST ${CHANGE_PATH}`]: () => pending.promise,
})
await openForm()
await fillAndSend(NEW, 'limewash2026')
expect(button('Sending…')!.disabled).toBe(true)
expect(field('New email address').disabled).toBe(true)
expect(field('Current password').disabled).toBe(true)
expect(posts(request, CHANGE_PATH)).toHaveLength(1)
const [, init] = posts(request, CHANGE_PATH)[0]!
expect(JSON.parse((init as RequestInit).body as string)).toEqual({
new_email: NEW,
current_password: 'limewash2026',
})
})
})
describe('Change email: Rate limited (429)', () => {
it('shows the rate copy, keeps the form and disables Send verification link', async () => {
const { request } = await mountProfile(MARTA, {
[`POST ${CHANGE_PATH}`]: () => Promise.reject(new ApiError(429, 120)),
})
await openForm()
await fillAndSend(NEW, 'limewash2026')
expect(text()).toContain(RATE_COPY)
expect(button('Send verification link')!.disabled).toBe(true)
expect(field('New email address').value).toBe(NEW)
expect(posts(request, CHANGE_PATH)).toHaveLength(1)
})
})
describe('Change email: Server or network error', () => {
it.each([
['a 500', () => Promise.reject(new ApiError(500))],
['a network failure', () => Promise.reject(new NetworkError())],
])('after %s shows the copy and keeps the typed values', async (_label, failure) => {
const { request } = await mountProfile(MARTA, { [`POST ${CHANGE_PATH}`]: failure })
await openForm()
await fillAndSend(NEW, 'limewash2026')
expect(text()).toContain(SERVER_COPY)
expect(field('New email address').value).toBe(NEW)
expect(field('Current password').value).toBe('limewash2026')
expect(button('Send verification link')!.disabled).toBe(false)
expect(posts(request, CHANGE_PATH)).toHaveLength(1)
})
})
describe('Change email: Pending', () => {
it('closes the form, keeps the old email and shows the pending card and a toast', async () => {
await mountProfile(MARTA, { [`POST ${CHANGE_PATH}`]: sent })
await openForm()
await fillAndSend(NEW, 'limewash2026')
expect(button('Send verification link')).toBeUndefined()
expect(field('Email').value).toBe(OLD)
expect(text()).toContain('Waiting for verification')
expect(text()).toContain(
`We sent a link to ${NEW}. Until you open it, you keep logging in with ${OLD}.`,
)
expect(button('Resend link')).toBeDefined()
expect(button('Cancel change')).toBeDefined()
expect(button('Change email')).toBeUndefined()
expect(toastTexts()).toEqual([
['Verification link sent', `Check ${NEW}. The link works once, within 2 hours.`],
])
})
it('shows the same card after a reload with pending_email set, without a toast', async () => {
await mountProfile({ ...MARTA, pendingEmail: NEW })
expect(text()).toContain('Waiting for verification')
expect(text()).toContain(NEW)
expect(toastAdd).not.toHaveBeenCalled()
})
})
describe('Change email: Pending: resending, resent', () => {
it('shows Sending… with the button disabled while the resend is in flight', async () => {
const pending = deferred()
const { request } = await mountProfile(
{ ...MARTA, pendingEmail: NEW },
{ [`POST ${RESEND_PATH}`]: () => pending.promise },
)
await click(button('Resend link'))
expect(button('Sending…')!.disabled).toBe(true)
expect(posts(request, RESEND_PATH)).toHaveLength(1)
})
it('raises the resent toast on a 200', async () => {
await mountProfile({ ...MARTA, pendingEmail: NEW }, { [`POST ${RESEND_PATH}`]: sent })
await click(button('Resend link'))
expect(button('Resend link')!.disabled).toBe(false)
expect(toastTexts()).toEqual([['Link sent again', 'The previous link no longer works.']])
})
})
describe('Change email: Pending: resend rate limited (429)', () => {
it('counts down from Retry-After, not the wall clock', async () => {
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
await mountProfile(
{ ...MARTA, pendingEmail: NEW },
{ [`POST ${RESEND_PATH}`]: () => Promise.reject(new ApiError(429, 270)) },
)
await click(button('Resend link'))
expect(button('Resend in 4:30')!.disabled).toBe(true)
expect(text()).toContain(RATE_COPY)
vi.advanceTimersByTime(30_000)
await flushPromises()
expect(button('Resend in 4:00')).toBeDefined()
vi.advanceTimersByTime(240_000)
await flushPromises()
expect(button('Resend link')!.disabled).toBe(false)
expect(text()).not.toContain(RATE_COPY)
})
})
describe('Change email: Cancelled', () => {
it('deletes without confirmation, returns to Default and raises the cancelled toast', async () => {
const { request } = await mountProfile(
{ ...MARTA, pendingEmail: NEW },
{ [`DELETE ${CHANGE_PATH}`]: () => Promise.resolve(undefined) },
)
await click(button('Cancel change'))
expect(
request.mock.calls.filter(
([path, init]) =>
path === CHANGE_PATH && (init as RequestInit | undefined)?.method === 'DELETE',
),
).toHaveLength(1)
expect(button('Change email')).toBeDefined()
expect(text()).not.toContain('Waiting for verification')
expect(toastTexts()).toEqual([
['Email change cancelled', `You keep logging in with ${OLD}.`],
])
})
})
+2 -1
View File
@@ -312,7 +312,7 @@ describe('Default: the usual first visit with no display name', () => {
links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull())
})
it('places the cards in order Display name, Account, Your role, Author profile, Password', async () => {
it('places the cards in order Display name, Account, Your role, Author profile, Password, Your data', async () => {
await mountApp()
const titles = Array.from(document.body.querySelectorAll('section h2')).map((el) =>
@@ -324,6 +324,7 @@ describe('Default: the usual first visit with no display name', () => {
'Your role',
'Author profile',
'Password',
'Your data',
])
})
})