import { describe, expect, it } from 'vitest' import { buildSearchIndex, firstSentence, highlightSegments, queryTerms, searchIndex, suggestedHits, type EndpointEntry, type SearchHit, } from '../apiSearch' const index = buildSearchIndex() function routes(hits: SearchHit[]) { return hits.map((hit) => hit.entry.kind === 'endpoint' ? `${hit.entry.method} ${hit.entry.path}` : `section ${hit.entry.title}`, ) } function search(query: string) { return searchIndex(index, query) } describe('firstSentence', () => { it.each([ ['Get one article. Drafts answer 404 to anonymous callers.', 'Get one article.'], ['Rename an image', 'Rename an image'], ['Upload an image (multipart).', 'Upload an image (multipart).'], ])('cuts %s', (text, expected) => { expect(firstSentence(text)).toBe(expected) }) }) describe('queryTerms', () => { it('splits on spaces, trims and lowercases', () => { expect(queryTerms(' POST Media ')).toEqual(['post', 'media']) expect(queryTerms(' ')).toEqual([]) }) }) describe('index', () => { it('covers documented, session only and public auth routes', () => { const keys = index.endpoints.map((entry) => entry.key) expect(keys).toContain('ep:med-merge') expect(keys).toContain('session:POST /v1/auth/login') expect(keys).toContain('public:GET /healthz') expect(index.sections.map((section) => section.sectionId)).toEqual([ 'auth', 'articles', 'seo', 'ctypes', 'entries', 'builtin', 'media', 'sites', 'images', 'users', 'settings', 'session', 'errors', 'notes', ]) }) it('holds 85 endpoints: 42 documented, 37 session only and 6 public auth', () => { expect(index.endpoints).toHaveLength(85) expect(index.endpoints.filter((entry) => entry.cardId !== null)).toHaveLength(42) expect(index.endpoints.filter((entry) => entry.session)).toHaveLength(37) expect(index.endpoints.filter((entry) => entry.key.startsWith('public:'))).toHaveLength(6) }) it('never indexes code samples or response examples', () => { expect(search('rcms_kf3x').endpoints.map((hit) => hit.entry.key)).toEqual([]) expect(search('"total"').endpoints).toEqual([]) expect(search('curl').endpoints).toEqual([]) }) }) describe('matching', () => { it('matches terms case-insensitively as substrings', () => { expect(routes(search('MEDIA').endpoints)).toContain('POST /v1/media/merge') expect(routes(search('edia').endpoints)).toContain('POST /v1/media/merge') }) it('does not match a misspelled term', () => { const result = search('mdia') expect(result.endpoints).toEqual([]) expect(result.sections).toEqual([]) }) it('requires every term in the same endpoint', () => { const result = routes(search('post media').endpoints) expect(result).toContain('POST /v1/media/') expect(result.every((route) => !route.startsWith('GET'))).toBe(true) expect(routes(search('patch user').endpoints)).toContain('PATCH /v1/user/me') }) it('ignores extra spaces', () => { expect(routes(search(' post media ').endpoints)).toEqual( routes(search('post media').endpoints), ) }) it('returns nothing for an empty query', () => { expect(search(' ')).toEqual({ endpoints: [], sections: [] }) }) it('matches the access chip', () => { const owners = search('OWNER ONLY').endpoints.map((hit) => hit.entry as EndpointEntry) expect(owners.map((entry) => entry.path)).toEqual( expect.arrayContaining(['/v1/user/invite', '/v1/user/invite/{id}']), ) expect( owners.every((entry) => entry.access === 'Owner only' || entry.path.length > 0), ).toBe(true) }) it('matches a body field and reports it as the second line', () => { const hits = search('keep_id').endpoints expect(routes(hits)).toEqual(['POST /v1/media/merge']) expect(hits[0]!.via).toEqual({ kind: 'body', text: 'keep_id' }) expect(search('keep_id').sections).toEqual([]) }) it('matches a query parameter and labels it', () => { const hit = search('hash').endpoints.find( (item) => (item.entry as EndpointEntry).path === '/v1/media/', ) expect(hit!.via).toEqual({ kind: 'query', text: 'hash' }) }) it('matches an error code or message and reports the first error', () => { const hits = search('404').endpoints expect(hits.length).toBeGreaterThan(6) const withError = hits.find((hit) => hit.via?.kind === 'error')! expect(withError.via!.text.startsWith('404 ')).toBe(true) expect(routes(search('404').sections)).toEqual(['section Errors']) }) it('shows no second line for a path, description, method or chip match', () => { for (const query of ['merge', 'identical copies', 'patch', 'verified user']) { expect(search(query).endpoints.some((hit) => hit.via === null)).toBe(true) } expect(search('merge').endpoints[0]!.via).toBeNull() }) it('lists path and description matches first, then catalogue order', () => { const hits = search('token').endpoints const firstIndirect = hits.findIndex((hit) => hit.via !== null) if (firstIndirect !== -1) { expect(hits.slice(0, firstIndirect).every((hit) => hit.via === null)).toBe(true) expect(hits.slice(firstIndirect).every((hit) => hit.via !== null)).toBe(true) } const keys = index.endpoints.map((entry) => entry.key) const media = search('media').endpoints.filter((hit) => hit.via === null) const positions = media.map((hit) => keys.indexOf(hit.entry.key)) expect(positions).toEqual([...positions].sort((a, b) => a - b)) }) it('puts a field-only match after the path matches', () => { const hits = search('keep_id media').endpoints expect(routes(hits)).toEqual(['POST /v1/media/merge']) const mixed = search('title').endpoints const firstIndirect = mixed.findIndex((hit) => hit.via !== null) expect(firstIndirect === -1 || mixed.slice(firstIndirect).every((hit) => hit.via)).toBe( true, ) }) it('finds sections by title, description and keywords', () => { expect(routes(search('media').sections)).toEqual(['section Media']) expect(search('media').sections[0]!.entry).toMatchObject({ description: '11 routes' }) expect(search('media').sections[0]!.entry.sectionId).toBe('media') const login = search('login') expect(routes(login.endpoints)).toEqual(['POST /v1/auth/login']) expect(routes(login.sections)).toEqual(['section Session only']) expect(login.sections[0]!.entry).toMatchObject({ locked: true, description: '37 routes, not available to API keys', }) }) it('flags session-only routes and sends them to the Session only section', () => { const entry = search('login').endpoints[0]!.entry as EndpointEntry expect(entry).toMatchObject({ session: true, access: 'Session only', description: 'Log in', cardId: null, sectionId: 'session', }) }) it('finds nothing for xyz', () => { expect(search('xyz')).toEqual({ endpoints: [], sections: [] }) }) }) describe('custom content types', () => { it.each([ ['media', 16, 1], ['404', 23, 1], ['login', 1, 1], ['entry', 7, 1], ['capacity', 2, 0], ['schema_incompatible', 1, 0], ['content type', 10, 2], ])('counts "%s" as %i endpoints and %i sections', (query, endpoints, sections) => { const result = search(query) expect(result.endpoints).toHaveLength(endpoints) expect(result.sections).toHaveLength(sections) }) it('also matches "entries" for the term entry, in text and in highlights', () => { const keys = search('entry').endpoints.map((hit) => hit.entry.key) expect(keys).toContain('ep:ct-delete') expect(keys).toContain('ep:ct-update') expect(routes(search('entry').sections)).toEqual(['section Entries']) expect(highlightSegments('Entries and entry', ['entry'])).toEqual([ { text: 'Entries', marked: true }, { text: ' and ', marked: false }, { text: 'entry', marked: true }, ]) }) it('labels the events keys as body fields and a code as an error', () => { const capacity = search('capacity').endpoints expect(capacity.map((hit) => hit.via?.kind)).toEqual(['body', 'body']) expect(search('starts_on').endpoints.map((hit) => hit.via)).toEqual([ { kind: 'body', text: 'starts_on' }, { kind: 'body', text: 'starts_on' }, ]) const [schema] = search('schema_incompatible').endpoints expect(schema!.via!.kind).toBe('error') expect(schema!.via!.text.startsWith('409 schema_incompatible')).toBe(true) expect(routes(search('schema_incompatible').endpoints)).toEqual([ 'PUT /v1/content-type/{key}', ]) }) it('finds the new sections by their keywords', () => { expect(routes(search('schema').sections)).toEqual([ 'section Content types', 'section Built-in types', ]) expect(routes(search('author').sections)).toContain('section Sites and authors') expect(routes(search('purge').sections)).toEqual(['section Media']) }) }) describe('roles sync: permission-gated routes', () => { const GATED = [ 'POST /v1/content-type/', 'PUT /v1/content-type/{key}', 'DELETE /v1/content-type/{key}', 'GET /v1/builtin-type/', 'GET /v1/builtin-type/{key}', 'GET /v1/media/purge-settings', 'PATCH /v1/media/purge-settings', 'GET /v1/media/purge-preview', 'POST /v1/media/purge', ] it('matches "permission" on the nine routes through the access chip and the description', () => { const found = routes(search('permission').endpoints) for (const route of GATED) { expect(found).toContain(route) } }) it('matches "missing_permission" on the error code of the gated routes', () => { const found = search('missing_permission').endpoints for (const route of GATED) { expect(routes(found)).toContain(route) } expect(found.find((hit) => routes([hit])[0] === GATED[0])!.via!.kind).toBe('error') }) it('no longer matches "owner or admin" on the nine routes', () => { const found = routes(search('owner or admin').endpoints) for (const route of GATED) { expect(found).not.toContain(route) } }) }) describe('suggestions', () => { it('lists the five suggested rows in order', () => { expect(routes(suggestedHits(index))).toEqual([ 'section Authentication', 'POST /v1/media/', 'section Errors', 'GET /v1/article/', 'PATCH /v1/user/me', ]) }) }) describe('highlightSegments', () => { it('marks every occurrence of every term', () => { expect(highlightSegments('POST /v1/media/media', ['media'])).toEqual([ { text: 'POST /v1/', marked: false }, { text: 'media', marked: true }, { text: '/', marked: false }, { text: 'media', marked: true }, ]) }) it('is case-insensitive and escapes regex characters', () => { expect(highlightSegments('Get {id}', ['get', '{id}'])).toEqual([ { text: 'Get', marked: true }, { text: ' ', marked: false }, { text: '{id}', marked: true }, ]) }) it('returns the text unchanged without terms', () => { expect(highlightSegments('Hello', [])).toEqual([{ text: 'Hello', marked: false }]) }) })