Files
summercms/admin/tests/views/AccountView.test.ts
Jakub Zych 5a6a190e7f feat(16.3-02): tracer — API tokens card on My account with show-once create
Wire ApiTokensPanel after the profile form, add the two-step create dialog,
phrase keys, smoke tracer, and rebuilt boardwalk dist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 02:02:44 +02:00

246 lines
11 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
import AccountView from '../../src/views/AccountView.vue'
import { currentUser } from '../../src/state/useAuth'
import { toasts } from '../../src/state/useToasts'
import { API, mountApp, profile, requestsTo, resetState, type Route } from '../helpers'
const ACCOUNT = `${API}/account`
const AVATAR = `${API}/account/avatar`
const signedIn = {
...profile.data,
first_name: 'Dana',
last_name: 'Dev',
subtitle: 'Editor',
}
const avatar = { url: '/storage/uploads/abc/def/ghi/abcdefghi.png', thumb_url: '/storage/uploads/abc/def/ghi/thumb_9_250_250_0_0_crop.png' }
const envelope = (data: unknown) => ({ body: { data, meta: {} } })
const invalid = (details: Record<string, string[]>) => ({
status: 422,
body: { error: { code: 'validation_failed', message: 'Validation failed', details } },
})
async function openAccount(user: Record<string, unknown> = signedIn, routes: Record<string, Route> = {}) {
return mountApp(
'/account',
{
[`GET ${API}/auth/me`]: envelope(user),
[`GET ${API}/account/tokens`]: envelope([]),
...routes,
},
{ attach: true },
)
}
function input(wrapper: VueWrapper, name: string) {
return wrapper.find<HTMLInputElement>(`#account-${name}`)
}
async function typeInto(wrapper: VueWrapper, name: string, value: string): Promise<void> {
await input(wrapper, name).setValue(value)
}
async function save(wrapper: VueWrapper): Promise<void> {
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
}
async function bodyOf(request: Request): Promise<Record<string, unknown>> {
return (await request.clone().json()) as Record<string, unknown>
}
async function pickFile(wrapper: VueWrapper, file: File): Promise<void> {
const element = wrapper.find<HTMLInputElement>('[data-account-avatar-input]')
Object.defineProperty(element.element, 'files', { value: [file], configurable: true })
await element.trigger('change')
await flushPromises()
}
const dialog = () => document.body.querySelector<HTMLElement>('[data-confirm-dialog]')
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('My account', () => {
it('renders the signed-in profile with the login read-only', async () => {
const { wrapper } = await openAccount()
expect(wrapper.findComponent(AccountView).exists()).toBe(true)
expect(wrapper.find('h1').text()).toBe('Moje konto')
const login = input(wrapper, 'login')
expect(login.element.value).toBe('dev')
expect(login.attributes('readonly')).toBeDefined()
expect(input(wrapper, 'first_name').element.value).toBe('Dana')
expect(input(wrapper, 'last_name').element.value).toBe('Dev')
expect(input(wrapper, 'email').element.value).toBe('dev@example.test')
expect(input(wrapper, 'email').attributes('type')).toBe('email')
expect(input(wrapper, 'subtitle').element.value).toBe('Editor')
expect(input(wrapper, 'subtitle').attributes('aria-describedby')).toBe('account-subtitle-comment')
expect(input(wrapper, 'current_password').attributes('autocomplete')).toBe('current-password')
expect(input(wrapper, 'password').attributes('autocomplete')).toBe('new-password')
expect(input(wrapper, 'password_confirmation').attributes('autocomplete')).toBe('new-password')
expect(input(wrapper, 'password').element.value).toBe('')
expect(wrapper.find('[data-account-avatar-initials]').text()).toBe('DD')
expect(wrapper.find('[data-action="avatar-remove"]').exists()).toBe(false)
})
it('sends only the profile keys when no new password is typed', async () => {
const { wrapper, calls } = await openAccount(signedIn, {
[`PUT ${ACCOUNT}`]: envelope({ ...signedIn, first_name: 'Grace', subtitle: 'Admiral' }),
})
await typeInto(wrapper, 'first_name', 'Grace')
await typeInto(wrapper, 'subtitle', 'Admiral')
await typeInto(wrapper, 'current_password', 'typed-but-unused')
await save(wrapper)
const [request] = requestsTo(calls, 'PUT', ACCOUNT)
expect(request!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
expect(await bodyOf(request!)).toEqual({
first_name: 'Grace',
last_name: 'Dev',
email: 'dev@example.test',
subtitle: 'Admiral',
})
expect(currentUser.value?.first_name).toBe('Grace')
expect(currentUser.value?.subtitle).toBe('Admiral')
expect(toasts.value[0]).toMatchObject({ text: 'Zapisano konto', tone: 'success' })
})
it('sends the password keys with a new password, updates the top bar and clears the inputs', async () => {
const { wrapper, calls } = await openAccount(signedIn, {
[`PUT ${ACCOUNT}`]: envelope({ ...signedIn, first_name: 'Grace', last_name: 'Hopper' }),
})
await typeInto(wrapper, 'first_name', 'Grace')
await typeInto(wrapper, 'last_name', 'Hopper')
await typeInto(wrapper, 'current_password', 'old-secret')
await typeInto(wrapper, 'password', 'new-secret')
await typeInto(wrapper, 'password_confirmation', 'new-secret')
await save(wrapper)
const [request] = requestsTo(calls, 'PUT', ACCOUNT)
expect(await bodyOf(request!)).toEqual({
first_name: 'Grace',
last_name: 'Hopper',
email: 'dev@example.test',
subtitle: 'Editor',
current_password: 'old-secret',
password: 'new-secret',
password_confirmation: 'new-secret',
})
expect(input(wrapper, 'current_password').element.value).toBe('')
expect(input(wrapper, 'password').element.value).toBe('')
expect(input(wrapper, 'password_confirmation').element.value).toBe('')
expect(wrapper.find('[data-user-identity]').text()).toContain('Grace Hopper')
expect(wrapper.find('[data-user-initials]').text()).toBe('GH')
})
it('shows 422 errors on their fields, focuses the first and clears an error on input', async () => {
const { wrapper } = await openAccount(signedIn, {
[`PUT ${ACCOUNT}`]: invalid({
current_password: ['Obecne hasło jest nieprawidłowe.'],
email: ['The email has already been taken.'],
role_id: ['Tego pola nie można zmienić w ustawieniach konta.'],
}),
})
await typeInto(wrapper, 'password', 'new-secret')
await save(wrapper)
expect(wrapper.find('[data-error-banner]').exists()).toBe(true)
expect(wrapper.find('[data-error-banner]').text()).toContain('Tego pola nie można zmienić')
const email = input(wrapper, 'email')
expect(email.attributes('aria-invalid')).toBe('true')
expect(email.attributes('aria-describedby')).toBe('account-email-error')
expect(wrapper.find('#account-email-error').text()).toBe('The email has already been taken.')
expect(wrapper.find('#account-current_password-error').text()).toBe('Obecne hasło jest nieprawidłowe.')
expect(document.activeElement).toBe(email.element)
expect(currentUser.value?.first_name).toBe('Dana')
await typeInto(wrapper, 'email', 'other@example.test')
expect(wrapper.find('#account-email-error').exists()).toBe(false)
expect(wrapper.find('#account-current_password-error').exists()).toBe(true)
})
it('toasts the generic error on any other failure', async () => {
const { wrapper } = await openAccount(signedIn, {
[`PUT ${ACCOUNT}`]: { status: 500, body: { error: { code: 'error', message: 'Server error', details: {} } } },
})
await save(wrapper)
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się zapisać zmian. Spróbuj ponownie.', tone: 'danger' })
expect(wrapper.find('[data-error-banner]').exists()).toBe(false)
})
it('uploads the avatar as one file_data part and shows it in the top bar', async () => {
const posted: string[] = []
const { wrapper, calls } = await openAccount(signedIn, {
[`POST ${AVATAR}`]: async (request) => {
const form = await request.formData()
const file = form.get('file_data') as File
posted.push([...form.keys()].join(','), file.name, await file.text())
return envelope({ ...signedIn, avatar })
},
})
await pickFile(wrapper, new File(['png-bytes'], 'me.png', { type: 'image/png' }))
const [request] = requestsTo(calls, 'POST', AVATAR)
expect(request!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
expect(request!.headers.get('Content-Type')).toMatch(/^multipart\/form-data; boundary=/)
expect(posted).toEqual(['file_data', 'me.png', 'png-bytes'])
expect(currentUser.value?.avatar).toEqual(avatar)
expect(wrapper.find('[data-account-avatar-image]').attributes('src')).toBe(avatar.thumb_url)
expect(wrapper.find('[data-user-avatar]').attributes('src')).toBe(avatar.thumb_url)
expect(wrapper.find('[data-user-initials]').exists()).toBe(false)
expect(toasts.value[0]).toMatchObject({ text: 'Zapisano awatar', tone: 'success' })
expect(wrapper.find('[data-account-avatar-input]').attributes('accept')).toBe('image/jpeg,image/png,image/gif,image/webp')
})
it('shows the avatar 422 detail and the hint on 413', async () => {
let status = 422
const { wrapper } = await openAccount(signedIn, {
[`POST ${AVATAR}`]: () =>
status === 422
? invalid({ avatar: ['The avatar must be an image.'] })
: { status: 413, body: { error: { code: 'payload_too_large', message: 'Payload too large', details: {} } } },
})
await pickFile(wrapper, new File(['<svg/>'], 'logo.svg', { type: 'image/svg+xml' }))
expect(wrapper.find('[data-avatar-error]').text()).toBe('The avatar must be an image.')
expect(currentUser.value?.avatar).toBeUndefined()
status = 413
await pickFile(wrapper, new File(['x'], 'big.png', { type: 'image/png' }))
expect(wrapper.find('[data-avatar-error]').text()).toBe('JPG, PNG, GIF lub WebP, maksymalnie 2 MB.')
})
it('removes the avatar after a confirmation', async () => {
const { wrapper, calls } = await openAccount({ ...signedIn, avatar }, {
[`DELETE ${AVATAR}`]: envelope(signedIn),
})
expect(wrapper.find('[data-account-avatar-image]').attributes('src')).toBe(avatar.thumb_url)
await wrapper.find('[data-action="avatar-remove"]').trigger('click')
await flushPromises()
expect(dialog()!.textContent).toContain('Usunąć awatar?')
dialog()!.querySelector<HTMLButtonElement>('[data-action="cancel"]')!.click()
await flushPromises()
expect(requestsTo(calls, 'DELETE', AVATAR)).toHaveLength(0)
await wrapper.find('[data-action="avatar-remove"]').trigger('click')
await flushPromises()
dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.click()
await flushPromises()
expect(requestsTo(calls, 'DELETE', AVATAR)).toHaveLength(1)
expect(currentUser.value?.avatar).toBeUndefined()
expect(wrapper.find('[data-account-avatar-initials]').text()).toBe('DD')
expect(wrapper.find('[data-action="avatar-remove"]').exists()).toBe(false)
expect(toasts.value[0]).toMatchObject({ text: 'Usunięto awatar', tone: 'success' })
})
it('names the page in the breadcrumbs and the document title', async () => {
const { wrapper } = await openAccount()
expect(wrapper.find('[data-crumb]').text()).toBe('Moje konto')
expect(document.title).toBe('Moje konto | SummerCMS')
})
})