362 lines
10 KiB
TypeScript
362 lines
10 KiB
TypeScript
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<typeof import('@/composables/useApi')>()
|
|
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: `<NewArticleModal v-model:open="open" @created="onCreated" />`,
|
|
})
|
|
|
|
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<typeof vi.fn>
|
|
|
|
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<HTMLInputElement>(
|
|
'input[placeholder="e.g. Choosing tiles for a small bathroom"]',
|
|
)!
|
|
}
|
|
|
|
function bodyTextarea() {
|
|
return document.body.querySelector<HTMLTextAreaElement>(
|
|
'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<Article>((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()
|
|
})
|
|
})
|