739 lines
25 KiB
TypeScript
739 lines
25 KiB
TypeScript
import { afterEach, beforeEach, 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 { useSeenImagesStore } from '@/stores/seenImages'
|
|
import { useSessionStore } from '@/stores/session'
|
|
import type { Article } from '@/composables/useArticles'
|
|
import ArticleEditor from '@/components/ArticleEditor.vue'
|
|
import RootComponent from '@/App.vue'
|
|
|
|
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
|
|
|
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
|
|
return {
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
headers: { get: (name: string) => headers[name] ?? null },
|
|
json: async () => body,
|
|
} as unknown as Response
|
|
}
|
|
|
|
function routeFor(url: string, init?: RequestInit) {
|
|
const method = init?.method ?? 'GET'
|
|
return `${method} ${url}`
|
|
}
|
|
|
|
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
|
|
return vi.fn((url: string, init?: RequestInit) => {
|
|
const key = routeFor(url, init)
|
|
const handler = handlers[key]
|
|
if (!handler) {
|
|
throw new Error(`Unexpected fetch call: ${key}`)
|
|
}
|
|
return handler()
|
|
})
|
|
}
|
|
|
|
function articleFixture(overrides: Partial<Article> = {}): Article {
|
|
return {
|
|
id: 12,
|
|
title: 'How we price a bathroom renovation',
|
|
body: 'a'.repeat(4318),
|
|
images: [
|
|
{ id: 1, url: '/images/1.png' },
|
|
{ id: 2, url: '/images/2.png' },
|
|
{ id: 3, url: '/images/3.png' },
|
|
],
|
|
is_online: false,
|
|
created_at: '2026-03-12T09:14:00Z',
|
|
updated_at: FIXED_NOW,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function createOverlayMock() {
|
|
let props: Record<string, unknown> | null = null
|
|
let resolveOpen: ((value: unknown) => void) | null = null
|
|
const create = vi.fn((_component: unknown, options: { props: Record<string, unknown> }) => {
|
|
props = options.props
|
|
return {
|
|
open: () =>
|
|
new Promise((resolve) => {
|
|
resolveOpen = resolve
|
|
}),
|
|
}
|
|
})
|
|
return {
|
|
create,
|
|
getProps: () => props,
|
|
resolveOpen: (value: unknown) => resolveOpen?.(value),
|
|
}
|
|
}
|
|
|
|
let overlayMock: ReturnType<typeof createOverlayMock>
|
|
let toastAdd: ReturnType<typeof vi.fn>
|
|
|
|
vi.mock('@nuxt/ui/composables', () => ({
|
|
useOverlay: () => overlayMock,
|
|
useToast: () => ({ add: toastAdd }),
|
|
}))
|
|
|
|
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
|
|
|
|
async function mountEditor(
|
|
handlers: Record<string, () => Response | Promise<Response>>,
|
|
path = '/article/12/edit',
|
|
) {
|
|
setActivePinia(createPinia())
|
|
overlayMock = createOverlayMock()
|
|
toastAdd = vi.fn()
|
|
const session = useSessionStore()
|
|
session.session = { verified: true, username: 'marta' }
|
|
session.status = 'ready'
|
|
global.fetch = fetchRouter(handlers) as unknown as typeof fetch
|
|
await router.push(path)
|
|
const wrapper = mount(RootComponent, {
|
|
attachTo: document.body,
|
|
global: {
|
|
plugins: [router, uiPlugin],
|
|
},
|
|
})
|
|
currentWrapper = wrapper
|
|
await flushPromises()
|
|
return wrapper
|
|
}
|
|
|
|
function saveButton(wrapper: Awaited<ReturnType<typeof mount>>) {
|
|
return wrapper.find('[data-testid="save-button"]')
|
|
}
|
|
|
|
function articleEditorBody(wrapper: Awaited<ReturnType<typeof mount>>) {
|
|
return wrapper.findComponent(ArticleEditor).props('modelValue')
|
|
}
|
|
|
|
async function typeBody(wrapper: Awaited<ReturnType<typeof mount>>, body: string) {
|
|
await wrapper.findComponent(ArticleEditor).vm.$emit('update:modelValue', body)
|
|
await flushPromises()
|
|
}
|
|
|
|
function paragraph(inner: string) {
|
|
return `<p>${inner}</p>`
|
|
}
|
|
|
|
function paragraphOfLength(totalLength: number) {
|
|
return paragraph('a'.repeat(totalLength - 7))
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers()
|
|
vi.setSystemTime(new Date(FIXED_NOW))
|
|
})
|
|
|
|
afterEach(async () => {
|
|
currentWrapper?.unmount()
|
|
currentWrapper = null
|
|
vi.useRealTimers()
|
|
sessionStorage.clear()
|
|
const onLineSpy = vi.spyOn(navigator, 'onLine', 'get')
|
|
onLineSpy.mockReturnValue(true)
|
|
window.dispatchEvent(new Event('online'))
|
|
onLineSpy.mockRestore()
|
|
await router.push('/login')
|
|
})
|
|
|
|
describe('Skeletons appear only after 150 ms, never for a fast response', () => {
|
|
it('shows no skeleton before 150 ms, a skeleton after, then the Saved state once resolved', async () => {
|
|
let resolveGet: (() => void) | null = null
|
|
const wrapper = await mountEditor({
|
|
'GET /v1/article/12': () =>
|
|
new Promise((resolve) => {
|
|
resolveGet = () => resolve(mockResponse(200, articleFixture()))
|
|
}),
|
|
})
|
|
|
|
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(false)
|
|
|
|
await vi.advanceTimersByTimeAsync(150)
|
|
|
|
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(true)
|
|
expect(wrapper.find('[data-testid="skeleton-content"]').exists()).toBe(true)
|
|
expect(wrapper.find('[data-testid="skeleton-details"]').exists()).toBe(true)
|
|
|
|
resolveGet?.()
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
await flushPromises()
|
|
|
|
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(false)
|
|
expect(wrapper.text()).toContain('How we price a bathroom renovation')
|
|
})
|
|
|
|
it('never shows a skeleton when the response resolves in under 150 ms', async () => {
|
|
const wrapper = await mountEditor({
|
|
'GET /v1/article/12': () => Promise.resolve(mockResponse(200, articleFixture())),
|
|
})
|
|
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
await flushPromises()
|
|
|
|
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(false)
|
|
expect(wrapper.text()).toContain('How we price a bathroom renovation')
|
|
})
|
|
})
|
|
|
|
describe('Saved — a freshly loaded article, draft or online', () => {
|
|
it.each([
|
|
{ is_online: false, badge: 'Draft', publishLabel: 'Publish' },
|
|
{ is_online: true, badge: 'Online', publishLabel: 'Unpublish' },
|
|
])(
|
|
'reads the Saved state for is_online $is_online',
|
|
async ({ is_online, badge, publishLabel }) => {
|
|
const wrapper = await mountEditor({
|
|
'GET /v1/article/12': () => mockResponse(200, articleFixture({ is_online })),
|
|
})
|
|
|
|
expect(wrapper.text()).toContain('Articles')
|
|
expect(wrapper.text()).toContain('How we price a bathroom renovation')
|
|
expect(wrapper.text()).toContain(badge)
|
|
expect(wrapper.text()).toContain('Saved at 11:02')
|
|
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
|
|
expect(wrapper.text()).toContain(publishLabel)
|
|
expect(wrapper.text()).toContain('4 318 / 10 000')
|
|
},
|
|
)
|
|
})
|
|
|
|
describe('Unsaved changes — any edit marks the article dirty', () => {
|
|
it('shows Unsaved changes, enables Save and switches Publish to Save and publish', async () => {
|
|
const wrapper = await mountEditor({
|
|
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
|
|
})
|
|
|
|
await typeBody(wrapper, paragraphOfLength(4320))
|
|
|
|
expect(wrapper.text()).toContain('Unsaved changes')
|
|
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
|
|
expect(wrapper.text()).toContain('Save and publish')
|
|
})
|
|
})
|
|
|
|
describe('Saving — Save sends the PATCH and disables itself while in flight', () => {
|
|
it('sends the edited body and shows Saving…', async () => {
|
|
let patchBody: string | null = null
|
|
const wrapper = await mountEditor({
|
|
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
|
|
})
|
|
await typeBody(wrapper, paragraphOfLength(4320))
|
|
|
|
global.fetch = vi.fn((url: string, init?: RequestInit) => {
|
|
if (routeFor(url, init) === 'PATCH /v1/article/12') {
|
|
patchBody = init?.body as string
|
|
return new Promise(() => {})
|
|
}
|
|
throw new Error(`Unexpected fetch call: ${routeFor(url, init)}`)
|
|
}) as unknown as typeof fetch
|
|
|
|
await saveButton(wrapper).trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(JSON.parse(patchBody ?? '{}')).toEqual({
|
|
body: paragraphOfLength(4320),
|
|
})
|
|
expect(wrapper.text()).toContain('Saving…')
|
|
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('Ctrl/⌘ S saves the same way as clicking Save', () => {
|
|
it('sends the same PATCH on Ctrl+S', async () => {
|
|
const wrapper = await mountEditor({
|
|
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
|
|
})
|
|
await typeBody(wrapper, paragraphOfLength(4320))
|
|
|
|
let patchBody: string | null = null
|
|
global.fetch = vi.fn((url: string, init?: RequestInit) => {
|
|
if (routeFor(url, init) === 'PATCH /v1/article/12') {
|
|
patchBody = init?.body as string
|
|
return new Promise(() => {})
|
|
}
|
|
throw new Error(`Unexpected fetch call: ${routeFor(url, init)}`)
|
|
}) as unknown as typeof fetch
|
|
|
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true }))
|
|
await flushPromises()
|
|
|
|
expect(JSON.parse(patchBody ?? '{}')).toEqual({
|
|
body: paragraphOfLength(4320),
|
|
})
|
|
expect(wrapper.text()).toContain('Saving…')
|
|
})
|
|
})
|
|
|
|
describe('A successful save returns to the Saved state with the new time', () => {
|
|
it('shows Saved at the current time and disables Save again', async () => {
|
|
vi.setSystemTime(new Date('2026-09-26T11:30:00Z'))
|
|
let resolvePatch: (() => void) | null = null
|
|
const wrapper = await mountEditor({
|
|
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
|
|
'PATCH /v1/article/12': () =>
|
|
new Promise((resolve) => {
|
|
resolvePatch = () =>
|
|
resolve(
|
|
mockResponse(
|
|
200,
|
|
articleFixture({
|
|
body: paragraphOfLength(4320),
|
|
}),
|
|
),
|
|
)
|
|
}),
|
|
})
|
|
await typeBody(wrapper, paragraphOfLength(4320))
|
|
await saveButton(wrapper).trigger('click')
|
|
await flushPromises()
|
|
|
|
resolvePatch?.()
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text()).toContain('Saved at 11:30')
|
|
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('SaveStatus switches to a relative time after an hour', () => {
|
|
it('reads "Saved n h ago" once more than an hour has passed', async () => {
|
|
const wrapper = await mountEditor({
|
|
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
|
|
})
|
|
|
|
expect(wrapper.text()).toContain('Saved at 11:02')
|
|
|
|
vi.setSystemTime(new Date('2026-09-26T12:32:00Z'))
|
|
await vi.advanceTimersByTimeAsync(20000)
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text()).toContain('Saved 1 h ago')
|
|
})
|
|
})
|
|
|
|
describe('Save failed — a 500 or a network failure keeps the changes and offers Retry', () => {
|
|
it.each([
|
|
{ name: 'a 500 response', handler: () => mockResponse(500) },
|
|
{
|
|
name: 'a network failure',
|
|
handler: () => Promise.reject(new TypeError('Failed to fetch')),
|
|
},
|
|
])('shows Save failed with Retry for $name', async ({ handler }) => {
|
|
const wrapper = await mountEditor({
|
|
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
|
|
'PATCH /v1/article/12': handler,
|
|
})
|
|
await typeBody(wrapper, paragraph('Revised opening paragraph.'))
|
|
await saveButton(wrapper).trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text()).toContain('Save failed')
|
|
expect(wrapper.findAll('button').some((button) => button.text() === 'Retry')).toBe(true)
|
|
expect(articleEditorBody(wrapper)).toBe(paragraph('Revised opening paragraph.'))
|
|
})
|
|
})
|
|
|
|
describe('Retry resends the same body and can recover', () => {
|
|
it('resends the same PATCH and shows Saved at the current time', async () => {
|
|
const patchCall = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(mockResponse(500))
|
|
.mockResolvedValueOnce(
|
|
mockResponse(
|
|
200,
|
|
articleFixture({
|
|
body: paragraph('Revised opening paragraph.'),
|
|
}),
|
|
),
|
|
)
|
|
const wrapper = await mountEditor({
|
|
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
|
|
'PATCH /v1/article/12': patchCall,
|
|
})
|
|
await typeBody(wrapper, paragraph('Revised opening paragraph.'))
|
|
await saveButton(wrapper).trigger('click')
|
|
await flushPromises()
|
|
|
|
const retryButton = wrapper.findAll('button').find((button) => button.text() === 'Retry')
|
|
await retryButton?.trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(patchCall).toHaveBeenCalledTimes(2)
|
|
expect(wrapper.text()).toMatch(/Saved at \d{2}:\d{2}/)
|
|
})
|
|
})
|
|
|
|
describe('A save against an article deleted elsewhere shows a toast, not the error SaveStatus', () => {
|
|
it('shows the toast and never reads Save failed', async () => {
|
|
const wrapper = await mountEditor({
|
|
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
|
|
'PATCH /v1/article/12': () => mockResponse(404),
|
|
})
|
|
await typeBody(wrapper, paragraphOfLength(4320))
|
|
await saveButton(wrapper).trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(toastAdd).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
title: 'That article no longer exists.',
|
|
}),
|
|
)
|
|
expect(wrapper.text()).not.toContain('Save failed')
|
|
})
|
|
})
|
|
|
|
describe('Near the limit — the counter warns at 9000 characters without blocking Save', () => {
|
|
it('shows the warning icon and keeps Save enabled', async () => {
|
|
const wrapper = await mountEditor({
|
|
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
|
|
})
|
|
await typeBody(wrapper, paragraphOfLength(9240))
|
|
|
|
expect(wrapper.text()).toContain('9 240 / 10 000')
|
|
expect(wrapper.find('[data-testid="character-counter-warning-icon"]').exists()).toBe(true)
|
|
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('Too long — over 10000 characters disables Save and Publish until shortened', () => {
|
|
it('shows the over-limit text and disables Save and Publish, then re-enables once shortened', async () => {
|
|
const wrapper = await mountEditor({
|
|
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
|
|
})
|
|
await typeBody(wrapper, paragraphOfLength(10214))
|
|
|
|
expect(wrapper.text()).toContain('10 214 / 10 000')
|
|
expect(wrapper.text()).toContain(
|
|
'214 characters over. Shorten the text or remove an image. Formatting counts too.',
|
|
)
|
|
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
|
|
const publishButton = wrapper
|
|
.findAll('button')
|
|
.find((button) => button.text() === 'Publish' || button.text() === 'Save and publish')
|
|
expect((publishButton?.element as HTMLButtonElement).disabled).toBe(true)
|
|
|
|
await typeBody(wrapper, paragraphOfLength(9000))
|
|
|
|
expect(wrapper.text()).not.toContain('characters over')
|
|
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('Preview shows the rendered title and body without the editing surface', () => {
|
|
it('replaces the editor with a read-only rendering, then restores it unchanged', async () => {
|
|
const wrapper = await mountEditor({
|
|
'GET /v1/article/12': () =>
|
|
mockResponse(200, articleFixture({ body: '<p>Some body</p>' })),
|
|
})
|
|
|
|
const previewSwitch = wrapper.find('button[role="switch"]')
|
|
await previewSwitch.trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(wrapper.findComponent(ArticleEditor).exists()).toBe(false)
|
|
expect(wrapper.find('[data-testid="article-preview"]').text()).toContain(
|
|
'How we price a bathroom renovation',
|
|
)
|
|
expect(wrapper.find('[data-testid="article-preview"]').html()).toContain('Some body')
|
|
|
|
await previewSwitch.trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(wrapper.findComponent(ArticleEditor).exists()).toBe(true)
|
|
expect(articleEditorBody(wrapper)).toBe('<p>Some body</p>')
|
|
})
|
|
})
|
|
|
|
describe('Offline blocks Save and Publish, and recovers when the connection returns', () => {
|
|
it('shows the offline SaveStatus and re-enables Save once back online', async () => {
|
|
const wrapper = await mountEditor({
|
|
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
|
|
})
|
|
await typeBody(wrapper, paragraphOfLength(4320))
|
|
|
|
const onLineSpy = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
|
|
window.dispatchEvent(new Event('offline'))
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text()).toContain('Waiting for connection')
|
|
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
|
|
|
|
onLineSpy.mockReturnValue(true)
|
|
window.dispatchEvent(new Event('online'))
|
|
await flushPromises()
|
|
|
|
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
|
|
expect(wrapper.text()).toContain('Unsaved changes')
|
|
onLineSpy.mockRestore()
|
|
})
|
|
})
|
|
|
|
describe('Leaving with unsaved changes prompts, and "Discard changes" proceeds', () => {
|
|
it('opens ConfirmDialog with the exact copy and navigates away on Discard changes', async () => {
|
|
const wrapper = await mountEditor({
|
|
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
|
|
})
|
|
await typeBody(wrapper, paragraphOfLength(4320))
|
|
|
|
const articlesLink = wrapper.findAll('a').find((a) => a.attributes('href') === '/article')
|
|
expect(articlesLink).toBeTruthy()
|
|
|
|
const navigation = router.push('/article')
|
|
await flushPromises()
|
|
|
|
const props = overlayMock.getProps() as Record<string, unknown>
|
|
expect(props.title).toBe('Leave without saving?')
|
|
expect(props.description).toBe(
|
|
'Your changes to "How we price a bathroom renovation" haven\'t been saved.',
|
|
)
|
|
expect(props.cancelLabel).toBe('Discard changes')
|
|
expect(props.confirmLabel).toBe('Keep editing')
|
|
expect(props.dismissResolvesTo).toBe('confirm')
|
|
|
|
overlayMock.resolveOpen(false)
|
|
await navigation
|
|
|
|
expect(router.currentRoute.value.path).toBe('/article')
|
|
})
|
|
})
|
|
|
|
describe('Leaving with unsaved changes, "Keep editing" stays on the page', () => {
|
|
it('keeps the user on the editor with the unsaved body intact', async () => {
|
|
const wrapper = await mountEditor({
|
|
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
|
|
})
|
|
await typeBody(wrapper, paragraphOfLength(4320))
|
|
|
|
const articlesLink = wrapper.findAll('a').find((a) => a.attributes('href') === '/article')
|
|
expect(articlesLink).toBeTruthy()
|
|
|
|
const navigation = router.push('/article')
|
|
await flushPromises()
|
|
|
|
overlayMock.resolveOpen(true)
|
|
await navigation
|
|
|
|
expect(router.currentRoute.value.path).toBe('/article/12/edit')
|
|
expect(articleEditorBody(wrapper)).toBe(paragraphOfLength(4320))
|
|
})
|
|
})
|
|
|
|
describe('Esc or the overlay on the leave guard behaves like "Keep editing"', () => {
|
|
it('keeps the user on /article/12/edit with the unsaved body intact', async () => {
|
|
const wrapper = await mountEditor({
|
|
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
|
|
})
|
|
await typeBody(wrapper, paragraphOfLength(4320))
|
|
|
|
const navigation = router.push('/article')
|
|
await flushPromises()
|
|
|
|
const props = overlayMock.getProps() as Record<string, unknown>
|
|
expect(props.dismissResolvesTo).toBe('confirm')
|
|
|
|
overlayMock.resolveOpen(true)
|
|
await navigation
|
|
|
|
expect(router.currentRoute.value.path).toBe('/article/12/edit')
|
|
expect(articleEditorBody(wrapper)).toBe(paragraphOfLength(4320))
|
|
})
|
|
})
|
|
|
|
describe('Leaving a clean article never prompts', () => {
|
|
it('navigates directly with no ConfirmDialog', async () => {
|
|
const wrapper = await mountEditor({
|
|
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
|
|
})
|
|
|
|
const articlesLink = wrapper.findAll('a').find((a) => a.attributes('href') === '/article')
|
|
await articlesLink?.trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(overlayMock.getProps()).toBeNull()
|
|
expect(router.currentRoute.value.path).toBe('/article')
|
|
})
|
|
})
|
|
|
|
describe('Session expired mid-save keeps the draft and restores it after logging back in', () => {
|
|
it('mirrors the body to sessionStorage and restores it on the next mount', async () => {
|
|
const wrapper = await mountEditor({
|
|
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
|
|
'PATCH /v1/article/12': () => mockResponse(401),
|
|
})
|
|
const body = paragraph('Paragraph added just before the session expired.')
|
|
await typeBody(wrapper, body)
|
|
await saveButton(wrapper).trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(sessionStorage.getItem('renewcms:article-draft:12')).toBe(body)
|
|
expect(router.currentRoute.value.path).toBe('/login')
|
|
expect(router.currentRoute.value.query.redirect).toBe('/article/12/edit')
|
|
expect(router.currentRoute.value.query.reason).toBe('expired')
|
|
|
|
const session = useSessionStore()
|
|
session.session = { verified: true, username: 'marta' }
|
|
session.status = 'ready'
|
|
global.fetch = fetchRouter({
|
|
'GET /v1/article/12': () =>
|
|
mockResponse(
|
|
200,
|
|
articleFixture({
|
|
body: 'Last saved body without that paragraph.',
|
|
}),
|
|
),
|
|
}) as unknown as typeof fetch
|
|
await router.push('/article/12/edit')
|
|
const secondWrapper = mount(RootComponent, {
|
|
attachTo: document.body,
|
|
global: {
|
|
plugins: [router, uiPlugin],
|
|
},
|
|
})
|
|
await flushPromises()
|
|
|
|
expect(articleEditorBody(secondWrapper)).toBe(body)
|
|
expect(secondWrapper.text()).toContain('Unsaved changes')
|
|
secondWrapper.unmount()
|
|
})
|
|
})
|
|
|
|
function articleWithImageFixture(overrides: Partial<Article> = {}) {
|
|
return articleFixture({
|
|
body: '<p>Body</p><img src="/static/uploadedImages/before.png" alt="" data-image-id="10">',
|
|
...overrides,
|
|
})
|
|
}
|
|
|
|
async function requestRemoveImage(wrapper: Awaited<ReturnType<typeof mount>>, imageId = 10) {
|
|
await wrapper.findComponent(ArticleEditor).vm.$emit('request-remove-image', imageId)
|
|
await flushPromises()
|
|
}
|
|
|
|
function requestCalls(wrapper: Awaited<ReturnType<typeof mount>>) {
|
|
void wrapper
|
|
return vi.mocked(global.fetch).mock.calls.map(([url, init]) => routeFor(url as string, init))
|
|
}
|
|
|
|
describe('Requesting removal of an inline image opens the neutral confirm dialog', () => {
|
|
it('opens ConfirmDialog with the exact copy and sends nothing', async () => {
|
|
const wrapper = await mountEditor({
|
|
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
|
|
})
|
|
|
|
await requestRemoveImage(wrapper)
|
|
|
|
const props = overlayMock.getProps() as Record<string, unknown>
|
|
expect(props.title).toBe('Remove this image from the article?')
|
|
expect(props.description).toBe(
|
|
"It's removed from this article only. The file stays in your library and in any other article that uses it.",
|
|
)
|
|
expect(props.confirmLabel).toBe('Remove image')
|
|
expect(props.cancelLabel).toBe('Cancel')
|
|
expect(props.tone).toBe('neutral')
|
|
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12'])
|
|
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
|
|
})
|
|
|
|
it('previews the image the session has seen with its filename and size', async () => {
|
|
const wrapper = await mountEditor({
|
|
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
|
|
})
|
|
useSeenImagesStore().remember([{ id: 10, filename: 'before.png', size: 4089446 }])
|
|
|
|
await requestRemoveImage(wrapper)
|
|
|
|
const props = overlayMock.getProps() as { preview: Record<string, string> }
|
|
expect(props.preview).toEqual({
|
|
src: '/static/uploadedImages/before.png',
|
|
filename: 'before.png',
|
|
size: '3.9 MB',
|
|
})
|
|
})
|
|
|
|
it('previews only the thumbnail for an image the session has not seen', async () => {
|
|
const wrapper = await mountEditor({
|
|
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
|
|
})
|
|
|
|
await requestRemoveImage(wrapper)
|
|
|
|
const props = overlayMock.getProps() as { preview: Record<string, string> }
|
|
expect(props.preview).toEqual({
|
|
src: '/static/uploadedImages/before.png',
|
|
filename: undefined,
|
|
size: undefined,
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Cancelling the confirm changes nothing', () => {
|
|
it('keeps the image and the draft state when the dialog resolves to cancel', async () => {
|
|
const wrapper = await mountEditor({
|
|
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
|
|
})
|
|
await requestRemoveImage(wrapper)
|
|
|
|
overlayMock.resolveOpen(false)
|
|
await flushPromises()
|
|
|
|
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
|
|
expect(wrapper.text()).not.toContain('Unsaved changes')
|
|
expect(toastAdd).not.toHaveBeenCalled()
|
|
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12'])
|
|
})
|
|
})
|
|
|
|
describe('Confirming removes the image from the body only, marks the draft dirty and toasts', () => {
|
|
it('removes the image from the content, reads Unsaved changes and sends no request', async () => {
|
|
const wrapper = await mountEditor({
|
|
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
|
|
})
|
|
await requestRemoveImage(wrapper)
|
|
|
|
const props = overlayMock.getProps() as { action: () => Promise<void> }
|
|
await props.action()
|
|
await flushPromises()
|
|
|
|
expect(articleEditorBody(wrapper)).not.toContain('data-image-id="10"')
|
|
expect(wrapper.text()).toContain('Unsaved changes')
|
|
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
|
|
expect(toastAdd).toHaveBeenCalledWith({
|
|
color: 'success',
|
|
icon: 'i-lucide-circle-check',
|
|
title: 'Image removed',
|
|
description: "It's still in your library.",
|
|
})
|
|
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12'])
|
|
})
|
|
})
|
|
|
|
describe('An unknown or deleted article id replaces the editor with an error state', () => {
|
|
it('shows the not-found error state and navigates to /article', async () => {
|
|
const wrapper = await mountEditor(
|
|
{
|
|
'GET /v1/article/9999': () => mockResponse(404),
|
|
},
|
|
'/article/9999/edit',
|
|
)
|
|
|
|
expect(wrapper.text()).toContain("This article doesn't exist.")
|
|
expect(wrapper.find('[data-testid="save-button"]').exists()).toBe(false)
|
|
expect(wrapper.text()).not.toContain('Publish')
|
|
|
|
const allArticlesButton = wrapper
|
|
.findAll('button')
|
|
.find((button) => button.text() === 'All articles')
|
|
await allArticlesButton?.trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(router.currentRoute.value.path).toBe('/article')
|
|
})
|
|
})
|