diff --git a/admin/tests/account/ApiTokenCreateDialog.test.ts b/admin/tests/account/ApiTokenCreateDialog.test.ts index af27468..de50230 100644 --- a/admin/tests/account/ApiTokenCreateDialog.test.ts +++ b/admin/tests/account/ApiTokenCreateDialog.test.ts @@ -1,7 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { enableAutoUnmount, flushPromises } from '@vue/test-utils' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import ApiTokenCreateDialog from '../../src/components/account/ApiTokenCreateDialog.vue' +import { useConfirm } from '../../src/components/ui/confirm' import { toasts } from '../../src/state/useToasts' -import { API, keydown, mountApp, requestsTo, resetState, type Reply, type Route } from '../helpers' +import { API, keydown, mockApi, mountApp, requestsTo, resetState, type Reply, type Route } from '../helpers' const TOKENS = `${API}/account/tokens` const SCOPES = `${API}/account/tokens/scopes` @@ -452,3 +454,41 @@ describe('ApiTokenCreateDialog step 2', () => { ).toBe(true) }) }) + +describe('ApiTokenCreateDialog mounted directly', () => { + // The panel mounts the dialog closed and opens it; opening loads the scopes. + async function openDialog() { + const calls = mockApi(tokenRoutes()) + const wrapper = mount(ApiTokenCreateDialog, { + props: { open: false, confirm: useConfirm() }, + attachTo: document.body, + }) + await wrapper.setProps({ open: true }) + await flushPromises() + return { wrapper, calls } + } + + it('opens with the scopes from one GET', async () => { + const { calls } = await openDialog() + expect(dialog()).toBeTruthy() + expect(requestsTo(calls, 'GET', SCOPES)).toHaveLength(1) + expect(dialog()!.querySelector('[data-scope="*"]')).toBeTruthy() + expect(dialog()!.querySelector('[data-scope="admin:read"]')).toBeTruthy() + }) + + it('creates a token with one POST, shows the plaintext and emits created', async () => { + const { wrapper, calls } = await openDialog() + await fillName('CI runner') + await pickAdminRead() + await click('[data-action="create-token-submit"]', dialog()!) + + const posts = requestsTo(calls, 'POST', TOKENS) + expect(posts).toHaveLength(1) + expect(await posts[0]!.clone().json()).toEqual({ name: 'CI runner', expires: '90d', scopes: ['admin:read'] }) + expect(dialog()!.querySelector('[data-token-plaintext]')!.value).toBe(PLAIN) + expect(wrapper.emitted('created')).toHaveLength(1) + + await click('[data-action="hide-token"]', dialog()!) + expect(wrapper.emitted('update:open')).toEqual([[false]]) + }) +}) diff --git a/admin/tests/account/ApiTokensPanel.test.ts b/admin/tests/account/ApiTokensPanel.test.ts index 0276915..4dc2006 100644 --- a/admin/tests/account/ApiTokensPanel.test.ts +++ b/admin/tests/account/ApiTokensPanel.test.ts @@ -1,7 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { enableAutoUnmount, flushPromises } from '@vue/test-utils' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import ApiTokensPanel from '../../src/components/account/ApiTokensPanel.vue' +import { useConfirm } from '../../src/components/ui/confirm' import { toasts } from '../../src/state/useToasts' -import { API, keydown, mountApp, queryOf, requestsTo, resetState, type Reply, type Route } from '../helpers' +import { API, keydown, mockApi, mountApp, queryOf, requestsTo, resetState, type Reply, type Route } from '../helpers' const TOKENS = `${API}/account/tokens` const SCOPES = `${API}/account/tokens/scopes` @@ -471,3 +473,38 @@ describe('ApiTokensPanel', () => { }) }) }) + +describe('ApiTokensPanel mounted directly', () => { + async function mountPanel(list: Route) { + const calls = mockApi({ [`GET ${TOKENS}`]: list, [`GET ${SCOPES}`]: envelope(scopes) }) + const wrapper = mount(ApiTokensPanel, { props: { confirm: useConfirm() }, attachTo: document.body }) + await flushPromises() + return { wrapper, calls } + } + + it('lists the tokens from one GET', async () => { + const { wrapper, calls } = await mountPanel(envelope([existingToken])) + const row = wrapper.find('[data-token-row="11"]') + expect(row.exists()).toBe(true) + expect(row.text()).toContain('Blog agent') + expect(wrapper.find('[data-tokens-empty]').exists()).toBe(false) + expect(requestsTo(calls, 'GET', TOKENS)).toHaveLength(1) + }) + + it('shows the empty block for an empty list', async () => { + const { wrapper, calls } = await mountPanel(envelope([])) + expect(wrapper.find('[data-tokens-empty]').exists()).toBe(true) + expect(wrapper.find('[data-token-row]').exists()).toBe(false) + expect(requestsTo(calls, 'GET', TOKENS)).toHaveLength(1) + }) + + it('shows the error block when the list fails', async () => { + const { wrapper, calls } = await mountPanel({ + status: 500, + body: { error: { code: 'internal', message: 'fail', details: {} } }, + }) + expect(wrapper.find('[data-tokens-error]').exists()).toBe(true) + expect(wrapper.find('[data-token-row]').exists()).toBe(false) + expect(requestsTo(calls, 'GET', TOKENS)).toHaveLength(1) + }) +}) diff --git a/admin/tests/form/FileCaptionModal.test.ts b/admin/tests/form/FileCaptionModal.test.ts new file mode 100644 index 0000000..cd5277e --- /dev/null +++ b/admin/tests/form/FileCaptionModal.test.ts @@ -0,0 +1,138 @@ +// FileCaptionModal (Phase 12.2, D-09; UI-SPEC section 3, caption modal) +// mounted on its own over a stub FileRoutes, so no HTTP is involved. Save +// sends title and description through routes.update; a 422 stays in the +// dialog under its inputs; any other failure keeps the input and posts a +// danger toast. The language bundle has no fileupload strings, so the test +// asserts on data attributes and events rather than texts. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import type { FileCallResult, FileRoutes } from '../../src/api/files' +import type { FileItem } from '../../src/api/types' +import FileCaptionModal from '../../src/components/form/fields/FileCaptionModal.vue' +import { toasts } from '../../src/state/useToasts' +import { resetState } from '../helpers' + +const FILE: FileItem = { + id: 41, + file_name: 'front.png', + file_size: 120400, + content_type: 'image/png', + title: 'Front', + description: 'The front side', + sort_order: 41, + pending: false, + created_at: '2026-10-02T10:00:00Z', +} + +function routesStub(update: FileRoutes['update']): FileRoutes { + return { + list: vi.fn(), + upload: vi.fn(), + update: vi.fn(update), + remove: vi.fn(), + reorder: vi.fn(), + download: vi.fn(), + thumb: vi.fn(), + } +} + +function ok(item: FileItem): FileCallResult { + return { data: item, status: 200, error: null } +} + +async function mountModal(routes: FileRoutes) { + const wrapper = mount(FileCaptionModal, { + props: { open: true, file: FILE, routes, preview: null, idPrefix: 'gadget' }, + attachTo: document.body, + }) + await flushPromises() + return wrapper +} + +function el(selector: string): T { + const found = document.body.querySelector(selector) + if (!found) { + throw new Error(`missing ${selector}`) + } + return found +} + +function type(selector: string, value: string): void { + const input = el(selector) + input.value = value + input.dispatchEvent(new Event('input', { bubbles: true })) +} + +async function save(): Promise { + el('[data-action="save-caption"]').click() + await flushPromises() +} + +beforeEach(() => { + resetState() +}) + +afterEach(() => { + document.body.innerHTML = '' +}) + +enableAutoUnmount(afterEach) + +describe('FileCaptionModal mounted directly', () => { + it('prefills title and description from the file', async () => { + await mountModal(routesStub(async () => ok(FILE))) + expect(document.body.querySelector('[data-caption-modal]')).not.toBeNull() + expect(el('[data-caption-title]').value).toBe('Front') + expect(el('[data-caption-description]').value).toBe('The front side') + expect(document.body.textContent).toContain(FILE.file_name) + }) + + it('saves the edited caption through routes.update and closes', async () => { + const saved: FileItem = { ...FILE, title: 'Front cover', description: 'Shown first' } + const routes = routesStub(async () => ok(saved)) + const wrapper = await mountModal(routes) + type('[data-caption-title]', 'Front cover') + type('[data-caption-description]', 'Shown first') + await save() + + expect(routes.update).toHaveBeenCalledTimes(1) + expect(routes.update).toHaveBeenCalledWith(FILE.id, { title: 'Front cover', description: 'Shown first' }) + expect(wrapper.emitted('saved')).toEqual([[saved]]) + expect(wrapper.emitted('update:open')).toEqual([[false]]) + expect(toasts.value[0]?.tone).toBe('success') + }) + + it('keeps the dialog open on a 422 and shows the field message', async () => { + const routes = routesStub(async () => ({ + data: null, + status: 422, + error: { code: 'validation_failed', message: 'Invalid', details: { title: ['Title is too long.'] } }, + })) + const wrapper = await mountModal(routes) + type('[data-caption-title]', 'x'.repeat(300)) + await save() + + expect(wrapper.emitted('saved')).toBeUndefined() + expect(wrapper.emitted('update:open')).toBeUndefined() + expect(document.body.querySelector('[data-caption-modal]')).not.toBeNull() + expect(el('#gadget-caption-title-error').textContent).toContain('Title is too long.') + expect(el('[data-caption-title]').getAttribute('aria-invalid')).toBe('true') + expect(toasts.value).toHaveLength(0) + }) + + it('posts a danger toast on any other failure and keeps the typed input', async () => { + const routes = routesStub(async () => ({ + data: null, + status: 500, + error: { code: 'internal', message: 'Server exploded', details: {} }, + })) + const wrapper = await mountModal(routes) + type('[data-caption-title]', 'Kept title') + await save() + + expect(wrapper.emitted('saved')).toBeUndefined() + expect(wrapper.emitted('update:open')).toBeUndefined() + expect(toasts.value[0]).toMatchObject({ tone: 'danger', text: 'Server exploded' }) + expect(el('[data-caption-title]').value).toBe('Kept title') + }) +}) diff --git a/admin/tests/form/MLField.test.ts b/admin/tests/form/MLField.test.ts new file mode 100644 index 0000000..61e2c67 --- /dev/null +++ b/admin/tests/form/MLField.test.ts @@ -0,0 +1,120 @@ +// MLField, the locale-switching wrapper of the ML controls, mounted on its own +// with the enabled locales FormView would provide: it edits only the active +// locale, emits the whole map, follows ML_LOCALE_CHANGE from every other ML +// control and hands content-locale only to an inner control that asks for it. +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { defineComponent, h, markRaw, ref, type Component } from 'vue' +import type { FormField } from '../../src/api/types' +import MLField from '../../src/components/form/fields/MLField.vue' +import TextField from '../../src/components/form/fields/TextField.vue' +import { FORM_ENABLED_LOCALES } from '../../src/components/form/formContext' +import { ML_LOCALE_CHANGE } from '../../src/components/form/mlLocale' +import { resetState } from '../helpers' + +const enabledLocales = ref(['en', 'pl']) + +function field(): FormField { + return { name: 'title', label: 'Title', type: 'mltext' } as FormField +} + +function mountML(props: { inner?: Component; contentLocale?: boolean; modelValue?: unknown; controlId?: string } = {}) { + return mount(MLField, { + props: { + field: field(), + modelValue: props.modelValue ?? { en: 'Hello', pl: 'Cześć' }, + controlId: props.controlId ?? 'f-title', + // markRaw: test-utils props are reactive, a component prop must not be. + inner: markRaw(props.inner ?? TextField), + innerType: 'text', + mlType: 'mltext', + contentLocale: props.contentLocale, + }, + attachTo: document.body, + global: { provide: { [FORM_ENABLED_LOCALES as symbol]: enabledLocales } }, + }) +} + +/** An inner control that declares content-locale and shows what it got. */ +const LocaleProbe = defineComponent({ + props: { + field: { type: Object, required: true }, + modelValue: { type: null, default: '' }, + controlId: { type: String, default: '' }, + contentLocale: { type: String, default: undefined }, + }, + setup(props) { + return () => h('span', { 'data-probe': '', 'data-content-locale': props.contentLocale ?? 'none' }, String(props.modelValue)) + }, +}) + +beforeEach(() => { + resetState() + enabledLocales.value = ['en', 'pl'] +}) + +afterEach(() => { + document.body.innerHTML = '' +}) + +enableAutoUnmount(afterEach) + +describe('MLField mounted directly', () => { + it('renders the ML type, the enabled locales and the first locale text', () => { + const wrapper = mountML() + expect(wrapper.attributes('data-ml-field')).toBe('mltext') + const options = wrapper.findAll('[data-ml-locale] option').map((option) => option.attributes('value')) + expect(options).toEqual(['en', 'pl']) + expect((wrapper.find('[data-ml-locale]').element as HTMLSelectElement).value).toBe('en') + expect((wrapper.find('input').element as HTMLInputElement).value).toBe('Hello') + }) + + it('emits the whole map with only the active locale changed', async () => { + const wrapper = mountML() + await wrapper.find('input').setValue('Hi there') + expect(wrapper.emitted('update:modelValue')).toEqual([[{ en: 'Hi there', pl: 'Cześć' }]]) + }) + + it('switches locale from the select and broadcasts it to every other ML control', async () => { + const first = mountML({ controlId: 'f-one' }) + const second = mountML({ controlId: 'f-two' }) + await first.find('[data-ml-locale]').setValue('pl') + await flushPromises() + expect((first.find('input').element as HTMLInputElement).value).toBe('Cześć') + expect((second.find('[data-ml-locale]').element as HTMLSelectElement).value).toBe('pl') + expect((second.find('input').element as HTMLInputElement).value).toBe('Cześć') + }) + + it('edits the switched locale only', async () => { + const wrapper = mountML() + await wrapper.find('[data-ml-locale]').setValue('pl') + await wrapper.find('input').setValue('Witaj') + expect(wrapper.emitted('update:modelValue')).toEqual([[{ en: 'Hello', pl: 'Witaj' }]]) + }) + + it('follows a window ML_LOCALE_CHANGE event and ignores an empty one', async () => { + const wrapper = mountML() + window.dispatchEvent(new CustomEvent(ML_LOCALE_CHANGE, { detail: 'pl' })) + await flushPromises() + expect((wrapper.find('input').element as HTMLInputElement).value).toBe('Cześć') + window.dispatchEvent(new CustomEvent(ML_LOCALE_CHANGE, { detail: '' })) + await flushPromises() + expect((wrapper.find('[data-ml-locale]').element as HTMLSelectElement).value).toBe('pl') + }) + + it('hands the active locale to an inner control only with contentLocale', async () => { + const withLocale = mountML({ inner: LocaleProbe, contentLocale: true, controlId: 'f-with' }) + expect(withLocale.find('[data-probe]').attributes('data-content-locale')).toBe('en') + await withLocale.find('[data-ml-locale]').setValue('pl') + expect(withLocale.find('[data-probe]').attributes('data-content-locale')).toBe('pl') + expect(withLocale.find('[data-probe]').text()).toBe('Cześć') + + const without = mountML({ inner: LocaleProbe, controlId: 'f-without' }) + expect(without.find('[data-probe]').attributes('data-content-locale')).toBe('none') + }) + + it('shows an empty input for a locale without text and for a non-map value', () => { + const wrapper = mountML({ modelValue: 'not a map' }) + expect((wrapper.find('input').element as HTMLInputElement).value).toBe('') + }) +}) diff --git a/admin/tests/form/mlLocale.test.ts b/admin/tests/form/mlLocale.test.ts new file mode 100644 index 0000000..dd0fe4a --- /dev/null +++ b/admin/tests/form/mlLocale.test.ts @@ -0,0 +1,86 @@ +// mlLocale (Phase 15): the ML field type predicates, the locale -> text map +// sent on save and the window event that keeps every ML control on one locale. +import { afterEach, describe, expect, it } from 'vitest' +import { + ML_LOCALE_CHANGE, + broadcastMLLocale, + isMLField, + isMLFieldType, + localeRecord, +} from '../../src/components/form/mlLocale' + +describe('isMLFieldType', () => { + it('accepts the three ML types', () => { + expect(isMLFieldType('mltext')).toBe(true) + expect(isMLFieldType('mltextarea')).toBe(true) + expect(isMLFieldType('mlmarkdown')).toBe(true) + }) + + it('refuses plain types and a missing type', () => { + expect(isMLFieldType('text')).toBe(false) + expect(isMLFieldType('markdown')).toBe(false) + expect(isMLFieldType(undefined)).toBe(false) + }) +}) + +describe('isMLField', () => { + it('accepts a built-in ML type', () => { + expect(isMLField({ type: 'mltext' })).toBe(true) + }) + + it('accepts a translatable plugin field with an element', () => { + expect(isMLField({ type: 'acme-color', element: 'acme-color', translatable: true })).toBe(true) + }) + + it('refuses a translatable field without an element', () => { + expect(isMLField({ type: 'acme-color', translatable: true })).toBe(false) + }) + + it('refuses a plugin field that is not translatable', () => { + expect(isMLField({ type: 'acme-color', element: 'acme-color' })).toBe(false) + expect(isMLField({ type: 'acme-color', element: 'acme-color', translatable: false })).toBe(false) + }) + + it('refuses a missing field', () => { + expect(isMLField(undefined)).toBe(false) + }) +}) + +describe('localeRecord', () => { + it('keeps only string values', () => { + expect(localeRecord({ en: 'Hello', pl: 'Cześć', de: 3, fr: null, es: '' })).toEqual({ + en: 'Hello', + pl: 'Cześć', + es: '', + }) + }) + + it('returns an empty map for null, an array and a string', () => { + expect(localeRecord(null)).toEqual({}) + expect(localeRecord(undefined)).toEqual({}) + expect(localeRecord(['Hello'])).toEqual({}) + expect(localeRecord('Hello')).toEqual({}) + }) +}) + +describe('broadcastMLLocale', () => { + const seen: string[] = [] + const listener = (event: Event) => seen.push((event as CustomEvent).detail) + + afterEach(() => { + window.removeEventListener(ML_LOCALE_CHANGE, listener) + seen.length = 0 + }) + + it('dispatches the locale as a window event', () => { + window.addEventListener(ML_LOCALE_CHANGE, listener) + broadcastMLLocale('pl') + expect(seen).toEqual(['pl']) + }) + + it('dispatches nothing for an empty locale', () => { + window.addEventListener(ML_LOCALE_CHANGE, listener) + broadcastMLLocale('') + expect(seen).toEqual([]) + }) +})