- account_internal_test: metadata subtitle reader and writer (NULL, blank, null, [], invalid JSON, non-objects, key preservation, NULL on the last key), the attachOne contract, avatar cap, unique-violation mapping, English fallbacks, and 401 for non-backend principals on all three routes - account_test (Postgres): profile payload incl. copied WinterCMS metadata and avatar rows, trimmed profile saves and metadata keys, the forbidden-key allow-list with unchanged rows, email rules, password rules, the failed attempt log, Bearer and cookie session effects, CSRF, avatar upload, refusals (SVG, HTML as PNG, size, part shape, 413, no bucket) with no row or blob left, replace and remove with blob cleanup - lagoon: TestBackendAdminMetadataColumn covers type, Winter row round trip, rollback of only the column and re-migrate - SPA: AccountView, the user menu account item and avatar image, setCurrentUser and profileInitials; lang fixture gains myaccount keys
238 lines
11 KiB
TypeScript
238 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), ...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')
|
|
})
|
|
})
|