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() 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('[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('input[name="label"]')!.disabled).toBe(false) expect(el('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('[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('input[name="label"]'), 'Title') expect(fieldKeyInput()!.value).toBe('title_2') expect(text(inspector())).toContain('Auto') expect(chip()).toBe('Unsaved changes') }) })