208 lines
6.4 KiB
TypeScript
208 lines
6.4 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { mount, flushPromises } from '@vue/test-utils'
|
|
import { createPinia, setActivePinia } from 'pinia'
|
|
import uiPlugin from '@nuxt/ui/vue-plugin'
|
|
import router from '@/router'
|
|
import { useSessionStore } from '@/stores/session'
|
|
import type { Article } from '@/composables/useArticles'
|
|
import ArticleEditor from '@/components/ArticleEditor.vue'
|
|
import RootComponent from '@/App.vue'
|
|
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
|
|
import { fixture } from './mediaTestKit'
|
|
import { ownerAccess } from '@/test/access'
|
|
|
|
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
|
|
|
const BODY_WITH_TWO_IMAGES =
|
|
'<p>Intro</p><img src="/media/north-wall.jpg" alt="" data-image-id="1"><img src="/media/logo-renew.svg" alt="" data-image-id="4">'
|
|
|
|
function mockResponse(status: number, body: unknown = null) {
|
|
return {
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
headers: { get: () => null },
|
|
json: async () => body,
|
|
} as unknown as Response
|
|
}
|
|
|
|
function articleFixture(body = BODY_WITH_TWO_IMAGES): Article {
|
|
return {
|
|
id: 12,
|
|
title: 'How we price a bathroom renovation',
|
|
body,
|
|
is_online: false,
|
|
...articleMeta,
|
|
created_at: '2026-03-12T09:14:00Z',
|
|
updated_at: FIXED_NOW,
|
|
}
|
|
}
|
|
|
|
function libraryPage() {
|
|
const items = fixture().map((item) =>
|
|
item.id === 2 ? { ...item, url: '/static/uploadedImages/2.webp' } : item,
|
|
)
|
|
return {
|
|
items,
|
|
total: 48,
|
|
page: 1,
|
|
size: 25,
|
|
counts: { all: 48, unused: 2, duplicates: 2 },
|
|
}
|
|
}
|
|
|
|
let toastAdd: ReturnType<typeof vi.fn>
|
|
|
|
vi.mock('@nuxt/ui/composables', () => ({
|
|
useOverlay: () => ({ create: vi.fn() }),
|
|
useToast: () => ({ add: toastAdd }),
|
|
}))
|
|
|
|
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
|
|
|
|
async function mountEditor(body = BODY_WITH_TWO_IMAGES) {
|
|
setActivePinia(createPinia())
|
|
toastAdd = vi.fn()
|
|
const session = useSessionStore()
|
|
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
|
|
session.status = 'ready'
|
|
global.fetch = vi.fn(async (url: string) => {
|
|
if (url === '/v1/article/12?format=html') {
|
|
return mockResponse(200, articleFixture(body))
|
|
}
|
|
if (url === '/v1/site/') {
|
|
return mockResponse(200, oneSite)
|
|
}
|
|
if (url === '/v1/article/12/seo/check') {
|
|
return mockResponse(200, readyCheck)
|
|
}
|
|
if (url === '/v1/media/?page=1&size=25') {
|
|
return mockResponse(200, libraryPage())
|
|
}
|
|
throw new Error(`Unexpected fetch call: ${url}`)
|
|
}) as unknown as typeof fetch
|
|
await router.push('/article/12/edit')
|
|
const wrapper = mount(RootComponent, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, uiPlugin] },
|
|
})
|
|
currentWrapper = wrapper
|
|
await flushPromises()
|
|
return wrapper
|
|
}
|
|
|
|
function findButton(text: string) {
|
|
return Array.from(document.body.querySelectorAll('button')).find(
|
|
(button) => button.textContent?.trim() === text,
|
|
)
|
|
}
|
|
|
|
async function openPicker(wrapper: Awaited<ReturnType<typeof mount>>) {
|
|
await wrapper
|
|
.findAll('button')
|
|
.find((button) => button.text() === 'Image')
|
|
?.trigger('click')
|
|
await flushPromises()
|
|
}
|
|
|
|
function tile(filename: string) {
|
|
return Array.from(document.body.querySelectorAll<HTMLButtonElement>('[data-picker-tile]')).find(
|
|
(element) => element.getAttribute('aria-label')?.startsWith(`${filename},`),
|
|
)!
|
|
}
|
|
|
|
function taggedIds() {
|
|
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-picker-tile]'))
|
|
.filter((element) => element.textContent?.includes('In this article'))
|
|
.map((element) => element.getAttribute('data-image-id'))
|
|
}
|
|
|
|
afterEach(async () => {
|
|
currentWrapper?.unmount()
|
|
currentWrapper = null
|
|
document.body.innerHTML = ''
|
|
await router.push('/login')
|
|
})
|
|
|
|
describe('An image is in this article when its node is in the current content', () => {
|
|
it('tags images 1 and 4 and no other when the dialog opens', async () => {
|
|
const wrapper = await mountEditor()
|
|
|
|
await openPicker(wrapper)
|
|
|
|
expect(taggedIds()).toEqual(['1', '4'])
|
|
})
|
|
|
|
it('counts an insertion that has not been saved yet', async () => {
|
|
const wrapper = await mountEditor('<p>Intro</p>')
|
|
wrapper.findComponent(ArticleEditor).vm.insertImage('/static/uploadedImages/2.webp', '', 2)
|
|
await flushPromises()
|
|
|
|
await openPicker(wrapper)
|
|
|
|
expect(taggedIds()).toEqual(['2'])
|
|
})
|
|
|
|
it('no longer tags an image that was removed from the body', async () => {
|
|
const wrapper = await mountEditor()
|
|
wrapper.findComponent(ArticleEditor).vm.removeImage(1)
|
|
await flushPromises()
|
|
|
|
await openPicker(wrapper)
|
|
|
|
expect(taggedIds()).toEqual(['4'])
|
|
})
|
|
})
|
|
|
|
describe('Insert puts the chosen image in the body with its alt text', () => {
|
|
it('inserts it at the cursor, marks the draft dirty, toasts and closes the dialog', async () => {
|
|
const wrapper = await mountEditor('<p>Intro</p>')
|
|
await openPicker(wrapper)
|
|
tile('kitchen-after.webp').click()
|
|
await flushPromises()
|
|
const alt = document.body.querySelector<HTMLInputElement>(
|
|
'input[placeholder="e.g. North wall of the kitchen before plastering"]',
|
|
)!
|
|
alt.value = 'A kitchen with new oak worktops and white tiles'
|
|
alt.dispatchEvent(new Event('input', { bubbles: true }))
|
|
await flushPromises()
|
|
|
|
findButton('Insert image')?.click()
|
|
await flushPromises()
|
|
|
|
const body = wrapper.findComponent(ArticleEditor).props('modelValue')
|
|
expect(body).toContain(
|
|
'<img src="/static/uploadedImages/2.webp" alt="A kitchen with new oak worktops and white tiles" data-image-id="2">',
|
|
)
|
|
expect(wrapper.text()).toContain('Unsaved changes')
|
|
expect(
|
|
(wrapper.find('[data-testid="save-button"]').element as HTMLButtonElement).disabled,
|
|
).toBe(false)
|
|
expect(toastAdd).toHaveBeenCalledWith({
|
|
color: 'success',
|
|
icon: 'i-lucide-circle-check',
|
|
title: 'Image added',
|
|
description: 'Save to keep it in the article.',
|
|
})
|
|
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
|
const methods = vi
|
|
.mocked(global.fetch)
|
|
.mock.calls.filter(([url]) => !String(url).endsWith('/seo/check'))
|
|
.map(([, init]) => (init as RequestInit | undefined)?.method)
|
|
expect(methods.every((method) => method === undefined)).toBe(true)
|
|
})
|
|
|
|
it('leaves the content and the draft alone when the dialog is cancelled', async () => {
|
|
const wrapper = await mountEditor('<p>Intro</p>')
|
|
await openPicker(wrapper)
|
|
tile('kitchen-after.webp').click()
|
|
await flushPromises()
|
|
|
|
findButton('Cancel')?.click()
|
|
await flushPromises()
|
|
|
|
expect(wrapper.findComponent(ArticleEditor).props('modelValue')).not.toContain('<img')
|
|
expect(wrapper.text()).not.toContain('Unsaved changes')
|
|
expect(toastAdd).not.toHaveBeenCalled()
|
|
})
|
|
})
|