import { afterEach, beforeEach, 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 BuilderActions from '@/components/BuilderActions.vue' import { useContentTypesStore } from '@/stores/contentTypes' import { button, calls, createdType, failWithBody, mountAt, networkFailure, pending, posts, text, unmountAll, } from './schemaTestKit' import { addField, all, buildEvents, byLabel, chip, click, discardButton, el, keyField, keyInput, labelInput, main, nameInput, rowLabels, saveButton, typeInto, } from './builderKit' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn() } }) addCollection(lucide) beforeEach(() => { Element.prototype.scrollIntoView = () => undefined }) afterEach(async () => { vi.restoreAllMocks() await unmountAll() }) const SAVE_FAILED_TITLE = "Couldn't save the content type" const SAVE_FAILED_TEXT = 'Something went wrong on our side. Your changes are still here. Try again in a moment.' const EVENTS_BODY = { key: 'events', name: 'Events', fields: [ { key: 'title', label: 'Title', type: 'text', required: true, unique: true, default: null, min: null, max: 80, options: [], relation: null, }, { key: 'starts_on', label: 'Starts on', type: 'date', required: false, unique: false, default: null, min: '2026-01-01', max: '2027-12-31', options: [], relation: null, }, { key: 'category', label: 'Category', type: 'select', required: false, unique: false, default: null, min: null, max: null, options: ['Talk', 'Workshop', 'Meetup'], relation: null, }, ], } describe('Saving a valid type', () => { it('posts once the exact body, in list order, with no icon', async () => { await mountAt('/schema/new') await buildEvents() await click(saveButton()) expect(posts).toHaveLength(1) expect(posts[0]!.url).toBe('/v1/content-type') expect(posts[0]!.body).toEqual(EVENTS_BODY) expect('icon' in posts[0]!.body).toBe(false) }) }) describe('Saving (design state 11, create)', () => { async function startSaving() { const gate = pending() await mountAt('/schema/new', { create: () => gate.promise }) await buildEvents() await click(saveButton()) return gate } it('shows the chip with a loader, dims and disables the form, and sends one request only', async () => { await startSaving() expect(chip()).toBe('Saving…') expect(el('[data-testid="builder-chip"] svg')).not.toBeNull() expect(saveButton()).toBeUndefined() const saving = button('Saving…') as HTMLButtonElement expect(saving.disabled).toBe(true) expect(saving.querySelector('svg')).not.toBeNull() expect(discardButton()!.disabled).toBe(true) expect(nameInput()!.disabled).toBe(true) expect(keyInput()!.disabled).toBe(true) expect(labelInput()!.disabled).toBe(true) expect((button('Add field') as HTMLButtonElement).disabled).toBe(true) expect(all('[data-grip]').every((grip) => grip.disabled)).toBe(true) expect((byLabel('Remove') as HTMLButtonElement).disabled).toBe(true) saving.click() await flushPromises() expect(posts).toHaveLength(1) }) it('leaves the form editable again once the request settles', async () => { const gate = await startSaving() gate.resolve(createdType(posts[0]!.body)) await flushPromises() expect(posts).toHaveLength(1) }) }) describe('Double activation while saving', () => { it('sends one POST however often Save is activated programmatically', async () => { const gate = pending() const mounted = await mountAt('/schema/new', { create: () => gate.promise }) await buildEvents() await click(saveButton()) const actions = mounted.findComponent(BuilderActions) actions.vm.$emit('save') actions.vm.$emit('save') await flushPromises() expect(posts).toHaveLength(1) expect((button('Saving…') as HTMLButtonElement).disabled).toBe(true) expect(actions.props('canSave')).toBe(false) expect(actions.props('canReveal')).toBe(false) gate.resolve(createdType(posts[0]!.body)) await flushPromises() expect(posts).toHaveLength(1) }) }) describe('Created type reaching the store', () => { it('inserts the new type before the first later name and needs no second list request', async () => { await mountAt('/schema/new') await typeInto(nameInput(), 'Newsletters') await click(saveButton()) await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema')) await flushPromises() expect(useContentTypesStore().items.map((type) => type.name)).toEqual([ 'Events', 'Newsletters', 'Recipes', 'Press mentions', ]) expect(el('nav a[href="/content/newsletters"]')).not.toBeNull() expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1) }) }) describe('Saved (design state 12, create)', () => { it('toasts, adds the type to the store and the sidebar, replaces the URL and does not prompt', async () => { await mountAt('/schema/new', { create: (body) => ({ ...createdType(body), fields: new Array(6) }), }) await buildEvents() await click(saveButton()) await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema')) await flushPromises() expect(text(document.body)).toContain('Content type saved') expect(text(document.body)).toContain('Events now has 6 fields.') expect(text(document.body)).not.toContain('Leave without saving?') expect(el('nav a[href="/content/events"]')).not.toBeNull() const listed = all('[data-testid="schema-row"]').map((row) => text(row)) expect(listed.some((row) => row.includes('Events'))).toBe(true) expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1) router.back() await flushPromises() expect(router.currentRoute.value.path).not.toBe('/schema/new') }) it('reads "1 field" for a single field', async () => { await mountAt('/schema/new', { create: (body) => ({ ...createdType(body), fields: new Array(1) }), }) await typeInto(nameInput(), 'Events') await click(saveButton()) await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema')) await flushPromises() expect(text(document.body)).toContain('Events now has 1 field.') }) }) describe('Navigation failing after a created type', () => { async function saveWithBrokenNavigation() { vi.spyOn(router, 'replace').mockRejectedValue(new Error('chunk load failed')) await mountAt('/schema/new') await typeInto(nameInput(), 'Events') await click(saveButton()) } it('keeps the type, shows no failure and blocks a second POST', async () => { await saveWithBrokenNavigation() expect(posts).toHaveLength(1) expect(text(document.body)).not.toContain(SAVE_FAILED_TITLE) expect(chip()).toBe('') expect(saveButton()!.disabled).toBe(true) expect(el('nav a[href="/content/events"]')).not.toBeNull() expect(text(document.body)).toContain('Content type saved') }) it('treats the form as editable again after the next edit', async () => { await saveWithBrokenNavigation() await typeInto(nameInput(), 'Events 2') expect(chip()).toBe('Unsaved changes') expect(saveButton()!.disabled).toBe(false) }) }) describe('A failed save then an edit', () => { it('returns to editing so Save works again', async () => { let attempts = 0 await mountAt('/schema/new', { create: (body) => { attempts++ if (attempts === 1) { throw new Error('boom') } return createdType(body) }, }) await typeInto(nameInput(), 'Events') await click(saveButton()) expect(chip()).toBe('Save failed') await typeInto(nameInput(), 'Events 2') expect(chip()).toBe('Unsaved changes') expect(saveButton()!.disabled).toBe(false) }) }) describe('Key already taken (design state 14)', () => { async function reachTaken() { await mountAt('/schema/new', { create: failWithBody(409, 'content_type_key_taken'), }) await typeInto(nameInput(), 'Events') await click(saveButton()) } it('shows the message under Key, makes it editable with focus, and counts one problem', async () => { await reachTaken() expect(text(keyField().querySelector('[role="alert"]'))).toBe( 'The key "events" is already used by another content type. Choose a different key.', ) expect(keyInput()!.readOnly).toBe(false) expect(document.activeElement).toBe(keyInput()) expect(text(keyField())).not.toContain('Auto') expect(chip()).toBe('1 problem to fix') expect(saveButton()!.disabled).toBe(true) expect(nameInput()!.value).toBe('Events') expect(rowLabels()).toEqual(['Title']) }) it('clears the error and enables Save when the key is edited', async () => { await reachTaken() await typeInto(keyInput(), 'events-2') expect(keyField().querySelector('[role="alert"]')).toBeNull() expect(saveButton()!.disabled).toBe(false) }) }) describe('The server refuses a key or a field', () => { it('shows the key message under Key for 400 invalid_content_type_key', async () => { await mountAt('/schema/new', { create: failWithBody(400, 'invalid_content_type_key'), }) await typeInto(nameInput(), 'Events') await click(saveButton()) expect(text(keyField().querySelector('[role="alert"]'))).toBe( 'Use 2 to 40 lowercase letters, digits or hyphens.', ) expect(chip()).toBe('1 problem to fix') }) it('shows the banner for 422 invalid_field and keeps the edits and Save', async () => { await mountAt('/schema/new', { create: failWithBody(422, 'invalid_field') }) await typeInto(nameInput(), 'Events') await click(saveButton()) expect(text(main())).toContain('Check the fields marked in the list.') expect(nameInput()!.value).toBe('Events') expect(saveButton()!.disabled).toBe(false) }) it('shows the access page for 403', async () => { await mountAt('/schema/new', { create: failWithBody(403, 'Only owners and admins') }) await typeInto(nameInput(), 'Events') await click(saveButton()) expect(text(main())).toContain("You don't have access to this page") expect(nameInput()).toBeNull() }) it('leaves the redirect to the API client for 401', async () => { await mountAt('/schema/new', { create: failWithBody(401, '') }) await typeInto(nameInput(), 'Events') await click(saveButton()) expect(text(main())).not.toContain(SAVE_FAILED_TITLE) expect(nameInput()!.value).toBe('Events') }) }) describe('Save failed (design state 13)', () => { it.each([ ['answers 500', failWithBody(500, 'boom')], ['fails (network)', networkFailure], ])( 'shows the alert, keeps every edit and retries with the same body when the POST %s', async (_, failure) => { let respond = failure await mountAt('/schema/new', { create: (body) => respond() ?? body }) await buildEvents() await click(saveButton()) expect(text(main())).toContain(SAVE_FAILED_TITLE) expect(text(main())).toContain(SAVE_FAILED_TEXT) expect(chip()).toBe('Save failed') expect(nameInput()!.value).toBe('Events') expect(rowLabels()).toEqual(['Title', 'Starts on', 'Category']) expect(saveButton()!.disabled).toBe(false) respond = () => undefined const first = posts[0]!.body await click(button('Try again')) expect(posts).toHaveLength(2) expect(posts[1]!.body).toEqual(first) }, ) }) describe('Offline (design state 16)', () => { function setOnline(value: boolean) { vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(value) window.dispatchEvent(new Event(value ? 'online' : 'offline')) } it('shows the banner and the chip, disables Save, keeps edits, and sends nothing on reconnect', async () => { await mountAt('/schema/new') await typeInto(nameInput(), 'Events') setOnline(false) await flushPromises() expect(text(document.body)).toContain( "You're offline. Changes will be saved when you reconnect.", ) expect(chip()).toBe('Waiting for connection') expect(saveButton()!.disabled).toBe(true) expect(nameInput()!.value).toBe('Events') setOnline(true) await flushPromises() expect(text(document.body)).not.toContain("You're offline.") expect(chip()).toBe('Unsaved changes') expect(saveButton()!.disabled).toBe(false) expect(posts).toHaveLength(0) }) }) describe('Discard', () => { it('resets to the blank form, sends nothing and keeps the URL', async () => { await mountAt('/schema/new') await buildEvents() await click(discardButton()) expect(nameInput()!.value).toBe('') expect(keyInput()!.value).toBe('') expect(rowLabels()).toEqual(['Title']) expect(chip()).toBe('') expect(discardButton()!.disabled).toBe(true) expect(saveButton()!.disabled).toBe(true) expect(posts).toHaveLength(0) expect(router.currentRoute.value.path).toBe('/schema/new') }) }) describe('Leaving with unsaved edits', () => { const DIALOG_TITLE = 'Leave without saving?' it('navigates without a prompt from a pristine form', async () => { await mountAt('/schema/new') await router.push('/schema') await flushPromises() expect(router.currentRoute.value.path).toBe('/schema') expect(text(document.body)).not.toContain(DIALOG_TITLE) }) it('asks first when something was edited, and stays on Keep editing', async () => { await mountAt('/schema/new') await typeInto(nameInput(), 'Events') const leaving = router.push('/schema') await flushPromises() expect(text(document.body)).toContain(DIALOG_TITLE) expect(button('Keep editing')).toBeInstanceOf(HTMLButtonElement) expect(button('Discard changes')).toBeInstanceOf(HTMLButtonElement) await click(button('Keep editing')) await leaving await flushPromises() expect(router.currentRoute.value.path).toBe('/schema/new') expect(nameInput()!.value).toBe('Events') }) it('leaves on Discard changes', async () => { await mountAt('/schema/new') await addField('Starts on') const leaving = router.push('/schema') await flushPromises() await click(button('Discard changes')) await leaving await flushPromises() expect(router.currentRoute.value.path).toBe('/schema') }) })