327 lines
11 KiB
TypeScript
327 lines
11 KiB
TypeScript
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 }])
|
|
})
|
|
})
|