// Phase 12.1 form seams in the admin SPA: the password field (UI-SPEC S7, // D-19) and preset fields (D-27 G7). Fixtures are neutral acme.roster.* data; // no application names appear in framework tests. import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils' import { editablePayload, presetValue } from '../../src/components/form/formState' import { setBundle } from '../../src/app/i18n' import { clone, langFixture, rosterFormSchemaFixture, rosterRecordFixture } from '../fixtures/typed' import { API, mountApp, requestsTo, resetState, type Reply, type Route } from '../helpers' const LIST = `${API}/acme/roster/people` const RECORD = `${LIST}/1` // The framework strings these controls use, as GET /lang serves them in en. const strings = { 'backend::lang.form.show_password': { other: 'Show password' }, 'backend::lang.form.hide_password': { other: 'Hide password' }, } function routes(overrides: Record = {}): Record { return { [`GET ${LIST}/schema/form`]: { body: rosterFormSchemaFixture }, [`GET ${RECORD}`]: { body: rosterRecordFixture }, [`GET ${LIST}/partials/status`]: { body: { data: { nodes: [] }, meta: {} } }, ...overrides, } } const input = (wrapper: VueWrapper, name: string) => wrapper.find(`#field-${name}`) const toggle = (wrapper: VueWrapper, name: string) => wrapper.find(`[data-field="${name}"] [data-password-toggle]`) async function save(wrapper: VueWrapper): Promise { await wrapper.find('[data-action="save"]').trigger('click') await flushPromises() } beforeEach(() => { resetState() setBundle({ ...langFixture.data, ...strings }, 'en') }) afterEach(() => { document.body.innerHTML = '' }) enableAutoUnmount(afterEach) describe('password field (UI-SPEC S7, D-19)', () => { it('renders empty and masked although the record is loaded, with the toggle after the input', async () => { // The record response carries no password, as on the server. const { wrapper } = await mountApp('/acme/roster/people/1', routes()) const password = input(wrapper, 'password') expect(password.element.value).toBe('') expect(password.attributes('type')).toBe('password') expect(password.attributes('autocomplete')).toBe('new-password') expect(password.attributes('spellcheck')).toBe('false') expect(password.classes()).toEqual(expect.arrayContaining(['h-input', 'pr-12'])) expect(input(wrapper, 'password_confirmation').element.value).toBe('') const button = toggle(wrapper, 'password') expect(button.attributes('type')).toBe('button') expect(button.attributes('aria-pressed')).toBe('false') expect(button.attributes('aria-label')).toBe('Show password') // The toggle follows the input in the DOM, so Tab reaches it second. expect(password.element.compareDocumentPosition(button.element) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() }) it('shows and hides the text with the toggle', async () => { const { wrapper } = await mountApp('/acme/roster/people/1', routes()) await input(wrapper, 'password').setValue('correct horse') await toggle(wrapper, 'password').trigger('click') expect(input(wrapper, 'password').attributes('type')).toBe('text') expect(input(wrapper, 'password').element.value).toBe('correct horse') expect(toggle(wrapper, 'password').attributes('aria-pressed')).toBe('true') expect(toggle(wrapper, 'password').attributes('aria-label')).toBe('Hide password') // The confirmation has its own toggle and stays masked. expect(input(wrapper, 'password_confirmation').attributes('type')).toBe('password') await toggle(wrapper, 'password').trigger('click') expect(input(wrapper, 'password').attributes('type')).toBe('password') }) it('leaves an empty password out of the update body', async () => { const { wrapper, calls } = await mountApp('/acme/roster/people/1', routes({ [`PUT ${RECORD}`]: { body: rosterRecordFixture } })) await input(wrapper, 'name').setValue('Ada B') // Typed and removed again: still "unchanged". await input(wrapper, 'password').setValue('x') await input(wrapper, 'password').setValue('') await save(wrapper) const [request] = requestsTo(calls, 'PUT', RECORD) const body = (await request!.clone().json()) as Record expect(body.name).toBe('Ada B') expect('password' in body).toBe(false) expect('password_confirmation' in body).toBe(false) // notify is a create-only field and joined_ip a preview-only one. expect('notify' in body).toBe(false) expect('joined_ip' in body).toBe(false) }) it('sends the password pair as entered and clears and hides both after a successful save', async () => { const { wrapper, calls } = await mountApp('/acme/roster/people/1', routes({ [`PUT ${RECORD}`]: { body: rosterRecordFixture } })) await input(wrapper, 'password').setValue('correct horse') await input(wrapper, 'password_confirmation').setValue('correct horse') await toggle(wrapper, 'password').trigger('click') await toggle(wrapper, 'password_confirmation').trigger('click') await save(wrapper) const [request] = requestsTo(calls, 'PUT', RECORD) const body = (await request!.clone().json()) as Record expect(body.password).toBe('correct horse') expect(body.password_confirmation).toBe('correct horse') for (const name of ['password', 'password_confirmation']) { expect(input(wrapper, name).element.value).toBe('') expect(input(wrapper, name).attributes('type')).toBe('password') expect(toggle(wrapper, name).attributes('aria-pressed')).toBe('false') } // The password never reaches a toast or the URL. expect(document.body.textContent).not.toContain('correct horse') expect(window.location.href).not.toContain('correct') }) it('sends a lone password as entered and shows the server 422 on the field', async () => { const refused: Reply = { status: 422, body: { error: { code: 'validation_failed', message: 'Validation failed', details: { password: ['The password confirmation does not match.'] }, }, }, } const { wrapper, calls } = await mountApp('/acme/roster/people/1', routes({ [`PUT ${RECORD}`]: refused }), { attach: true }) await input(wrapper, 'password').setValue('correct horse') await save(wrapper) const [request] = requestsTo(calls, 'PUT', RECORD) const body = (await request!.clone().json()) as Record // The SPA does not compare the two fields: the save goes out as entered. expect(body.password).toBe('correct horse') expect('password_confirmation' in body).toBe(false) expect(wrapper.find('[data-field="password"]').text()).toContain('The password confirmation does not match.') expect(input(wrapper, 'password').attributes('aria-invalid')).toBe('true') // A refused save keeps what was typed. expect(input(wrapper, 'password').element.value).toBe('correct horse') }) it('keeps an empty password in a create body and drops it only on update', () => { const fields = rosterFormSchemaFixture.data.fields.filter((field) => field.type === 'password') expect(editablePayload(fields, { password: '' }, 'create')).toEqual({ password: '' }) expect(editablePayload(fields, { password: '' }, 'update')).toEqual({}) expect(editablePayload(fields, { password: 'abc' }, 'update')).toEqual({ password: 'abc' }) }) }) describe('preset (D-27 G7)', () => { it('slugs lower-case ASCII with single hyphens and no cut', () => { expect(presetValue('slug', 'Ada Lovelace')).toBe('ada-lovelace') expect(presetValue('slug', ' Hello, World!! ')).toBe('hello-world') expect(presetValue('slug', 'Zażółć 42')).toBe('za-42') expect(presetValue('slug', '')).toBe('') expect(presetValue('exact', 'Ada Lovelace')).toBe('Ada Lovelace') const long = 'word '.repeat(80).trim() expect(presetValue('slug', long)).toHaveLength(long.length) }) it('fills the target from the source on create until the target is edited by hand', async () => { const { wrapper } = await mountApp('/acme/roster/people/create', routes()) expect(input(wrapper, 'slug').element.value).toBe('') await input(wrapper, 'name').setValue('Grace Hopper') expect(input(wrapper, 'slug').element.value).toBe('grace-hopper') await input(wrapper, 'name').setValue('Grace B. Hopper') expect(input(wrapper, 'slug').element.value).toBe('grace-b-hopper') // An empty source leaves the target empty. await input(wrapper, 'name').setValue('') expect(input(wrapper, 'slug').element.value).toBe('') // The first manual edit stops it for the session. await input(wrapper, 'slug').setValue('admiral') await input(wrapper, 'name').setValue('Grace Hopper') expect(input(wrapper, 'slug').element.value).toBe('admiral') // The target is an ordinary input with no marker. expect(input(wrapper, 'slug').attributes('readonly')).toBeUndefined() }) it('sends the preset value with the create body', async () => { const created = clone(rosterRecordFixture) created.data.id = 7 const { wrapper, calls } = await mountApp('/acme/roster/people/create', routes({ [`POST ${LIST}`]: { status: 201, body: created }, [`GET ${LIST}/7`]: { body: created } })) await input(wrapper, 'name').setValue('Grace Hopper') await save(wrapper) const [request] = requestsTo(calls, 'POST', LIST) const body = (await request!.clone().json()) as Record expect(body.slug).toBe('grace-hopper') expect(body.notify).toBe(true) }) it('does not follow the source on update', async () => { const { wrapper } = await mountApp('/acme/roster/people/1', routes()) expect(input(wrapper, 'slug').element.value).toBe('ada-lovelace') await input(wrapper, 'name').setValue('Ada King') expect(input(wrapper, 'slug').element.value).toBe('ada-lovelace') }) })