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() 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']) }) })