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

231 lines
6.5 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { ApiError, NetworkError } from '@/composables/useApi'
import {
button,
calls,
entryWrites,
fieldWrapper,
installSelectPolyfills,
save,
text,
typeInto,
unmountAll,
} from './entryEditorKit'
import {
chipLabels,
emptyText,
liveText,
mountRelation,
openList,
optionLabels,
pickOption,
relationError,
relationInput,
searchBody,
settleSearch,
typeSearch,
} from './relationKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeEach(() => {
installSelectPolyfills()
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] })
})
afterEach(unmountAll)
const SKELETON_DELAY_MS = 150
const searches = () => calls.filter((url) => url.includes('/venues/entry?'))
const empty = { items: [], total: 0, page: 1, size: 10 }
const skeletons = () => document.body.querySelectorAll('[data-testid="relation-skeleton"]')
function failing(error: unknown) {
return () => {
throw error
}
}
describe('Picker loading', () => {
it('shows three skeleton rows after 150 ms and announces Searching…', async () => {
await mountRelation('/content/events/42', { search: () => new Promise(() => undefined) })
await typeSearch('venue', 'zzz')
await settleSearch(vi)
expect(skeletons()).toHaveLength(0)
await vi.advanceTimersByTimeAsync(SKELETON_DELAY_MS)
await flushPromises()
expect(skeletons()).toHaveLength(3)
expect(
text(document.body.querySelector('[data-testid="relation-empty"] [role="status"]')),
).toBe('Searching…')
expect(optionLabels()).toEqual([])
})
})
describe('No match', () => {
it('names the query and clears the search', async () => {
await mountRelation('/content/events/42', {
search: (key, query) => searchBody(key, query),
})
await openList('venue', vi, 'zzz')
expect(emptyText()).toBe('No Venues match "zzz" Clear search')
expect(liveText('venue')).toBe('No results')
button('Clear search')!.click()
await flushPromises()
await settleSearch(vi)
expect(relationInput('venue').value).toBe('')
expect(searches().at(-1)).toBe('/v1/content-type/venues/entry?page=1&size=10')
})
it('says the type has no entries at all', async () => {
await mountRelation('/content/events/42', { search: () => empty })
await openList('venue', vi)
expect(emptyText()).toBe('No Venues yet. Add one in Content, then come back.')
})
})
describe('Search failures', () => {
it.each([
['500', new ApiError(500)],
['a network error', new NetworkError()],
['404', new ApiError(404)],
])(
'shows the load error on %s and sends only the search again on Try again',
async (_name, error) => {
let fail = true
await mountRelation('/content/events/42', {
search: (key, query) => {
if (fail) {
throw error
}
return searchBody(key, query)
},
})
await openList('venue', vi, 'rue')
expect(emptyText()).toBe(
"Couldn't load Venues Check your connection and try again. Try again",
)
fail = false
const before = calls.length
button('Try again')!.click()
await flushPromises()
expect(calls.slice(before)).toEqual([
'/v1/content-type/venues/entry?q=rue&page=1&size=10',
])
expect(optionLabels()).toHaveLength(3)
},
)
it('shows the rate limit countdown on 429', async () => {
await mountRelation('/content/events/42', { search: failing(new ApiError(429, 3)) })
await openList('venue', vi, 'rue')
expect(emptyText()).toBe('Too many requests Try again in 0:03')
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
expect(emptyText()).toContain('Wait a moment before trying again.')
expect(button('Try again')).toBeDefined()
})
it('adds no local copy on 401', async () => {
await mountRelation('/content/events/42', { search: failing(new ApiError(401)) })
await openList('venue', vi, 'rue')
expect(emptyText()).toBe('')
})
})
describe('Load more', () => {
it('appends the next page', async () => {
const names = Array.from({ length: 12 }, (_, index) => `Hall ${index + 1}`)
await mountRelation('/content/events/42', {
search: (_key, query) => {
const page = Number(query.get('page'))
const slice = names.slice((page - 1) * 10, page * 10)
return {
items: slice.map((name, index) => ({
id: 100 + (page - 1) * 10 + index,
data: { name, city: '', details: '' },
relations: {},
})),
total: 12,
page,
size: 10,
}
},
})
await openList('venue', vi)
expect(optionLabels()).toHaveLength(10)
button('Load more')!.click()
await flushPromises()
expect(searches().at(-1)).toBe('/v1/content-type/venues/entry?page=2&size=10')
expect(optionLabels()).toHaveLength(12)
expect(button('Load more')).toBeUndefined()
})
})
describe('A missing target', () => {
it('shows a dashed chip and lets an optional field save with the same id', async () => {
await mountRelation('/content/events/42', {
venue: 3,
relations: { venue: { id: 3, missing: true } },
})
expect(chipLabels('venue')).toEqual(['Entry no longer exists'])
expect(fieldWrapper('venue').querySelector('[data-missing="true"]')).not.toBeNull()
expect(text(fieldWrapper('venue').querySelector('[data-testid="relation-help"]'))).toBe(
'You can still save. The entry keeps the link until you change it.',
)
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(entryWrites[0]!.body.data).toMatchObject({ venue: 3 })
})
it('refuses to save a required field with only a missing target', async () => {
await mountRelation('/content/events/42', {
venue: 3,
venueField: { required: true },
relations: { venue: { id: 3, missing: true } },
})
expect(text(fieldWrapper('venue').querySelector('[data-testid="relation-help"]'))).toBe(
'Venue is required. Choose another to save.',
)
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(entryWrites).toHaveLength(0)
expect(relationError('venue')).toBe('Choose a Venue.')
expect(document.activeElement).toBe(relationInput('venue'))
})
it('replaces the missing chip when another target is picked', async () => {
await mountRelation('/content/events/42', {
venue: 3,
relations: { venue: { id: 3, missing: true } },
})
await openList('venue', vi, 'rue')
await pickOption('Rue Verte yard')
expect(chipLabels('venue')).toEqual(['Rue Verte yard'])
})
})