311 lines
8.7 KiB
TypeScript
311 lines
8.7 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 { useSessionStore } from '@/stores/session'
|
|
import type { Article } from '@/composables/useArticles'
|
|
import ArticleEditor from '@/components/ArticleEditor.vue'
|
|
import RootComponent from '@/App.vue'
|
|
import { articleMeta, oneSite } from './editorMetaKit'
|
|
|
|
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
|
|
|
vi.mock('@/lib/fileHash', () => ({
|
|
hashFile: vi.fn(async () => 'a'.repeat(64)),
|
|
}))
|
|
|
|
vi.mock('@/lib/imageCrop', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/lib/imageCrop')>()
|
|
return {
|
|
...actual,
|
|
readNaturalImageSize: vi.fn(async () => ({
|
|
width: 4032,
|
|
height: 3024,
|
|
})),
|
|
renderCroppedImage: vi.fn(async () => new Blob([new Uint8Array(430080)])),
|
|
}
|
|
})
|
|
|
|
class FakeXMLHttpRequest {
|
|
static instances: FakeXMLHttpRequest[] = []
|
|
|
|
method = ''
|
|
url = ''
|
|
status = 0
|
|
responseText = ''
|
|
withCredentials = false
|
|
sentBody: FormData | null = null
|
|
|
|
private listeners: Record<string, Array<(event?: unknown) => void>> = {}
|
|
private uploadListeners: Record<string, (event: { loaded: number; total: number }) => void> = {}
|
|
|
|
upload = {
|
|
addEventListener: (
|
|
type: string,
|
|
cb: (event: { loaded: number; total: number }) => void,
|
|
) => {
|
|
this.uploadListeners[type] = cb
|
|
},
|
|
}
|
|
|
|
constructor() {
|
|
FakeXMLHttpRequest.instances.push(this)
|
|
}
|
|
|
|
open(method: string, url: string) {
|
|
this.method = method
|
|
this.url = url
|
|
}
|
|
|
|
send(body: FormData) {
|
|
this.sentBody = body
|
|
}
|
|
|
|
abort() {
|
|
this.dispatch('abort')
|
|
}
|
|
|
|
addEventListener(type: string, cb: (event?: unknown) => void) {
|
|
this.listeners[type] = this.listeners[type] || []
|
|
this.listeners[type].push(cb)
|
|
}
|
|
|
|
dispatch(type: string, event?: unknown) {
|
|
;(this.listeners[type] || []).forEach((cb) => cb(event))
|
|
}
|
|
|
|
resolve(status: number, body: string) {
|
|
this.status = status
|
|
this.responseText = body
|
|
this.dispatch('load')
|
|
}
|
|
}
|
|
|
|
function latestXHR() {
|
|
return FakeXMLHttpRequest.instances[FakeXMLHttpRequest.instances.length - 1]
|
|
}
|
|
|
|
function mib(value: number) {
|
|
return Math.round(value * 1024 * 1024)
|
|
}
|
|
|
|
function fileFixture(name: string, type: string, sizeMB: number) {
|
|
const file = new File(['x'], name, { type })
|
|
Object.defineProperty(file, 'size', {
|
|
value: mib(sizeMB),
|
|
configurable: true,
|
|
})
|
|
return file
|
|
}
|
|
|
|
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>>) {
|
|
const routes = {
|
|
'GET /v1/site/': () => mockResponse(200, oneSite),
|
|
...handlers,
|
|
}
|
|
return vi.fn((url: string, init?: RequestInit) => {
|
|
const key = routeFor(url, init)
|
|
const handler = routes[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: '<p>a</p>',
|
|
is_online: false,
|
|
...articleMeta,
|
|
created_at: '2026-03-12T09:14:00Z',
|
|
updated_at: FIXED_NOW,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
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(
|
|
handlers: Record<string, () => Response | Promise<Response>>,
|
|
path = '/article/12/edit',
|
|
) {
|
|
setActivePinia(createPinia())
|
|
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 findButton(text: string) {
|
|
return Array.from(document.body.querySelectorAll('button')).find(
|
|
(button) => button.textContent?.trim() === text,
|
|
)
|
|
}
|
|
|
|
function toolbarImageButton(wrapper: Awaited<ReturnType<typeof mount>>) {
|
|
return wrapper.findAll('button').find((button) => button.text() === 'Image')
|
|
}
|
|
|
|
const RealXMLHttpRequest = global.XMLHttpRequest
|
|
|
|
beforeEach(() => {
|
|
FakeXMLHttpRequest.instances = []
|
|
global.XMLHttpRequest = FakeXMLHttpRequest as unknown as typeof XMLHttpRequest
|
|
})
|
|
|
|
afterEach(async () => {
|
|
currentWrapper?.unmount()
|
|
currentWrapper = null
|
|
sessionStorage.clear()
|
|
global.XMLHttpRequest = RealXMLHttpRequest
|
|
await router.push('/login')
|
|
})
|
|
|
|
const EMPTY_LIBRARY = {
|
|
items: [],
|
|
total: 0,
|
|
page: 1,
|
|
size: 25,
|
|
counts: { all: 0, unused: 0, duplicates: 0 },
|
|
}
|
|
|
|
function articleHandlers(extra: Record<string, () => Response | Promise<Response>> = {}) {
|
|
return {
|
|
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
|
|
'GET /v1/media/?page=1&size=25': () => mockResponse(200, EMPTY_LIBRARY),
|
|
...extra,
|
|
}
|
|
}
|
|
|
|
describe("Opening the uploader from the toolbar's image button on an empty library shows Upload", () => {
|
|
it('opens ImageUploader on the idle Upload tab and sends no POST', async () => {
|
|
const wrapper = await mountEditor(articleHandlers())
|
|
|
|
await toolbarImageButton(wrapper)?.trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(document.body.textContent).toContain('Add an image')
|
|
expect(document.body.textContent).toContain('Drop an image here, or choose a file')
|
|
expect(document.body.textContent).toContain('PNG, JPEG, WEBP, SVG · max 10 MB')
|
|
expect(findButton('Cancel')).toBeTruthy()
|
|
expect(findButton('Insert image')).toBeUndefined()
|
|
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('Dropping a valid file onto the editor opens the uploader on the crop step', () => {
|
|
it('looks the hash up, skips the list, and uploads to /v1/media/ from the crop step', async () => {
|
|
const wrapper = await mountEditor(
|
|
articleHandlers({
|
|
[`GET /v1/media/?hash=${'a'.repeat(64)}&page=1&size=100`]: () =>
|
|
mockResponse(200, EMPTY_LIBRARY),
|
|
}),
|
|
)
|
|
|
|
const file = fileFixture('before.png', 'image/png', 3.9)
|
|
const dropEvent = new Event('drop', { bubbles: true, cancelable: true })
|
|
Object.defineProperty(dropEvent, 'dataTransfer', {
|
|
value: { files: { 0: file, length: 1 } },
|
|
})
|
|
wrapper.findComponent(ArticleEditor).element.dispatchEvent(dropEvent)
|
|
await flushPromises()
|
|
|
|
expect(document.body.textContent).toContain('Crop and resize')
|
|
expect(vi.mocked(global.fetch).mock.calls.map(([url]) => url)).not.toContain(
|
|
'/v1/media/?page=1&size=25',
|
|
)
|
|
|
|
findButton('Upload image')?.click()
|
|
await flushPromises()
|
|
|
|
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
|
|
expect(latestXHR().url).toBe('/v1/media/')
|
|
expect((latestXHR().sentBody?.get('file') as File).name).toBe('before.png')
|
|
})
|
|
})
|
|
|
|
describe('A successful upload inserts the image at the cursor and marks the draft dirty', () => {
|
|
it('inserts the image with its id, marks the draft dirty and toasts', async () => {
|
|
const wrapper = await mountEditor(articleHandlers())
|
|
|
|
await toolbarImageButton(wrapper)?.trigger('click')
|
|
await flushPromises()
|
|
|
|
const fileInput = document.body.querySelector<HTMLInputElement>('input[type="file"]')!
|
|
const file = fileFixture('north-wall.jpg', 'image/jpeg', 3.9)
|
|
Object.defineProperty(fileInput, 'files', {
|
|
value: { 0: file, length: 1 },
|
|
configurable: true,
|
|
})
|
|
fileInput.dispatchEvent(new Event('change'))
|
|
await flushPromises()
|
|
findButton('Upload image')?.click()
|
|
await flushPromises()
|
|
|
|
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
|
|
latestXHR().resolve(
|
|
201,
|
|
JSON.stringify({
|
|
id: 13,
|
|
filename: 'north-wall.jpg',
|
|
url: '/static/uploadedImages/13.jpg',
|
|
size: 4089446,
|
|
}),
|
|
)
|
|
await flushPromises()
|
|
|
|
const insertedBody = wrapper.findComponent(ArticleEditor).props('modelValue')
|
|
expect(insertedBody).toContain(
|
|
'<img src="/static/uploadedImages/13.jpg" alt="" data-image-id="13">',
|
|
)
|
|
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 added',
|
|
description: 'Save to keep it in the article.',
|
|
})
|
|
})
|
|
})
|