449 lines
14 KiB
TypeScript
449 lines
14 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { flushPromises } from '@vue/test-utils'
|
|
import { addCollection } from '@iconify/vue'
|
|
import lucide from 'virtual:lucide-subset'
|
|
import router from '@/router'
|
|
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
|
import { useContentTypesStore } from '@/stores/contentTypes'
|
|
import { useSessionStore } from '@/stores/session'
|
|
import {
|
|
button,
|
|
calls,
|
|
entryPage,
|
|
failWith,
|
|
mountAt,
|
|
networkFailure,
|
|
pending,
|
|
text,
|
|
unmountAll,
|
|
} from './schemaTestKit'
|
|
import {
|
|
all,
|
|
badgesOf,
|
|
chip,
|
|
click,
|
|
discardButton,
|
|
el,
|
|
fieldKeyInput,
|
|
inspector,
|
|
inspectorTitle,
|
|
keyInput,
|
|
limit,
|
|
main,
|
|
nameInput,
|
|
notice,
|
|
pickTile,
|
|
rowLabels,
|
|
rows,
|
|
saveButton,
|
|
selectRow,
|
|
tile,
|
|
typeInto,
|
|
} from './builderKit'
|
|
|
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
|
return { ...actual, useApi: vi.fn() }
|
|
})
|
|
|
|
addCollection(lucide)
|
|
|
|
afterEach(unmountAll)
|
|
|
|
const SKELETON_DELAY_MS = 150
|
|
const NOT_FOUND = 'Content type not found'
|
|
const COUNT_ERROR = "Couldn't load content type"
|
|
const COUNT_LINE = 'GET /v1/content-type/events/entry failed. Check your connection and try again.'
|
|
|
|
describe('Resolving the key of /schema/:key', () => {
|
|
it('opens the builder in edit mode for a custom type', async () => {
|
|
await mountAt('/schema/events')
|
|
|
|
expect(nameInput()!.value).toBe('Events')
|
|
expect(keyInput()!.value).toBe('events')
|
|
expect(rowLabels()).toHaveLength(7)
|
|
})
|
|
|
|
it('keeps the read-only view for a built-in type', async () => {
|
|
await mountAt('/schema/articles')
|
|
|
|
expect(main().querySelector('h1')?.textContent).toBe('Articles')
|
|
expect(nameInput()).toBeNull()
|
|
})
|
|
|
|
it('says not found for an unknown key', async () => {
|
|
await mountAt('/schema/unknown')
|
|
|
|
expect(text(main())).toContain(NOT_FOUND)
|
|
expect(nameInput()).toBeNull()
|
|
expect(calls.some((url) => url.includes('/entry'))).toBe(false)
|
|
})
|
|
|
|
it('keeps /schema/new as the create builder', async () => {
|
|
await mountAt('/schema/new')
|
|
|
|
expect(nameInput()!.value).toBe('')
|
|
expect(keyInput()!.readOnly).toBe(true)
|
|
expect(rowLabels()).toEqual(['Title'])
|
|
})
|
|
})
|
|
|
|
describe('Loading, the session store and the entry count', () => {
|
|
it('calls each list once and the count once, and never reads the type on its own', async () => {
|
|
await mountAt('/schema/events')
|
|
|
|
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
|
|
expect(calls.filter((url) => url === '/v1/builtin-type')).toHaveLength(1)
|
|
expect(
|
|
calls.filter((url) => url === '/v1/content-type/events/entry?page=1&size=5'),
|
|
).toHaveLength(1)
|
|
expect(calls.filter((url) => url === '/v1/content-type/events')).toHaveLength(0)
|
|
})
|
|
|
|
it('shows the skeleton, and no builder, until the count is known', async () => {
|
|
vi.useFakeTimers()
|
|
const gate = pending()
|
|
await mountAt('/schema/events', { entries: () => gate.promise })
|
|
|
|
expect(document.body.querySelector('[data-testid="schema-skeleton-row"]')).toBeNull()
|
|
vi.advanceTimersByTime(SKELETON_DELAY_MS)
|
|
await flushPromises()
|
|
|
|
expect(
|
|
document.body.querySelectorAll('[data-testid="schema-skeleton-row"]').length,
|
|
).toBeGreaterThan(0)
|
|
expect(nameInput()).toBeNull()
|
|
expect(saveButton()).toBeUndefined()
|
|
|
|
gate.resolve(entryPage(37))
|
|
await flushPromises()
|
|
|
|
expect(nameInput()!.value).toBe('Events')
|
|
expect(document.body.querySelector('[data-testid="schema-skeleton-row"]')).toBeNull()
|
|
})
|
|
|
|
it('shows the skeleton until the built-in list is known', async () => {
|
|
vi.useFakeTimers()
|
|
const gate = pending()
|
|
await mountAt('/schema/events', { builtin: () => gate.promise })
|
|
vi.advanceTimersByTime(SKELETON_DELAY_MS)
|
|
await flushPromises()
|
|
|
|
expect(nameInput()).toBeNull()
|
|
expect(calls.filter((url) => url.includes('/entry'))).toHaveLength(0)
|
|
|
|
gate.resolve([])
|
|
await flushPromises()
|
|
|
|
expect(nameInput()!.value).toBe('Events')
|
|
})
|
|
})
|
|
|
|
describe('The count cannot be loaded', () => {
|
|
it.each([
|
|
['answers 500', failWith(500)],
|
|
['fails (network)', networkFailure],
|
|
])('shows the error when the count %s, then opens on Try again', async (_, failure) => {
|
|
let respond = failure as () => unknown
|
|
await mountAt('/schema/events', { entries: () => respond() })
|
|
|
|
expect(text(main())).toContain(COUNT_ERROR)
|
|
expect(text(main())).toContain(COUNT_LINE)
|
|
expect(nameInput()).toBeNull()
|
|
expect(saveButton()).toBeUndefined()
|
|
|
|
respond = () => entryPage(37)
|
|
await click(button('Try again'))
|
|
|
|
expect(nameInput()!.value).toBe('Events')
|
|
expect(
|
|
calls.filter((url) => url === '/v1/content-type/events/entry?page=1&size=5'),
|
|
).toHaveLength(2)
|
|
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
|
|
})
|
|
|
|
it('says not found when the count answers 404', async () => {
|
|
await mountAt('/schema/events', { entries: failWith(404) })
|
|
|
|
expect(text(main())).toContain(NOT_FOUND)
|
|
expect(nameInput()).toBeNull()
|
|
})
|
|
|
|
it('shows the access page when the count answers 403', async () => {
|
|
await mountAt('/schema/events', { entries: failWith(403) })
|
|
|
|
expect(text(main())).toContain("You don't have access to this page")
|
|
expect(nameInput()).toBeNull()
|
|
})
|
|
|
|
it('shows the list error when the custom list fails, and retries it', async () => {
|
|
let respond = failWith(500) as () => unknown
|
|
await mountAt('/schema/events', { content: () => respond() })
|
|
|
|
expect(text(main())).toContain("Couldn't load content types")
|
|
expect(nameInput()).toBeNull()
|
|
|
|
const { customTypes } = await import('./schemaTestKit')
|
|
respond = () => [...customTypes]
|
|
await click(button('Try again'))
|
|
|
|
expect(nameInput()!.value).toBe('Events')
|
|
})
|
|
})
|
|
|
|
describe('Roles and sessions in edit mode', () => {
|
|
it.each(['owner', 'admin'])('shows the builder to a verified %s', async (role) => {
|
|
await mountAt('/schema/events', { role })
|
|
|
|
expect(nameInput()!.value).toBe('Events')
|
|
})
|
|
|
|
it('tells a verified user who is neither to go home, leaving the URL', async () => {
|
|
await mountAt('/schema/events', { role: 'user' })
|
|
|
|
expect(text(main())).toContain("You don't have access to this page")
|
|
expect(button('Go to Home')).toBeInstanceOf(HTMLButtonElement)
|
|
expect(router.currentRoute.value.path).toBe('/schema/events')
|
|
expect(calls.filter((url) => url.includes('/entry'))).toHaveLength(0)
|
|
})
|
|
|
|
it('redirects an unverified user to /register/pending', async () => {
|
|
await mountAt('/schema/events', { verified: false })
|
|
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
|
|
})
|
|
|
|
it('redirects a signed-out visitor to /login', async () => {
|
|
await mountAt('/schema/events')
|
|
useSessionStore().clear()
|
|
await router.push('/schema/events')
|
|
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
|
|
})
|
|
})
|
|
|
|
describe('Session-owned store', () => {
|
|
it('resets both lists on sign-out and reloads them for the next session', async () => {
|
|
await mountAt('/schema/events')
|
|
const store = useContentTypesStore()
|
|
expect(store.items).toHaveLength(3)
|
|
expect(store.builtinItems).toHaveLength(5)
|
|
|
|
useSessionStore().clear()
|
|
await flushPromises()
|
|
|
|
expect(store.items).toEqual([])
|
|
expect(store.builtinItems).toEqual([])
|
|
expect(store.status).toBe('idle')
|
|
expect(store.builtinStatus).toBe('idle')
|
|
})
|
|
|
|
it('starts a second visit from the store, not from an old draft', async () => {
|
|
await mountAt('/schema/events')
|
|
await typeInto(nameInput(), 'Events and talks')
|
|
await unmountAll()
|
|
|
|
await mountAt('/schema/events')
|
|
|
|
expect(nameInput()!.value).toBe('Events')
|
|
expect(chip()).toBe('Saved')
|
|
})
|
|
})
|
|
|
|
describe('Editing an existing type, desktop (design state 2)', () => {
|
|
it('shows the breadcrumb, a locked key, the chip, the notice and the list', async () => {
|
|
await mountAt('/schema/events')
|
|
|
|
const crumbs = text(document.body.querySelector('nav[aria-label="breadcrumb"]'))
|
|
expect(crumbs).toContain('Schema')
|
|
expect(crumbs).toContain('Events')
|
|
expect(
|
|
document.body.querySelector('nav[aria-label="breadcrumb"] a[href="/schema"]'),
|
|
).not.toBeNull()
|
|
expect(nameInput()!.readOnly).toBe(false)
|
|
expect(nameInput()!.disabled).toBe(false)
|
|
expect(keyInput()!.readOnly).toBe(true)
|
|
expect(keyInput()!.className).toContain('font-mono')
|
|
expect(el('[data-testid="slug-lock"]')).not.toBeNull()
|
|
expect(document.body.querySelector('button[aria-label="Edit key"]')).toBeNull()
|
|
expect(text(main())).not.toContain('Auto')
|
|
expect(text(main())).not.toContain('Reset to match the name')
|
|
expect(chip()).toBe('Saved')
|
|
expect(notice()).toBe(
|
|
'37 entries use this content type. Changes are checked against them when you save.',
|
|
)
|
|
expect(text(el('[data-testid="field-count"]'))).toBe('7 of 50')
|
|
expect(rowLabels()).toEqual([
|
|
'Title',
|
|
'Starts on',
|
|
'Ends on',
|
|
'Online',
|
|
'Capacity',
|
|
'Category',
|
|
'Description',
|
|
])
|
|
expect(rows().every((row) => !/New|Changed/.test(text(row)))).toBe(true)
|
|
expect(button('Add field')).toBeInstanceOf(HTMLButtonElement)
|
|
expect(inspectorTitle()).toBe('Field: Title')
|
|
expect(text(el('[data-testid="inspector-pane"] h2')!.parentElement)).not.toContain('New')
|
|
expect(saveButton()!.disabled).toBe(true)
|
|
expect(discardButton()!.disabled).toBe(true)
|
|
expect(el('[data-testid="api-preview-toggle"]')!.getAttribute('aria-expanded')).toBe(
|
|
'false',
|
|
)
|
|
})
|
|
|
|
it('puts the notice under the type bar and above the list', async () => {
|
|
await mountAt('/schema/events')
|
|
|
|
const bar = el('[data-testid="entries-notice"]')!
|
|
const header = el('[data-testid="field-count"]')!
|
|
expect(bar.compareDocumentPosition(header) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
|
expect(
|
|
nameInput()!.compareDocumentPosition(bar) & Node.DOCUMENT_POSITION_FOLLOWING,
|
|
).toBeTruthy()
|
|
})
|
|
})
|
|
|
|
describe('Editing an existing type, mobile (design state 2)', () => {
|
|
it('stacks the type bar, the notice, the rows and the API row, with the bottom bar', async () => {
|
|
await mountAt('/schema/events', { width: MOBILE_WIDTH })
|
|
|
|
expect(text(main())).not.toContain('Field: Title')
|
|
expect(el('[data-testid="inspector-pane"]')).toBeNull()
|
|
const order = [
|
|
nameInput(),
|
|
keyInput(),
|
|
el('[data-testid="entries-notice"]'),
|
|
rows()[0],
|
|
el('[data-testid="api-row"]'),
|
|
]
|
|
order.forEach((node, index) => {
|
|
expect(node).not.toBeNull()
|
|
if (index > 0) {
|
|
expect(
|
|
order[index - 1]!.compareDocumentPosition(node!) &
|
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
).toBeTruthy()
|
|
}
|
|
})
|
|
const bar = el('.sticky')!
|
|
expect(text(bar)).toBe('Saved Discard Save')
|
|
expect(text(document.body)).toContain('Events')
|
|
})
|
|
})
|
|
|
|
describe('The notice follows the count', () => {
|
|
it.each([
|
|
[0, null],
|
|
[1, '1 entry uses this content type. Changes are checked against it when you save.'],
|
|
[37, '37 entries use this content type. Changes are checked against them when you save.'],
|
|
[
|
|
1200,
|
|
'1,200 entries use this content type. Changes are checked against them when you save.',
|
|
],
|
|
])('for %s entries', async (total, copy) => {
|
|
await mountAt('/schema/events', { entries: () => entryPage(total) })
|
|
|
|
if (copy === null) {
|
|
expect(el('[data-testid="entries-notice"]')).toBeNull()
|
|
} else {
|
|
expect(notice()).toBe(copy)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('The locked type key (design state 3)', () => {
|
|
const TIP = "The key is used in URLs and the API, so it can't change after the type is created."
|
|
|
|
it('opens a tooltip on focus, closes it on blur and Esc, and refuses typing', async () => {
|
|
await mountAt('/schema/events')
|
|
|
|
keyInput()!.focus()
|
|
await flushPromises()
|
|
expect(text(document.body)).toContain(TIP)
|
|
|
|
keyInput()!.blur()
|
|
await flushPromises()
|
|
expect(text(document.body)).not.toContain(TIP)
|
|
|
|
keyInput()!.focus()
|
|
await flushPromises()
|
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
|
await flushPromises()
|
|
expect(text(document.body)).not.toContain(TIP)
|
|
|
|
await typeInto(keyInput(), 'changed')
|
|
expect(keyInput()!.readOnly).toBe(true)
|
|
expect(text(main())).not.toContain('Use 2 to 40 lowercase letters')
|
|
})
|
|
})
|
|
|
|
describe('Saved fields are locked', () => {
|
|
it('shows the lock, the help and the dimmed tiles for a saved field', async () => {
|
|
await mountAt('/schema/events')
|
|
await selectRow('Starts on')
|
|
|
|
expect(fieldKeyInput()!.value).toBe('starts_on')
|
|
expect(fieldKeyInput()!.readOnly).toBe(true)
|
|
expect(inspector()!.querySelector('[data-testid="slug-lock"]')).not.toBeNull()
|
|
expect(text(inspector())).toContain("Used in the API, so it can't change.")
|
|
expect(inspector()!.querySelector('button[aria-label="Edit key"]')).toBeNull()
|
|
expect(text(inspector())).toContain("Can't change after saving")
|
|
expect(all('[role="radio"]')).toHaveLength(8)
|
|
expect(tile('Date').getAttribute('data-state')).toBe('checked')
|
|
const others = all<HTMLButtonElement>('[role="radio"]').filter(
|
|
(radio) => radio !== tile('Date'),
|
|
)
|
|
expect(others.every((radio) => radio.disabled || radio.hasAttribute('data-disabled'))).toBe(
|
|
true,
|
|
)
|
|
expect(tile('Date').hasAttribute('data-disabled')).toBe(false)
|
|
})
|
|
|
|
it('does not change the type when another tile is activated', async () => {
|
|
await mountAt('/schema/events')
|
|
await selectRow('Starts on')
|
|
|
|
await pickTile('Number')
|
|
|
|
expect(tile('Date').getAttribute('data-state')).toBe('checked')
|
|
expect(chip()).toBe('Saved')
|
|
})
|
|
|
|
it('keeps label, Required, Unique, default and limits editable', async () => {
|
|
await mountAt('/schema/events')
|
|
await selectRow('Capacity')
|
|
|
|
expect(limit('max')!.disabled).toBe(false)
|
|
expect(limit('min')!.disabled).toBe(false)
|
|
expect(el<HTMLInputElement>('input[name="label"]')!.disabled).toBe(false)
|
|
expect(el<HTMLInputElement>('input[name="default"]')!.disabled).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('Fields added since the last save', () => {
|
|
it('selects a new row with New, an Auto key, seven selectable tiles and no lock', async () => {
|
|
await mountAt('/schema/events')
|
|
await click(button('Add field'))
|
|
|
|
expect(inspectorTitle()).toBe('Field: Untitled field')
|
|
expect(text(el('[data-testid="inspector-pane"]')!)).toContain('New')
|
|
expect(badgesOf('Untitled field')).toContain('New')
|
|
expect(inspector()!.querySelector('[data-testid="slug-lock"]')).toBeNull()
|
|
expect(text(inspector())).not.toContain("Can't change after saving")
|
|
expect(all<HTMLButtonElement>('[role="radio"]').every((radio) => !radio.disabled)).toBe(
|
|
true,
|
|
)
|
|
})
|
|
|
|
it('derives a key for a typed label that does not collide with a saved key', async () => {
|
|
await mountAt('/schema/events')
|
|
await click(button('Add field'))
|
|
|
|
await typeInto(el<HTMLInputElement>('input[name="label"]'), 'Title')
|
|
|
|
expect(fieldKeyInput()!.value).toBe('title_2')
|
|
expect(text(inspector())).toContain('Auto')
|
|
expect(chip()).toBe('Unsaved changes')
|
|
})
|
|
})
|