231 lines
6.5 KiB
TypeScript
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'])
|
|
})
|
|
})
|