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>
246 lines
11 KiB
TypeScript
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')
|
|
})
|
|
})
|