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) => ({ status: 422, body: { error: { code: 'validation_failed', message: 'Validation failed', details } }, }) async function openAccount(user: Record = signedIn, routes: Record = {}) { return mountApp('/account', { [`GET ${API}/auth/me`]: envelope(user), ...routes }, { attach: true }) } function input(wrapper: VueWrapper, name: string) { return wrapper.find(`#account-${name}`) } async function typeInto(wrapper: VueWrapper, name: string, value: string): Promise { await input(wrapper, name).setValue(value) } async function save(wrapper: VueWrapper): Promise { await wrapper.find('[data-action="save"]').trigger('click') await flushPromises() } async function bodyOf(request: Request): Promise> { return (await request.clone().json()) as Record } async function pickFile(wrapper: VueWrapper, file: File): Promise { const element = wrapper.find('[data-account-avatar-input]') Object.defineProperty(element.element, 'files', { value: [file], configurable: true }) await element.trigger('change') await flushPromises() } const dialog = () => document.body.querySelector('[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([''], '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('[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('[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') }) })