import { afterEach, describe, expect, it, vi } from 'vitest' import { defineComponent, ref } from 'vue' import { mount, flushPromises } from '@vue/test-utils' import uiPlugin from '@nuxt/ui/vue-plugin' import { ApiError, NetworkError, useApi } from '@/composables/useApi' import type { Article } from '@/composables/useArticles' import NewArticleModal from '../NewArticleModal.vue' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn(), } }) const TITLE = 'Choosing tiles for a small bathroom' const BODY = 'Small rooms show every grout line, so we start with the floor.' function articleFixture(): Article { return { id: 42, title: TITLE, body: BODY, images: [], is_online: false, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', } } const Host = defineComponent({ components: { NewArticleModal }, emits: ['created'], setup(_props, { emit }) { const open = ref(true) return { open, onCreated: (article: Article) => emit('created', article) } }, template: ``, }) function findButton(text: string) { return Array.from(document.body.querySelectorAll('button')).find( (button) => button.textContent?.trim() === text, ) } async function clickButton(text: string) { findButton(text)?.click() await flushPromises() } let requestMock: ReturnType function mountModal() { requestMock = vi.fn() vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock, }) return mount(Host, { attachTo: document.body, global: { plugins: [uiPlugin] }, }) } function titleInput() { return document.body.querySelector( 'input[placeholder="e.g. Choosing tiles for a small bathroom"]', )! } function bodyTextarea() { return document.body.querySelector( 'textarea[placeholder="Start writing. You\'ll continue in the editor."]', )! } function dispatch(element: HTMLElement, type: string) { element.dispatchEvent(new Event(type, { bubbles: true })) } async function fillTitle(value: string) { const input = titleInput() input.value = value dispatch(input, 'input') await flushPromises() } async function blurTitle() { dispatch(titleInput(), 'blur') await flushPromises() } async function fillBody(value: string) { const textarea = bodyTextarea() textarea.value = value dispatch(textarea, 'input') await flushPromises() } async function blurBody() { dispatch(bodyTextarea(), 'blur') await flushPromises() } async function fillValidForm() { await fillTitle(TITLE) await fillBody(BODY) } afterEach(() => { document.body.innerHTML = '' }) describe('Opening the modal shows the Default state', () => { it('renders empty, focused fields with the Default copy', async () => { mountModal() await flushPromises() expect(document.body.textContent).toContain('New article') expect(document.body.textContent).toContain( "It's saved as a draft. Titles can't be changed later, so check it now.", ) expect(titleInput().value).toBe('') expect(document.activeElement).toBe(titleInput()) expect(document.body.textContent).toContain('0 / 200') expect(document.body.textContent).toContain('3 to 200 characters.') expect(bodyTextarea().value).toBe('') expect(document.body.textContent).toContain( 'You can format it and add images in the editor.', ) expect(findButton('Cancel')).toBeTruthy() expect(findButton('Create draft')).toBeTruthy() expect(requestMock).not.toHaveBeenCalled() }) }) describe('Validation — a title under 3 characters on blur', () => { it('shows the error in place of help and the counter in its error style', async () => { mountModal() await flushPromises() await fillTitle('Ti') await blurTitle() expect(document.body.textContent).toContain('Use at least 3 characters.') expect(document.body.textContent).not.toContain('3 to 200 characters.') expect(document.body.textContent).toContain('2 / 200') expect(requestMock).not.toHaveBeenCalled() }) }) describe('Validation — an empty first paragraph on blur', () => { it('shows the error once the field is left empty and blurred', async () => { mountModal() await flushPromises() await fillTitle(TITLE) await blurTitle() await blurBody() expect(document.body.textContent).toContain( 'Add a first paragraph. It can be a single sentence.', ) expect(requestMock).not.toHaveBeenCalled() }) }) describe('Validation — submitting with both fields invalid shows both errors and sends nothing', () => { it('shows both errors and never sends the request', async () => { mountModal() await flushPromises() await fillTitle('Ti') await clickButton('Create draft') expect(document.body.textContent).toContain('Use at least 3 characters.') expect(document.body.textContent).toContain( 'Add a first paragraph. It can be a single sentence.', ) expect(requestMock).not.toHaveBeenCalled() }) }) describe('Submitting disables the form and cannot be dismissed', () => { it('sends the request, disables the form and blocks Esc/backdrop dismissal', async () => { let resolveRequest: ((article: Article) => void) | null = null requestMock = vi.fn( () => new Promise
((resolve) => { resolveRequest = resolve }), ) vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock }) const wrapper = mount(Host, { attachTo: document.body, global: { plugins: [uiPlugin] }, }) await flushPromises() await fillValidForm() await clickButton('Create draft') expect(requestMock).toHaveBeenCalledWith( '/v1/article/', expect.objectContaining({ method: 'POST', body: JSON.stringify({ title: TITLE, body: BODY }), }), ) expect(titleInput().disabled).toBe(true) expect(bodyTextarea().disabled).toBe(true) expect((findButton('Cancel') as HTMLButtonElement).disabled).toBe(true) expect(findButton('Creating…')).toBeTruthy() document.dispatchEvent( new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true, cancelable: true, }), ) await flushPromises() expect(document.querySelector('[role="dialog"]')).toBeTruthy() resolveRequest?.(articleFixture()) await flushPromises() wrapper.unmount() }) }) describe('A successful create closes the modal and hands off to the editor with a toast', () => { it('closes the modal and emits created with the response body', async () => { requestMock = vi.fn().mockResolvedValue(articleFixture()) vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock }) const wrapper = mount(Host, { attachTo: document.body, global: { plugins: [uiPlugin] }, }) await flushPromises() await fillValidForm() await clickButton('Create draft') await flushPromises() expect(wrapper.emitted('created')).toEqual([[articleFixture()]]) expect(document.querySelector('[role="dialog"]')).toBeFalsy() wrapper.unmount() }) }) describe("Title rejected — a 400 that got past this form's own check", () => { it('keeps the values, shows the title error and the form alert', async () => { requestMock = vi.fn().mockRejectedValue(new ApiError(400)) vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock }) const wrapper = mount(Host, { attachTo: document.body, global: { plugins: [uiPlugin] }, }) await flushPromises() await fillValidForm() await clickButton('Create draft') await flushPromises() expect(titleInput().value).toBe(TITLE) expect(bodyTextarea().value).toBe(BODY) expect(document.body.textContent).toContain('Check this title and try again.') expect(document.body.textContent).toContain( "Something in the form isn't valid. Check the highlighted fields.", ) expect(document.body.textContent).not.toContain( 'Add a first paragraph. It can be a single sentence.', ) expect(titleInput().disabled).toBe(false) wrapper.unmount() }) }) describe('Error — a 500 or a network failure keeps the text and offers to retry', () => { it.each([ { name: 'a 500 response', error: () => new ApiError(500) }, { name: 'a network failure', error: () => new NetworkError() }, ])('keeps the values and shows the generic alert for $name', async ({ error }) => { requestMock = vi.fn().mockRejectedValue(error()) vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock }) const wrapper = mount(Host, { attachTo: document.body, global: { plugins: [uiPlugin] }, }) await flushPromises() await fillValidForm() await clickButton('Create draft') await flushPromises() expect(titleInput().value).toBe(TITLE) expect(bodyTextarea().value).toBe(BODY) expect(document.body.textContent).toContain( "Couldn't create the article. Your text is still here. Try again in a moment.", ) expect(document.body.textContent).not.toContain('Check this title and try again.') expect(document.body.textContent).not.toContain( 'Add a first paragraph. It can be a single sentence.', ) wrapper.unmount() }) }) describe('Retrying after an Error succeeds', () => { it('sends the same body again and hands off to the editor on success', async () => { requestMock = vi.fn().mockRejectedValueOnce(new ApiError(500)) vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock }) const wrapper = mount(Host, { attachTo: document.body, global: { plugins: [uiPlugin] }, }) await flushPromises() await fillValidForm() await clickButton('Create draft') await flushPromises() requestMock.mockResolvedValueOnce(articleFixture()) await clickButton('Create draft') await flushPromises() expect(requestMock).toHaveBeenCalledTimes(2) expect(requestMock).toHaveBeenNthCalledWith( 2, '/v1/article/', expect.objectContaining({ body: JSON.stringify({ title: TITLE, body: BODY }), }), ) expect(wrapper.emitted('created')).toEqual([[articleFixture()]]) expect(document.querySelector('[role="dialog"]')).toBeFalsy() wrapper.unmount() }) }) describe('Cancel discards the fields with no confirmation', () => { it('closes the modal immediately without sending a request', async () => { mountModal() await flushPromises() await fillValidForm() await clickButton('Cancel') expect(document.querySelector('[role="dialog"]')).toBeFalsy() expect(requestMock).not.toHaveBeenCalled() }) })