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:
james.maes authored and GitHub committed 2026-10-12 08:35:12 -05:00
commit b699204d87
4 files changed
+90 -19

No files matched your search

+23 -9
View File
@@ -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
View File
@@ -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)
+29 -1
View File
@@ -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
View File
@@ -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
}
/**