Files
RenewCMS/web/src/views/__tests__/EntryEditorLoad.spec.ts
T

364 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, describe, expect, it, vi } from 'vitest'
import router from '@/router'
import { flushPromises } from '@vue/test-utils'
import {
MOBILE_WIDTH,
arrived,
button,
calls,
chip,
control,
customTypes,
entry42,
entryWith,
eventsFields,
failWith,
failWithBody,
fieldWrapper,
hasText,
setField,
typeInto,
main,
mountEditor,
networkFailure,
pending,
text,
unmountAll,
} from './entryEditorKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
afterEach(unmountAll)
function entryRequests() {
return calls.filter((url) => /\/entry\/\d+$/.test(url))
}
describe('1 New entry', () => {
it('shows the title, the subtitle, the chip and the actions', async () => {
await mountEditor('/content/events/new')
expect(text(main())).toContain('New entry Events · Fields marked * are required.')
expect(text(chip())).toBe('New entry')
expect(button('Cancel')).toBeTruthy()
expect(button('Save')!.disabled).toBe(false)
expect(button('Delete')).toBeUndefined()
expect(entryRequests()).toEqual([])
})
it('marks required labels and shows helps, placeholders and defaults', async () => {
await mountEditor('/content/events/new')
expect(text(fieldWrapper('title'))).toContain('Title *')
expect(text(fieldWrapper('starts_on'))).toContain('Starts on *')
expect(text(fieldWrapper('ends_on'))).not.toContain('*')
expect(text(fieldWrapper('title'))).toContain('Each entry needs a different title.')
expect(text(fieldWrapper('capacity'))).toContain('Between 1 and 5000.')
expect(control('capacity').getAttribute('placeholder')).toBe('Enter a number')
expect(text(fieldWrapper('category'))).toContain('Choose an option')
expect(text(fieldWrapper('starts_on'))).toContain('Pick a date')
expect(text(fieldWrapper('online'))).toContain('Off')
expect(text(fieldWrapper('online'))).toContain('Default')
expect(text(fieldWrapper('title'))).not.toContain('Default')
})
it('shows the footer of an unsaved entry', async () => {
await mountEditor('/content/events/new')
expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toBe(
'Entry Not saved yet Created by – Updated by –',
)
})
it('reads Content, Events and New entry in the breadcrumb', async () => {
await mountEditor('/content/events/new')
expect(text(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe(
'Content Events New entry',
)
})
it('has no required marker for assistive technology', async () => {
await mountEditor('/content/events/new')
const star = fieldWrapper('title').querySelector('[aria-hidden="true"]')
expect(star?.textContent?.trim()).toBe('*')
expect(control('title').getAttribute('aria-required')).toBe('true')
})
})
describe('2 Editing', () => {
it('shows the entry, the chip, the actions and the footer', async () => {
await mountEditor('/content/events/42')
expect(text(main())).toContain('Lime plaster workshop Events · Entry 42')
expect(text(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe(
'Content Events Lime plaster workshop',
)
expect(text(chip())).toBe('Saved')
expect(button('Delete')).toBeTruthy()
expect(button('Save')!.disabled).toBe(true)
expect(control('title').value).toBe('Lime plaster workshop')
expect(control('capacity').value).toBe('18')
expect(text(fieldWrapper('online'))).not.toContain('Default')
expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toBe(
'Entry events / 42 Created by Inès Duarte · 12 Oct 2026, 09:14 Updated by Marta Alves · 15 Oct 2026, 11:02',
)
})
it('sends the entry request with the type request and the lookup once', async () => {
await mountEditor('/content/events/42')
expect(calls).toContain('/v1/content-type/events')
expect(calls).toContain('/v1/content-type/events/entry/42')
expect(calls.filter((url) => url.startsWith('/v1/user/lookup'))).toEqual([
'/v1/user/lookup?ids=4,3',
])
})
it('reads a dash and keeps the date when the lookup fails or lacks an id', async () => {
await mountEditor('/content/events/42', {
lookup: () => [{ id: 3, display_name: 'Marta Alves' }],
})
expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toContain(
'Created by – · 12 Oct 2026, 09:14 Updated by Marta Alves · 15 Oct 2026, 11:02',
)
})
it('reads dashes when the lookup fails', async () => {
await mountEditor('/content/events/42', { lookup: failWith(500) })
expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toContain(
'Updated by – · 15 Oct 2026, 11:02',
)
})
it('shows a trash action on mobile and no Delete button', async () => {
await mountEditor('/content/events/42', { width: MOBILE_WIDTH })
expect(button('Delete')).toBeUndefined()
expect(document.body.querySelector('button[aria-label="Delete"]')).toBeTruthy()
expect(button('Cancel')).toBeTruthy()
expect(button('Save')!.disabled).toBe(true)
})
it('shows the new entry layout on mobile without an action', async () => {
await mountEditor('/content/events/new', { width: MOBILE_WIDTH })
expect(document.body.querySelector('button[aria-label="Delete"]')).toBeNull()
expect(button('Cancel')).toBeTruthy()
expect(text(chip())).toBe('New entry')
})
})
describe('The page title', () => {
it.each([
['text', { title: 'Lime plaster workshop' }, 'Lime plaster workshop'],
['null', { title: null }, 'Entry 42'],
['number', { title: 18 }, '18'],
])('reads %s', async (_name, data, title) => {
await mountEditor('/content/events/42', { entry: () => entryWith(data) })
expect(text(main().querySelector('h1'))).toBe(title)
})
it('reads the first line of rich text', async () => {
const types = customTypes.map((type) =>
type.key === 'events'
? { ...type, fields: [{ ...eventsFields[6]!, key: 'body', label: 'Body' }] }
: type,
)
await mountEditor('/content/events/42', {
type: () => types[0],
entry: () => entryWith({ body: 'First line\n\nSecond' }),
})
expect(text(main().querySelector('h1'))).toBe('First line')
})
})
describe('Defaults', () => {
it.each([
['Capacity', 'capacity', { default: 100 }, '100'],
['Title', 'title', { default: 'Untitled' }, 'Untitled'],
])('pre-fills and tags %s', async (_label, key, rest, shown) => {
const fields = eventsFields.map((field) =>
field.key === key ? { ...field, ...rest } : field,
)
await mountEditor('/content/events/new', {
type: () => ({ ...customTypes[0]!, fields }),
})
expect(control(key).value).toBe(shown)
expect(text(fieldWrapper(key))).toContain('Default')
})
})
describe('Create-only defaults', () => {
const withDefaults = eventsFields.map((field) =>
field.key === 'capacity'
? { ...field, default: 100 }
: field.key === 'title'
? { ...field, default: 'Untitled' }
: field,
)
const type = () => ({ ...customTypes[0]!, fields: withDefaults })
it('pre-fills and tags every default on a new entry', async () => {
await mountEditor('/content/events/new', { type })
expect(control('capacity').value).toBe('100')
expect(control('title').value).toBe('Untitled')
expect(text(fieldWrapper('capacity'))).toContain('Default')
expect(text(fieldWrapper('title'))).toContain('Default')
})
it('never pre-fills or tags a default on an existing entry', async () => {
await mountEditor('/content/events/42', {
type,
entry: () => entryWith({ capacity: null, title: 'Stored' }),
})
expect(control('capacity').value).toBe('')
expect(control('title').value).toBe('Stored')
expect(text(main())).not.toContain('Default')
})
})
describe('Session not ready', () => {
it('shows the shell and issues no request', async () => {
await mountEditor('/content/events/42', { status: 'checking' })
expect(calls.filter((url) => url.includes('/entry') || url.includes('lookup'))).toEqual([])
expect(document.body.querySelector('[data-entry-field]')).toBeNull()
})
})
describe('Navigating between entries', () => {
const entry = (_key: string, id: number) =>
entryWith({ title: `Entry title ${id}`, capacity: id }, { id })
it('carries no value, error or chip from /42 to /43', async () => {
const wrapper = await mountEditor('/content/events/42', {
entry,
writeEntry: failWith(500),
})
await setField(wrapper, 'capacity', 20)
document.body.querySelector<HTMLButtonElement>('button[type="submit"]')!.click()
await flushPromises()
await setField(wrapper, 'capacity', 42)
expect(hasText("Couldn't save the entry")).toBe(true)
await router.push('/content/events/43')
await flushPromises()
expect(control('title').value).toBe('Entry title 43')
expect(control('capacity').value).toBe('43')
expect(hasText("Couldn't save the entry")).toBe(false)
expect(text(chip())).toBe('Saved')
expect(text(main().querySelector('h1'))).toBe('Entry title 43')
})
it('carries nothing from an entry to another type new form', async () => {
await mountEditor('/content/events/42', { entry })
await typeInto('title', 'Typed here')
void router.push('/content/recipes/new')
await vi.waitFor(() => expect(hasText('Leave without saving?')).toBe(true))
button('Discard changes')!.click()
await arrived('/content/recipes/new')
await flushPromises()
expect(control('title').value).toBe('')
expect(text(chip())).toBe('New entry')
expect(text(main().querySelector('h1'))).toBe('New entry')
expect(hasText('Typed here')).toBe(false)
})
})
describe('Entry not found and load failures', () => {
it.each(['abc', '4294967296', '-1'])(
'shows Entry not found for /content/events/%s with no entry request',
async (id) => {
await mountEditor(`/content/events/${id}`)
expect(text(main())).toContain('Entry not found')
expect(text(main())).toContain('It may have been deleted by someone else.')
expect(entryRequests()).toEqual([])
expect(button('Back to Events')).toBeTruthy()
},
)
it('sends id 0 as is and shows Entry not found when the API answers 404', async () => {
await mountEditor('/content/events/0', { entry: failWithBody(404, 'entry_not_found') })
expect(entryRequests()).toEqual(['/v1/content-type/events/entry/0'])
expect(text(main())).toContain('Entry not found')
})
it('shows Entry not found on 404 entry_not_found and goes back to the list', async () => {
await mountEditor('/content/events/999', { entry: failWithBody(404, 'entry_not_found') })
expect(text(main())).toContain('Entry not found')
button('Back to Events')!.click()
await arrived('/content/events')
})
it('shows the content type page on 404 content_type_not_found', async () => {
await mountEditor('/content/events/42', {
type: failWithBody(404, 'content_type_not_found'),
entry: failWithBody(404, 'content_type_not_found'),
})
expect(text(main())).toContain('Content type not found')
})
it('shows the access page on 403', async () => {
await mountEditor('/content/events/42', { entry: failWith(403) })
expect(text(main())).toContain("You don't have access to Events")
})
it.each([
['500', failWith(500)],
['a network failure', networkFailure],
])('shows the load error on %s and retries only the entry', async (_name, failure) => {
let attempts = 0
await mountEditor('/content/events/42', {
entry: () => {
attempts++
if (attempts === 1) {
return failure()
}
return entry42
},
})
expect(text(main())).toContain("Couldn't load the entry")
expect(text(main())).toContain(
'GET /v1/content-type/events/entry/42 failed. Check your connection and try again.',
)
expect(document.body.querySelector('form')).toBeNull()
const typeRequests = calls.filter((url) => url === '/v1/content-type/events').length
button('Try again')!.click()
await vi.waitFor(() => expect(control('title').value).toBe('Lime plaster workshop'))
expect(calls.filter((url) => url === '/v1/content-type/events').length).toBe(typeRequests)
})
it('shows a skeleton with aria-busy while loading', async () => {
const gate = pending()
await mountEditor('/content/events/42', { entry: () => gate.promise })
const skeleton = document.body.querySelector('[data-testid="entry-editor-skeleton"]')
expect(skeleton?.getAttribute('aria-busy')).toBe('true')
expect(button('Save')).toBeUndefined()
expect(button('Delete')).toBeUndefined()
})
})