Merge pull request #57 from QRun-IO/fix/record-search-tolerant-results
fix(search): one result without a label no longer fails record search
This commit was merged in pull request #57.
This commit is contained in:
4 files changed
+90
-19
No files matched your search
@@ -197,12 +197,26 @@ describe('RecordSearchResultSchema', () => {
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('fails when recordLabel is missing', () => {
|
||||
const result = RecordSearchResultSchema.safeParse({
|
||||
tableName: 'person',
|
||||
recordId: '123',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
it('accepts a missing, null or empty recordLabel or tableLabel (QQQ omits empty values)', () => {
|
||||
for (const entry of [
|
||||
{ tableName: 'page_section', recordId: '1' },
|
||||
{ tableName: 'page_section', tableLabel: null, recordId: '1', recordLabel: null },
|
||||
{ tableName: 'page_section', tableLabel: '', recordId: '1', recordLabel: '' },
|
||||
]) {
|
||||
const result = RecordSearchResultSchema.safeParse(entry)
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data).toEqual({ tableName: 'page_section', tableLabel: undefined, recordId: '1', recordLabel: undefined })
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts a numeric recordId as a string', () => {
|
||||
const result = RecordSearchResultSchema.safeParse({ tableName: 'person', recordId: 7, recordLabel: 'Zed' })
|
||||
expect(result.data?.recordId).toBe('7')
|
||||
})
|
||||
|
||||
it('fails when tableName or recordId is empty', () => {
|
||||
expect(RecordSearchResultSchema.safeParse({ tableName: '', recordId: '1' }).success).toBe(false)
|
||||
expect(RecordSearchResultSchema.safeParse({ tableName: 'person', recordId: '' }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -228,11 +242,11 @@ describe('RecordSearchResponseSchema', () => {
|
||||
expect(RecordSearchResponseSchema.safeParse({}).success).toBe(false)
|
||||
})
|
||||
|
||||
it('fails when a result is missing required fields', () => {
|
||||
it('leaves result validation to RecordSearchResultSchema (one bad result must not fail the search)', () => {
|
||||
const result = RecordSearchResponseSchema.safeParse({ results: [
|
||||
{ tableName: 'person' }, // missing recordId and recordLabel
|
||||
{ tableName: 'person' }, // missing recordId
|
||||
] })
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+13
-8
@@ -98,20 +98,25 @@ export const CountRecordsResponseSchema = z.object({
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Zod schema for one record search result. `tableLabel` is optional (a table
|
||||
* may have no label); the other fields are required.
|
||||
* Zod schema for one record search result. `tableName` and `recordId` are
|
||||
* required (a result cannot link anywhere without them); `recordId` may arrive
|
||||
* as a number. `tableLabel` and `recordLabel` may be missing or null: QQQ leaves
|
||||
* empty and null values out of its JSON, so a record whose label fields are empty
|
||||
* arrives without `recordLabel`, and callers label it themselves.
|
||||
*/
|
||||
export const RecordSearchResultSchema = z.object({
|
||||
tableName: z.string(),
|
||||
tableLabel: z.string().optional(),
|
||||
recordId: z.string(),
|
||||
recordLabel: z.string(),
|
||||
tableName: z.string().min(1),
|
||||
tableLabel: z.string().nullish().transform((value) => value || undefined),
|
||||
recordId: z.union([z.string().min(1), z.number()]).transform(String),
|
||||
recordLabel: z.string().nullish().transform((value) => value || undefined),
|
||||
})
|
||||
|
||||
/**
|
||||
* Zod schema for the record search response: `{ results: [...] }`.
|
||||
* Zod schema for the record search response envelope: `{ results: [...] }`. Each
|
||||
* result is validated separately ({@link RecordSearchResultSchema}), so one
|
||||
* malformed result is skipped rather than failing the whole search.
|
||||
*/
|
||||
export const RecordSearchResponseSchema = z.object({ results: z.array(RecordSearchResultSchema) })
|
||||
export const RecordSearchResponseSchema = z.object({ results: z.array(z.unknown()) })
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// QInstance — GET /metaData (minimal required-field validation)
|
||||
|
||||
@@ -286,7 +286,35 @@ describe('Tables API', () => {
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/search', { searchTerm: 'x' })
|
||||
})
|
||||
|
||||
it.each([[[]], [{}], ['<html>Dashboard</html>'], [{ results: [{ tableName: 'person' }] }]])('rejects an invalid response: %j', async (response) => {
|
||||
it('labels a result whose recordLabel was omitted (empty label fields) instead of failing the search', async () => {
|
||||
// the prod admin.kof22.com response for "maes": QQQ left recordLabel out of results whose label was ""
|
||||
const { default: apiClient } = await import('./client')
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ results: [
|
||||
{ tableName: 'author', tableLabel: 'Authors', recordId: '2', recordLabel: 'jm' },
|
||||
{ tableName: 'page_section', tableLabel: 'Page Sections', recordId: '1' },
|
||||
{ tableName: 'page_section', recordId: 11, recordLabel: null },
|
||||
] })
|
||||
|
||||
const { searchRecords } = await import('./tables')
|
||||
await expect(searchRecords('maes')).resolves.toEqual([
|
||||
{ tableName: 'author', tableLabel: 'Authors', recordId: '2', recordLabel: 'jm' },
|
||||
{ tableName: 'page_section', tableLabel: 'Page Sections', recordId: '1', recordLabel: 'Page Sections 1' },
|
||||
{ tableName: 'page_section', recordId: '11', recordLabel: 'page_section 11' },
|
||||
])
|
||||
})
|
||||
|
||||
it('skips (and warns about) a result that cannot link to a record, keeping the others', async () => {
|
||||
const { default: apiClient } = await import('./client')
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ results: [{ tableName: 'person' }, { tableName: 'person', recordId: '1', recordLabel: 'Alice' }] })
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const { searchRecords } = await import('./tables')
|
||||
await expect(searchRecords('x')).resolves.toEqual([{ tableName: 'person', recordId: '1', recordLabel: 'Alice' }])
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('skipped 1 malformed result'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it.each([[[]], [{}], ['<html>Dashboard</html>'], [{ results: 'nope' }]])('rejects an invalid response: %j', async (response) => {
|
||||
const { default: apiClient } = await import('./client')
|
||||
vi.mocked(apiClient.post).mockResolvedValue(response)
|
||||
|
||||
|
||||
+25
-1
@@ -27,6 +27,7 @@ import {
|
||||
QueryRecordsResponseSchema,
|
||||
CountRecordsResponseSchema,
|
||||
RecordSearchResponseSchema,
|
||||
RecordSearchResultSchema,
|
||||
} from './schemas'
|
||||
|
||||
/**
|
||||
@@ -395,6 +396,11 @@ export interface RecordSearchOptions {
|
||||
*
|
||||
* @param searchTerm - Free-text search term (the backend trims it).
|
||||
* @param options - Table scope and per-table limit.
|
||||
* A result without a record label (its label fields are empty) is labeled
|
||||
* "{table label} {record id}"; a result that cannot link to a record (no table
|
||||
* name or record id) is skipped with a console warning, so the other results
|
||||
* still show.
|
||||
*
|
||||
* @returns Matching records, grouped by table in backend order.
|
||||
* @throws When the request fails or the response is not a record search response.
|
||||
*/
|
||||
@@ -406,7 +412,25 @@ export async function searchRecords(searchTerm: string, options: RecordSearchOpt
|
||||
})
|
||||
const parsed = RecordSearchResponseSchema.safeParse(result)
|
||||
if (!parsed.success) throw new Error('Invalid record search response')
|
||||
return parsed.data.results
|
||||
|
||||
const results: RecordSearchResult[] = []
|
||||
let skipped = 0
|
||||
for (const entry of parsed.data.results) {
|
||||
const item = RecordSearchResultSchema.safeParse(entry)
|
||||
if (!item.success) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
const { tableName, tableLabel, recordId, recordLabel } = item.data
|
||||
results.push({
|
||||
tableName,
|
||||
...(tableLabel ? { tableLabel } : {}),
|
||||
recordId,
|
||||
recordLabel: recordLabel?.trim() ? recordLabel : `${tableLabel ?? tableName} ${recordId}`,
|
||||
})
|
||||
}
|
||||
if (skipped > 0) console.warn(`Record search skipped ${skipped} malformed result(s) without a table name or record id.`)
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user