import { afterEach, describe, expect, it, vi } from 'vitest' import router from '@/router' import { flushPromises } from '@vue/test-utils' import { MOBILE_WIDTH, arrived, button, calls, chip, control, customTypes, entry42, entryWith, eventsFields, failWith, failWithBody, fieldWrapper, hasText, setField, typeInto, main, mountEditor, networkFailure, pending, text, unmountAll, } from './entryEditorKit' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn() } }) afterEach(unmountAll) function entryRequests() { return calls.filter((url) => /\/entry\/\d+$/.test(url)) } describe('1 New entry', () => { it('shows the title, the subtitle, the chip and the actions', async () => { await mountEditor('/content/events/new') expect(text(main())).toContain('New entry Events · Fields marked * are required.') expect(text(chip())).toBe('New entry') expect(button('Cancel')).toBeTruthy() expect(button('Save')!.disabled).toBe(false) expect(button('Delete')).toBeUndefined() expect(entryRequests()).toEqual([]) }) it('marks required labels and shows helps, placeholders and defaults', async () => { await mountEditor('/content/events/new') expect(text(fieldWrapper('title'))).toContain('Title *') expect(text(fieldWrapper('starts_on'))).toContain('Starts on *') expect(text(fieldWrapper('ends_on'))).not.toContain('*') expect(text(fieldWrapper('title'))).toContain('Each entry needs a different title.') expect(text(fieldWrapper('capacity'))).toContain('Between 1 and 5000.') expect(control('capacity').getAttribute('placeholder')).toBe('Enter a number') expect(text(fieldWrapper('category'))).toContain('Choose an option') expect(text(fieldWrapper('starts_on'))).toContain('Pick a date') expect(text(fieldWrapper('online'))).toContain('Off') expect(text(fieldWrapper('online'))).toContain('Default') expect(text(fieldWrapper('title'))).not.toContain('Default') }) it('shows the footer of an unsaved entry', async () => { await mountEditor('/content/events/new') expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toBe( 'Entry Not saved yet Created by – Updated by –', ) }) it('reads Content, Events and New entry in the breadcrumb', async () => { await mountEditor('/content/events/new') expect(text(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe( 'Content Events New entry', ) }) it('has no required marker for assistive technology', async () => { await mountEditor('/content/events/new') const star = fieldWrapper('title').querySelector('[aria-hidden="true"]') expect(star?.textContent?.trim()).toBe('*') expect(control('title').getAttribute('aria-required')).toBe('true') }) }) describe('2 Editing', () => { it('shows the entry, the chip, the actions and the footer', async () => { await mountEditor('/content/events/42') expect(text(main())).toContain('Lime plaster workshop Events · Entry 42') expect(text(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe( 'Content Events Lime plaster workshop', ) expect(text(chip())).toBe('Saved') expect(button('Delete')).toBeTruthy() expect(button('Save')!.disabled).toBe(true) expect(control('title').value).toBe('Lime plaster workshop') expect(control('capacity').value).toBe('18') expect(text(fieldWrapper('online'))).not.toContain('Default') expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toBe( 'Entry events / 42 Created by Inès Duarte · 12 Oct 2026, 09:14 Updated by Marta Alves · 15 Oct 2026, 11:02', ) }) it('sends the entry request with the type request and the lookup once', async () => { await mountEditor('/content/events/42') expect(calls).toContain('/v1/content-type/events') expect(calls).toContain('/v1/content-type/events/entry/42') expect(calls.filter((url) => url.startsWith('/v1/user/lookup'))).toEqual([ '/v1/user/lookup?ids=4,3', ]) }) it('reads a dash and keeps the date when the lookup fails or lacks an id', async () => { await mountEditor('/content/events/42', { lookup: () => [{ id: 3, display_name: 'Marta Alves' }], }) expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toContain( 'Created by – · 12 Oct 2026, 09:14 Updated by Marta Alves · 15 Oct 2026, 11:02', ) }) it('reads dashes when the lookup fails', async () => { await mountEditor('/content/events/42', { lookup: failWith(500) }) expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toContain( 'Updated by – · 15 Oct 2026, 11:02', ) }) it('shows a trash action on mobile and no Delete button', async () => { await mountEditor('/content/events/42', { width: MOBILE_WIDTH }) expect(button('Delete')).toBeUndefined() expect(document.body.querySelector('button[aria-label="Delete"]')).toBeTruthy() expect(button('Cancel')).toBeTruthy() expect(button('Save')!.disabled).toBe(true) }) it('shows the new entry layout on mobile without an action', async () => { await mountEditor('/content/events/new', { width: MOBILE_WIDTH }) expect(document.body.querySelector('button[aria-label="Delete"]')).toBeNull() expect(button('Cancel')).toBeTruthy() expect(text(chip())).toBe('New entry') }) }) describe('The page title', () => { it.each([ ['text', { title: 'Lime plaster workshop' }, 'Lime plaster workshop'], ['null', { title: null }, 'Entry 42'], ['number', { title: 18 }, '18'], ])('reads %s', async (_name, data, title) => { await mountEditor('/content/events/42', { entry: () => entryWith(data) }) expect(text(main().querySelector('h1'))).toBe(title) }) it('reads the first line of rich text', async () => { const types = customTypes.map((type) => type.key === 'events' ? { ...type, fields: [{ ...eventsFields[6]!, key: 'body', label: 'Body' }] } : type, ) await mountEditor('/content/events/42', { type: () => types[0], entry: () => entryWith({ body: 'First line\n\nSecond' }), }) expect(text(main().querySelector('h1'))).toBe('First line') }) }) describe('Defaults', () => { it.each([ ['Capacity', 'capacity', { default: 100 }, '100'], ['Title', 'title', { default: 'Untitled' }, 'Untitled'], ])('pre-fills and tags %s', async (_label, key, rest, shown) => { const fields = eventsFields.map((field) => field.key === key ? { ...field, ...rest } : field, ) await mountEditor('/content/events/new', { type: () => ({ ...customTypes[0]!, fields }), }) expect(control(key).value).toBe(shown) expect(text(fieldWrapper(key))).toContain('Default') }) }) describe('Create-only defaults', () => { const withDefaults = eventsFields.map((field) => field.key === 'capacity' ? { ...field, default: 100 } : field.key === 'title' ? { ...field, default: 'Untitled' } : field, ) const type = () => ({ ...customTypes[0]!, fields: withDefaults }) it('pre-fills and tags every default on a new entry', async () => { await mountEditor('/content/events/new', { type }) expect(control('capacity').value).toBe('100') expect(control('title').value).toBe('Untitled') expect(text(fieldWrapper('capacity'))).toContain('Default') expect(text(fieldWrapper('title'))).toContain('Default') }) it('never pre-fills or tags a default on an existing entry', async () => { await mountEditor('/content/events/42', { type, entry: () => entryWith({ capacity: null, title: 'Stored' }), }) expect(control('capacity').value).toBe('') expect(control('title').value).toBe('Stored') expect(text(main())).not.toContain('Default') }) }) describe('Session not ready', () => { it('shows the shell and issues no request', async () => { await mountEditor('/content/events/42', { status: 'checking' }) expect(calls.filter((url) => url.includes('/entry') || url.includes('lookup'))).toEqual([]) expect(document.body.querySelector('[data-entry-field]')).toBeNull() }) }) describe('Navigating between entries', () => { const entry = (_key: string, id: number) => entryWith({ title: `Entry title ${id}`, capacity: id }, { id }) it('carries no value, error or chip from /42 to /43', async () => { const wrapper = await mountEditor('/content/events/42', { entry, writeEntry: failWith(500), }) await setField(wrapper, 'capacity', 20) document.body.querySelector('button[type="submit"]')!.click() await flushPromises() await setField(wrapper, 'capacity', 42) expect(hasText("Couldn't save the entry")).toBe(true) await router.push('/content/events/43') await flushPromises() expect(control('title').value).toBe('Entry title 43') expect(control('capacity').value).toBe('43') expect(hasText("Couldn't save the entry")).toBe(false) expect(text(chip())).toBe('Saved') expect(text(main().querySelector('h1'))).toBe('Entry title 43') }) it('carries nothing from an entry to another type new form', async () => { await mountEditor('/content/events/42', { entry }) await typeInto('title', 'Typed here') void router.push('/content/recipes/new') await vi.waitFor(() => expect(hasText('Leave without saving?')).toBe(true)) button('Discard changes')!.click() await arrived('/content/recipes/new') await flushPromises() expect(control('title').value).toBe('') expect(text(chip())).toBe('New entry') expect(text(main().querySelector('h1'))).toBe('New entry') expect(hasText('Typed here')).toBe(false) }) }) describe('Entry not found and load failures', () => { it.each(['abc', '4294967296', '-1'])( 'shows Entry not found for /content/events/%s with no entry request', async (id) => { await mountEditor(`/content/events/${id}`) expect(text(main())).toContain('Entry not found') expect(text(main())).toContain('It may have been deleted by someone else.') expect(entryRequests()).toEqual([]) expect(button('Back to Events')).toBeTruthy() }, ) it('sends id 0 as is and shows Entry not found when the API answers 404', async () => { await mountEditor('/content/events/0', { entry: failWithBody(404, 'entry_not_found') }) expect(entryRequests()).toEqual(['/v1/content-type/events/entry/0']) expect(text(main())).toContain('Entry not found') }) it('shows Entry not found on 404 entry_not_found and goes back to the list', async () => { await mountEditor('/content/events/999', { entry: failWithBody(404, 'entry_not_found') }) expect(text(main())).toContain('Entry not found') button('Back to Events')!.click() await arrived('/content/events') }) it('shows the content type page on 404 content_type_not_found', async () => { await mountEditor('/content/events/42', { type: failWithBody(404, 'content_type_not_found'), entry: failWithBody(404, 'content_type_not_found'), }) expect(text(main())).toContain('Content type not found') }) it('shows the access page on 403', async () => { await mountEditor('/content/events/42', { entry: failWith(403) }) expect(text(main())).toContain("You don't have access to Events") }) it.each([ ['500', failWith(500)], ['a network failure', networkFailure], ])('shows the load error on %s and retries only the entry', async (_name, failure) => { let attempts = 0 await mountEditor('/content/events/42', { entry: () => { attempts++ if (attempts === 1) { return failure() } return entry42 }, }) expect(text(main())).toContain("Couldn't load the entry") expect(text(main())).toContain( 'GET /v1/content-type/events/entry/42 failed. Check your connection and try again.', ) expect(document.body.querySelector('form')).toBeNull() const typeRequests = calls.filter((url) => url === '/v1/content-type/events').length button('Try again')!.click() await vi.waitFor(() => expect(control('title').value).toBe('Lime plaster workshop')) expect(calls.filter((url) => url === '/v1/content-type/events').length).toBe(typeRequests) }) it('shows a skeleton with aria-busy while loading', async () => { const gate = pending() await mountEditor('/content/events/42', { entry: () => gate.promise }) const skeleton = document.body.querySelector('[data-testid="entry-editor-skeleton"]') expect(skeleton?.getAttribute('aria-busy')).toBe('true') expect(button('Save')).toBeUndefined() expect(button('Delete')).toBeUndefined() }) })