From efb6c2c1c005879fcdc4c9373441bdbf00cb3cd5 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Fri, 21 Aug 2026 20:54:19 +0200 Subject: [PATCH] feat(agent-bff): expose full-text search on the list and count endpoints A BFF client can now run the agent's native search by sending `search` and `searchExtended` on list and count, instead of rebuilding a per-field condition tree that cannot cover relations or non-text columns. Both parsers validate the two fields; both builders emit them under the wire names the agent reads. A blank search is dropped rather than forwarded, and `searchExtended` only ships alongside a real search, so a search-less body produces the exact query it does today. Non-searchable collections are left to the agent: it answers 400 validation_error with "Collection is not searchable". Relation list and count share the parsers and the builders, so they gain search too; their OpenAPI schemas and tests are updated accordingly. Covered by unit tests on the parsers and builders, route-level tests on all four endpoints, and an integration test against a real in-process agent proving the count reflects the searched rows and that a search-disabled collection is rejected rather than listed in full. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SktMf1BRjkFKR3gY25ngJt --- packages/agent-bff/package.json | 2 + packages/agent-bff/src/data/agent-query.ts | 44 +++ packages/agent-bff/src/openapi/schemas.ts | 30 +- .../agent-bff/test/data/agent-query.test.ts | 130 +++++++++ .../test/data/data-routes-middleware.test.ts | 136 ++++++++++ .../data/fixtures/in-memory-collection.ts | 87 ++++++ .../test/data/fixtures/search-datasource.ts | 52 ++++ .../data/search-agent-integration.test.ts | 256 ++++++++++++++++++ .../test/openapi/openapi-document.test.ts | 42 +++ 9 files changed, 777 insertions(+), 2 deletions(-) create mode 100644 packages/agent-bff/test/data/fixtures/in-memory-collection.ts create mode 100644 packages/agent-bff/test/data/fixtures/search-datasource.ts create mode 100644 packages/agent-bff/test/data/search-agent-integration.test.ts diff --git a/packages/agent-bff/package.json b/packages/agent-bff/package.json index 14d0446677..aa11647220 100644 --- a/packages/agent-bff/package.json +++ b/packages/agent-bff/package.json @@ -41,6 +41,8 @@ "zod": "4.3.6" }, "devDependencies": { + "@forestadmin/agent": "1.96.0", + "@forestadmin/agent-testing": "1.1.79", "@redocly/cli": "2.35.1", "@types/jsonwebtoken": "^9.0.1", "@types/koa": "^2.13.5", diff --git a/packages/agent-bff/src/data/agent-query.ts b/packages/agent-bff/src/data/agent-query.ts index daf3bfdb06..c03baac8ac 100644 --- a/packages/agent-bff/src/data/agent-query.ts +++ b/packages/agent-bff/src/data/agent-query.ts @@ -19,10 +19,14 @@ export interface ListRequestBody { projection?: string[]; sort?: BffSortClause[]; page?: BffPage; + search?: string; + searchExtended?: boolean; } export interface CountRequestBody { filter?: unknown; + search?: string; + searchExtended?: boolean; } export type RelationListRequestBody = ListRequestBody & { parentId: string }; @@ -55,11 +59,29 @@ function assertNoNodeReadableAsBothLeafAndBranch(node: unknown, depth = 0): void // Validate the untyped request body before it reaches the query builders, so malformed shapes // (e.g. `projection` or `sort` as a string) surface as 400 invalid_request rather than a 500 from // an array method blowing up downstream. +/** + * The agent reads `search`/`searchExtended` from query params, so it coerces both from strings. + * The BFF is a JSON contract: a real boolean is required here, like `page.limit` requires a real + * integer. Only the type is checked — whether a blank search is worth sending is the builder's + * call, so a cleared search box parses the same way as an absent one. + */ +function assertValidSearch(search: unknown, searchExtended: unknown): void { + if (search !== undefined && typeof search !== 'string') { + throw invalidRequest('search must be a string'); + } + + if (searchExtended !== undefined && typeof searchExtended !== 'boolean') { + throw invalidRequest('searchExtended must be a boolean'); + } +} + export function parseListRequest(body: unknown): ListRequestBody { if (!isPlainObject(body)) throw invalidRequest('Request body must be an object'); const { filter, projection, sort, page } = body; + assertValidSearch(body.search, body.searchExtended); + if (projection !== undefined) { if (!Array.isArray(projection) || projection.some(field => typeof field !== 'string')) { throw invalidRequest('projection must be an array of field names'); @@ -105,6 +127,8 @@ export function parseListRequest(body: unknown): ListRequestBody { export function parseCountRequest(body: unknown): CountRequestBody { if (!isPlainObject(body)) throw invalidRequest('Request body must be an object'); + assertValidSearch(body.search, body.searchExtended); + if (body.filter !== undefined) { if (!isPlainObject(body.filter)) throw invalidRequest('filter must be an object'); assertNoNodeReadableAsBothLeafAndBranch(body.filter); @@ -138,6 +162,24 @@ function serializePage(page: BffPage): Record { return { 'page[size]': limit, 'page[number]': offset / limit + 1 }; } +/** + * `search` and `searchExtended` are the wire names the agent reads; no other spelling is parsed. + * + * A blank search is dropped rather than forwarded. The agent's search decorator already treats it + * as absent, but `parseSearch` guards on a truthy value, so a whitespace-only search would raise + * "Collection is not searchable" on a non-searchable collection while an empty one would not — a + * cleared search box must not depend on how many spaces it holds. + * + * `searchExtended` only ships alongside a real search: on its own it changes nothing agent-side, + * and emitting it would alter the outgoing query of every search-less request. + */ +function applySearch(query: AgentQuery, body: CountRequestBody): void { + if (!body.search?.trim()) return; + + query.search = body.search; + if (body.searchExtended !== undefined) query.searchExtended = body.searchExtended; +} + export function buildListAgentQuery( collection: string, timezone: string, @@ -149,6 +191,7 @@ export function buildListAgentQuery( if (body.projection?.length) query[`fields[${collection}]`] = body.projection.join(','); if (body.sort?.length) query.sort = serializeSort(body.sort); if (body.page) Object.assign(query, serializePage(body.page)); + applySearch(query, body); return query; } @@ -157,6 +200,7 @@ export function buildCountAgentQuery(timezone: string, body: CountRequestBody): const query: AgentQuery = { timezone }; if (body.filter !== undefined) query.filters = JSON.stringify(body.filter); + applySearch(query, body); return query; } diff --git a/packages/agent-bff/src/openapi/schemas.ts b/packages/agent-bff/src/openapi/schemas.ts index f2655f5c48..7ef1b263da 100644 --- a/packages/agent-bff/src/openapi/schemas.ts +++ b/packages/agent-bff/src/openapi/schemas.ts @@ -61,12 +61,32 @@ export const TimezoneSchema = z.string().openapi('Timezone', { 'missing_timezone.', }); +export const SearchSchema = z.string().openapi('Search', { + description: + "The agent's native full-text search, applied on top of `filter` rather than instead of it. " + + 'An empty or whitespace-only value is treated as absent, so clearing a search box is not an ' + + 'error. Searching a collection whose search is disabled is not rejected here: the agent ' + + 'answers 400 validation_error with "Collection is not searchable". The response does not say ' + + 'which field matched.', +}); + +export const SearchExtendedSchema = z.boolean().openapi('SearchExtended', { + description: + 'Widens `search` to the related collections reachable from this one. Meaningless on its own: ' + + 'sent without `search` it is ignored and changes nothing. Note it reads relation fields even ' + + 'though naming a relation field path in `filter`, `sort` or `projection` is rejected with 422 ' + + 'relation_field_not_supported — records can therefore match on a field the response cannot ' + + 'show.', +}); + export const ListRequestSchema = z .object({ filter: ConditionTreeSchema.optional(), projection: z.array(z.string()).optional(), sort: z.array(SortClauseSchema).optional(), page: PageSchema.optional(), + search: SearchSchema.optional(), + searchExtended: SearchExtendedSchema.optional(), timezone: TimezoneSchema.optional(), }) .openapi('ListRequest'); @@ -74,9 +94,15 @@ export const ListRequestSchema = z export const CountRequestSchema = z .object({ filter: ConditionTreeSchema.optional(), + search: SearchSchema.optional(), + searchExtended: SearchExtendedSchema.optional(), timezone: TimezoneSchema.optional(), }) - .openapi('CountRequest'); + .openapi('CountRequest', { + description: + 'Accepts the same search inputs as list, so a client can count exactly the rows its search ' + + 'returns.', + }); const ParentIdSchema = z.union([z.string().regex(/\S/), z.number()]).openapi('ParentId', { description: @@ -88,7 +114,7 @@ export const RelationListRequestSchema = ListRequestSchema.extend({ parentId: ParentIdSchema, }).openapi('RelationListRequest', { description: - 'Filter, sort and projection apply to the FOREIGN collection; the parent only resolves ' + + 'Filter, sort, projection and search apply to the FOREIGN collection; the parent only resolves ' + 'which records are related.', }); diff --git a/packages/agent-bff/test/data/agent-query.test.ts b/packages/agent-bff/test/data/agent-query.test.ts index 30d1bddeb6..78e1d31a52 100644 --- a/packages/agent-bff/test/data/agent-query.test.ts +++ b/packages/agent-bff/test/data/agent-query.test.ts @@ -66,6 +66,84 @@ describe('buildCountAgentQuery', () => { }); }); +describe('search in the outgoing agent query', () => { + it('should send the search term under the wire name the agent reads', () => { + expect(buildListAgentQuery('users', 'Europe/Paris', { search: 'ada' })).toEqual({ + timezone: 'Europe/Paris', + search: 'ada', + }); + }); + + it('should send searchExtended under the wire name the agent reads', () => { + expect( + buildListAgentQuery('users', 'Europe/Paris', { search: 'ada', searchExtended: true }), + ).toEqual({ timezone: 'Europe/Paris', search: 'ada', searchExtended: true }); + }); + + it('should send searchExtended false when explicitly disabled alongside a search', () => { + expect( + buildListAgentQuery('users', 'Europe/Paris', { search: 'ada', searchExtended: false }), + ).toEqual({ timezone: 'Europe/Paris', search: 'ada', searchExtended: false }); + }); + + it('should send both the filter and the search so the agent intersects them', () => { + expect( + buildListAgentQuery('users', 'Europe/Paris', { + filter: { field: 'active', operator: 'equal', value: true }, + search: 'ada', + }), + ).toEqual({ + timezone: 'Europe/Paris', + filters: JSON.stringify({ field: 'active', operator: 'equal', value: true }), + search: 'ada', + }); + }); + + it('should treat an empty search as absent', () => { + expect(buildListAgentQuery('users', 'Europe/Paris', { search: '' })).toEqual({ + timezone: 'Europe/Paris', + }); + }); + + it('should treat a whitespace-only search as absent', () => { + expect(buildListAgentQuery('users', 'Europe/Paris', { search: ' ' })).toEqual({ + timezone: 'Europe/Paris', + }); + }); + + it('should not send searchExtended when it arrives without a search', () => { + expect(buildListAgentQuery('users', 'Europe/Paris', { searchExtended: true })).toEqual({ + timezone: 'Europe/Paris', + }); + }); + + it('should not send searchExtended when the search it accompanies is blank', () => { + expect( + buildListAgentQuery('users', 'Europe/Paris', { search: ' ', searchExtended: true }), + ).toEqual({ timezone: 'Europe/Paris' }); + }); + + it('should send the search term unchanged, including its inner spacing', () => { + expect(buildListAgentQuery('users', 'Europe/Paris', { search: 'ada lovelace' }).search).toBe( + 'ada lovelace', + ); + }); + + it('should accept the same search inputs on count as on list', () => { + expect(buildCountAgentQuery('Europe/Paris', { search: 'ada', searchExtended: true })).toEqual({ + timezone: 'Europe/Paris', + search: 'ada', + searchExtended: true, + }); + }); + + it('should leave the count query untouched when the search is blank', () => { + expect(buildCountAgentQuery('UTC', { search: ' ', searchExtended: true })).toEqual({ + timezone: 'UTC', + }); + }); +}); + describe('collectListFieldPaths', () => { it('should collect field paths from projection, filter and sort', () => { const paths = collectListFieldPaths({ @@ -113,6 +191,43 @@ describe('parseListRequest', () => { expect.objectContaining({ type: 'invalid_request', status: 400 }), ); }); + + it('should accept a body carrying search and searchExtended', () => { + const body = { search: 'ada', searchExtended: true }; + + expect(parseListRequest(body)).toBe(body); + }); + + it('should accept a blank search rather than rejecting a cleared search box', () => { + const body = { search: ' ' }; + + expect(parseListRequest(body)).toBe(body); + }); + + it.each([ + ['a non-string search', { search: 42 }], + ['a null search', { search: null }], + ['an array search', { search: ['ada'] }], + ])('should reject %s with 400 invalid_request', (_label, body) => { + expect(() => parseListRequest(body)).toThrow( + expect.objectContaining({ type: 'invalid_request', status: 400 }), + ); + }); + + it.each([ + ['the string "true"', { search: 'ada', searchExtended: 'true' }], + ['the string "false"', { search: 'ada', searchExtended: 'false' }], + ['the number 1', { search: 'ada', searchExtended: 1 }], + ['the string "0"', { search: 'ada', searchExtended: '0' }], + ['a null value', { search: 'ada', searchExtended: null }], + ])( + 'should reject searchExtended sent as %s rather than coercing it like the agent does', + (_label, body) => { + expect(() => parseListRequest(body)).toThrow( + expect.objectContaining({ type: 'invalid_request', status: 400 }), + ); + }, + ); }); describe('parseCountRequest', () => { @@ -131,6 +246,21 @@ describe('parseCountRequest', () => { expect.objectContaining({ type: 'invalid_request', status: 400 }), ); }); + + it('should accept a body carrying search and searchExtended', () => { + const body = { search: 'ada', searchExtended: false }; + + expect(parseCountRequest(body)).toBe(body); + }); + + it.each([ + ['a non-string search', { search: 42 }], + ['a non-boolean searchExtended', { search: 'ada', searchExtended: 'true' }], + ])('should reject %s with 400 invalid_request', (_label, body) => { + expect(() => parseCountRequest(body)).toThrow( + expect.objectContaining({ type: 'invalid_request', status: 400 }), + ); + }); }); describe('a filter node readable as both a leaf and a branch', () => { diff --git a/packages/agent-bff/test/data/data-routes-middleware.test.ts b/packages/agent-bff/test/data/data-routes-middleware.test.ts index 442914da48..f52e5bb466 100644 --- a/packages/agent-bff/test/data/data-routes-middleware.test.ts +++ b/packages/agent-bff/test/data/data-routes-middleware.test.ts @@ -204,6 +204,75 @@ describe('data routes middleware', () => { expect(list).toHaveBeenCalledWith('users', expect.objectContaining({ timezone: TIMEZONE })); }); + it('should pass search and searchExtended to the agent query', async () => { + const list = jest.fn(async () => []); + const app = buildApp(storeOf(usersReadModel), { list }); + + await request(app.callback()) + .post('/agent/v1/users/list') + .send({ search: 'ada', searchExtended: true }); + + expect(list).toHaveBeenCalledWith('users', { + timezone: TIMEZONE, + search: 'ada', + searchExtended: true, + }); + }); + + it('should leave the agent query untouched when no search is sent', async () => { + const list = jest.fn(async () => []); + const app = buildApp(storeOf(usersReadModel), { list }); + + await request(app.callback()) + .post('/agent/v1/users/list') + .send({ projection: ['id'] }); + + expect(list).toHaveBeenCalledWith('users', { + timezone: TIMEZONE, + 'fields[users]': 'id', + }); + }); + + it('should reject a non-boolean searchExtended with 400 without calling the agent', async () => { + const list = jest.fn(async () => []); + const app = buildApp(storeOf(usersReadModel), { list }); + + const response = await request(app.callback()) + .post('/agent/v1/users/list') + .send({ search: 'ada', searchExtended: 'true' }); + + expect(response.status).toBe(400); + expect(response.body.error).toMatchObject({ type: 'invalid_request', status: 400 }); + expect(list).not.toHaveBeenCalled(); + }); + + it('should reach the agent without a search when the search box is cleared', async () => { + const list = jest.fn(async () => []); + const app = buildApp(storeOf(usersReadModel), { list }); + + const response = await request(app.callback()) + .post('/agent/v1/users/list') + .send({ search: ' ' }); + + expect(response.status).toBe(200); + expect(list).toHaveBeenCalledWith('users', { timezone: TIMEZONE }); + }); + + it('should validate the filter against capabilities while carrying the search through', async () => { + const list = jest.fn(async () => []); + const app = buildApp(storeOf(usersReadModel), { list }); + + await request(app.callback()) + .post('/agent/v1/users/list') + .send({ filter: { field: 'email', operator: 'Present' }, search: 'ada' }); + + expect(list).toHaveBeenCalledWith('users', { + timezone: TIMEZONE, + filters: JSON.stringify({ field: 'email', operator: 'Present' }), + search: 'ada', + }); + }); + it.each([['projection'], ['filter'], ['sort']])( 'should reject a nested relation path in %s with 422', async surface => { @@ -690,6 +759,43 @@ describe('data routes middleware', () => { expect(response.body).toEqual({ count: null, countStatus: 'deactivated' }); }); + it('should pass search and searchExtended to the agent query', async () => { + const countRaw = jest.fn(async () => ({ count: 2 })); + const app = buildApp(storeOf(usersReadModel), { countRaw }); + + await request(app.callback()) + .post('/agent/v1/users/count') + .send({ search: 'ada', searchExtended: true }); + + expect(countRaw).toHaveBeenCalledWith('users', { + timezone: TIMEZONE, + search: 'ada', + searchExtended: true, + }); + }); + + it('should leave the agent query untouched when no search is sent', async () => { + const countRaw = jest.fn(async () => ({ count: 2 })); + const app = buildApp(storeOf(usersReadModel), { countRaw }); + + await request(app.callback()).post('/agent/v1/users/count').send({}); + + expect(countRaw).toHaveBeenCalledWith('users', { timezone: TIMEZONE }); + }); + + it('should reject a non-string search with 400 without calling the agent', async () => { + const countRaw = jest.fn(); + const app = buildApp(storeOf(usersReadModel), { countRaw }); + + const response = await request(app.callback()) + .post('/agent/v1/users/count') + .send({ search: 42 }); + + expect(response.status).toBe(400); + expect(response.body.error).toMatchObject({ type: 'invalid_request', status: 400 }); + expect(countRaw).not.toHaveBeenCalled(); + }); + it('should reject a nested relation path in the count filter with 422', async () => { const countRaw = jest.fn(); const app = buildApp(storeOf(usersReadModel), { countRaw }); @@ -820,6 +926,21 @@ describe('data routes middleware', () => { }); }); + it('should search the foreign collection, not the parent one', async () => { + const listRelation = jest.fn(async () => []); + const app = buildApp(storeOf(relationReadModel), { listRelation }); + + await request(app.callback()) + .post('/agent/v1/users/relations/posts/list') + .send({ parentId: '7', search: 'hello', searchExtended: true }); + + expect(listRelation).toHaveBeenCalledWith('users', '7', 'posts', { + timezone: TIMEZONE, + search: 'hello', + searchExtended: true, + }); + }); + it('should resolve the parent from the path but project/filter/sort on the foreign collection', async () => { const listRelation = jest.fn(async () => []); const app = buildApp(storeOf(relationReadModel), { listRelation }); @@ -1007,6 +1128,21 @@ describe('data routes middleware', () => { expect(response.body).toEqual({ count: null, countStatus: 'deactivated' }); }); + it('should pass search and searchExtended to the relation count query', async () => { + const countRelationRaw = jest.fn(async () => ({ count: 1 })); + const app = buildApp(storeOf(relationReadModel), { countRelationRaw }); + + await request(app.callback()) + .post('/agent/v1/users/relations/posts/count') + .send({ parentId: '7', search: 'hello', searchExtended: true }); + + expect(countRelationRaw).toHaveBeenCalledWith('users', '7', 'posts', { + timezone: TIMEZONE, + search: 'hello', + searchExtended: true, + }); + }); + it.each([ ['unknown relation', 'users', 'ghosts', 'unknown_relation'], ['to-one relation', 'users', 'company', 'unknown_relation'], diff --git a/packages/agent-bff/test/data/fixtures/in-memory-collection.ts b/packages/agent-bff/test/data/fixtures/in-memory-collection.ts new file mode 100644 index 0000000000..56c34b332e --- /dev/null +++ b/packages/agent-bff/test/data/fixtures/in-memory-collection.ts @@ -0,0 +1,87 @@ +import type { + AggregateResult, + Aggregation, + Caller, + DataSource, + FieldSchema, + Filter, + Operator, + PaginatedFilter, + Projection, + RecordData, +} from '@forestadmin/datasource-toolkit'; + +import { BaseCollection } from '@forestadmin/datasource-toolkit'; + +// The search decorator prefers IContains on a String column and only falls back to Contains then +// Equal, so a fixture omitting it would exercise a case-sensitive path production does not take. +const OPERATORS = new Set([ + 'IContains', + 'Contains', + 'Equal', + 'NotEqual', + 'In', + 'Present', +]); + +/** + * A read-only in-memory collection: enough to let the agent's own search decorator build a real + * condition tree and apply it. `searchable` stays false, as on any datasource with no native + * search, which is the path the decorator implements itself. + */ +export default class InMemoryCollection extends BaseCollection { + private readonly records: RecordData[]; + + constructor( + datasource: DataSource, + name: string, + fields: Record, + records: RecordData[], + ) { + super(name, datasource); + this.records = records; + this.addFields(fields); + this.enableCount(); + + for (const schema of Object.values(this.schema.fields)) { + if (schema.type === 'Column') schema.filterOperators = OPERATORS; + } + } + + async list( + caller: Caller, + filter: PaginatedFilter, + projection: Projection, + ): Promise { + let result = this.records.slice(); + if (filter?.conditionTree) result = filter.conditionTree.apply(result, this, caller.timezone); + if (filter?.page) result = filter.page.apply(result); + + return projection.apply(result); + } + + async aggregate( + caller: Caller, + filter: Filter, + aggregation: Aggregation, + limit?: number, + ): Promise { + return aggregation.apply( + await this.list(caller, filter as PaginatedFilter, aggregation.projection), + caller.timezone, + limit, + ); + } + + async create(): Promise { + throw new Error('The search fixture is read-only'); + } + + async update(): Promise { + throw new Error('The search fixture is read-only'); + } + + async delete(): Promise { + throw new Error('The search fixture is read-only'); + } +} diff --git a/packages/agent-bff/test/data/fixtures/search-datasource.ts b/packages/agent-bff/test/data/fixtures/search-datasource.ts new file mode 100644 index 0000000000..b42da27e3b --- /dev/null +++ b/packages/agent-bff/test/data/fixtures/search-datasource.ts @@ -0,0 +1,52 @@ +import type { FieldSchema, RecordData } from '@forestadmin/datasource-toolkit'; + +import { BaseDataSource } from '@forestadmin/datasource-toolkit'; + +import InMemoryCollection from './in-memory-collection'; + +const AUTHORS: RecordData[] = [ + { id: 1, name: 'Isaac Asimov' }, + { id: 2, name: 'Ursula Le Guin' }, +]; + +// No title contains "asimov", so a search for it returns nothing unless it reaches the author +// relation — which is what tells searchExtended apart from a plain search. +const BOOKS: RecordData[] = [ + { id: 1, title: 'Foundation', authorId: 1 }, + { id: 2, title: 'I, Robot', authorId: 1 }, + { id: 3, title: 'The Dispossessed', authorId: 2 }, +]; + +// Its label carries the same term as a book title, so a rejection asserted on this collection +// cannot pass by accident on a collection that has nothing to match. +const LEDGERS: RecordData[] = [{ id: 1, label: 'Foundation ledger' }]; + +const NUMBER_PK: FieldSchema = { type: 'Column', columnType: 'Number', isPrimaryKey: true }; +const STRING_COLUMN: FieldSchema = { type: 'Column', columnType: 'String' }; + +export default class SearchDataSource extends BaseDataSource { + constructor() { + super(); + + this.addCollection( + new InMemoryCollection(this, 'authors', { id: NUMBER_PK, name: STRING_COLUMN }, AUTHORS), + ); + + this.addCollection( + new InMemoryCollection( + this, + 'books', + { + id: NUMBER_PK, + title: STRING_COLUMN, + authorId: { type: 'Column', columnType: 'Number' }, + }, + BOOKS, + ), + ); + + this.addCollection( + new InMemoryCollection(this, 'ledgers', { id: NUMBER_PK, label: STRING_COLUMN }, LEDGERS), + ); + } +} diff --git a/packages/agent-bff/test/data/search-agent-integration.test.ts b/packages/agent-bff/test/data/search-agent-integration.test.ts new file mode 100644 index 0000000000..09626e2c27 --- /dev/null +++ b/packages/agent-bff/test/data/search-agent-integration.test.ts @@ -0,0 +1,256 @@ +import type { Logger } from '../../src/ports/logger-port'; +import type { SchemaFetcher } from '../../src/read-model/forest-schema-client'; +import type { TestableAgent } from '@forestadmin/agent-testing'; +import type { ForestSchemaCollection } from '@forestadmin/forestadmin-client'; + +import { createTestableAgent } from '@forestadmin/agent-testing'; +import { bodyParser } from '@koa/bodyparser'; +import fs from 'fs/promises'; +import jsonwebtoken from 'jsonwebtoken'; +import Koa from 'koa'; +import net from 'net'; +import os from 'os'; +import path from 'path'; +import request from 'supertest'; + +import SearchDataSource from './fixtures/search-datasource'; +import createDataRoutesMiddleware from '../../src/data/data-routes-middleware'; +import createErrorMiddleware from '../../src/http/error-middleware'; +import CapabilitiesCache from '../../src/read-model/capabilities-cache'; +import ReadModelStore from '../../src/read-model/read-model-store'; +import SchemaCache from '../../src/read-model/schema-cache'; + +const TIMEZONE = 'Europe/Paris'; +const AUTH_SECRET = 'b0bdf0a639c16bae8851dd24ee3d79ef0a352e957c5b86cb'; +const ENV_SECRET = 'ceba742f5bc73946b34da192816a4d7177b3233fee7769955c29c0e90fd584f2'; +const BOOT_TIMEOUT_MS = 60_000; + +// agent-testing only deletes a schema file whose name carries this prefix, so reusing it keeps the +// temporary schema cleaned up by `agent.stop()` even though the path is chosen here. +const RESERVED_SCHEMA_PREFIX = 'reserved-forestadmin-schema-test-'; + +const noopLogger: Logger = () => {}; + +async function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + + server.on('error', reject); + server.listen(0, () => { + const { port } = server.address() as net.AddressInfo; + + server.close(() => resolve(port)); + }); + }); +} + +function agentToken(): string { + return jsonwebtoken.sign( + { id: 1, email: 'forest@forest.com', renderingId: 1, team: 'admin' }, + AUTH_SECRET, + { expiresIn: '1 hour' }, + ); +} + +function schemaFetcherFromFile(schemaPath: string): SchemaFetcher { + return { + fetchSchema: async () => { + const { collections } = JSON.parse(await fs.readFile(schemaPath, 'utf8')) as { + collections: ForestSchemaCollection[]; + }; + + return collections; + }, + }; +} + +/** + * The BFF in front of a real agent: the middleware production mounts, the real data client, and a + * read-model built from the schema the agent just wrote. Only the schema transport is swapped — + * production fetches it from the Forest server, which plays no part in search. + */ +function buildApp(agentUrl: string, schemaPath: string): Koa { + const token = agentToken(); + const schemaCache = new SchemaCache({ + fetcher: schemaFetcherFromFile(schemaPath), + metrics: { increment: () => {}, gauge: () => {} }, + }); + const store = new ReadModelStore(schemaCache, new CapabilitiesCache()); + const app = new Koa(); + + app.silent = true; + app.use(createErrorMiddleware({ logger: noopLogger })); + app.use(bodyParser()); + app.use(async (ctx, next) => { + ctx.state.timezone = TIMEZONE; + ctx.state.agentToken = token; + await next(); + }); + app.use(createDataRoutesMiddleware({ store, agentUrl, logger: noopLogger })); + + return app; +} + +describe('search against a real agent', () => { + let agent: TestableAgent; + let app: Koa; + + beforeAll(async () => { + const port = await findFreePort(); + const schemaPath = path.join(os.tmpdir(), `${RESERVED_SCHEMA_PREFIX}-bff-search-${port}.json`); + + agent = await createTestableAgent( + forestAgent => { + forestAgent.addDataSource(async () => new SearchDataSource()); + forestAgent.customizeCollection('books', collection => + collection.addManyToOneRelation('author', 'authors', { foreignKey: 'authorId' }), + ); + forestAgent.customizeCollection('ledgers', collection => collection.disableSearch()); + }, + { authSecret: AUTH_SECRET, envSecret: ENV_SECRET, isProduction: false, port, schemaPath }, + ); + + await agent.start(); + + app = buildApp(`http://localhost:${port}`, schemaPath); + }, BOOT_TIMEOUT_MS); + + afterAll(async () => { + await agent?.stop(); + }); + + function titlesOf(body: { data: Array<{ title: string }> }): string[] { + return body.data.map(record => record.title).sort(); + } + + describe('list', () => { + it('should return only the records the search matches', async () => { + const response = await request(app.callback()) + .post('/agent/v1/books/list') + .send({ projection: ['id', 'title'], search: 'foundation' }); + + expect(response.status).toBe(200); + expect(titlesOf(response.body)).toEqual(['Foundation']); + }); + + it('should return every record when no search is sent', async () => { + const response = await request(app.callback()) + .post('/agent/v1/books/list') + .send({ projection: ['id', 'title'] }); + + expect(response.status).toBe(200); + expect(titlesOf(response.body)).toEqual(['Foundation', 'I, Robot', 'The Dispossessed']); + }); + + it('should not match a related record without searchExtended', async () => { + const response = await request(app.callback()) + .post('/agent/v1/books/list') + .send({ projection: ['id', 'title'], search: 'asimov' }); + + expect(response.status).toBe(200); + expect(response.body.data).toEqual([]); + }); + + it('should match through a relation when searchExtended is true', async () => { + const response = await request(app.callback()) + .post('/agent/v1/books/list') + .send({ projection: ['id', 'title'], search: 'asimov', searchExtended: true }); + + expect(response.status).toBe(200); + expect(titlesOf(response.body)).toEqual(['Foundation', 'I, Robot']); + }); + + it('should intersect the search with the filter rather than replace it', async () => { + const response = await request(app.callback()) + .post('/agent/v1/books/list') + .send({ + projection: ['id', 'title'], + search: 'asimov', + searchExtended: true, + filter: { field: 'title', operator: 'IContains', value: 'robot' }, + }); + + expect(response.status).toBe(200); + expect(titlesOf(response.body)).toEqual(['I, Robot']); + }); + + it('should list everything when the search holds only whitespace', async () => { + const response = await request(app.callback()) + .post('/agent/v1/books/list') + .send({ projection: ['id', 'title'], search: ' ' }); + + expect(response.status).toBe(200); + expect(titlesOf(response.body)).toEqual(['Foundation', 'I, Robot', 'The Dispossessed']); + }); + }); + + describe('count', () => { + it('should count the searched rows, not the whole collection', async () => { + const searched = await request(app.callback()) + .post('/agent/v1/books/count') + .send({ search: 'foundation' }); + const all = await request(app.callback()).post('/agent/v1/books/count').send({}); + + expect(all.body).toEqual({ count: 3, countStatus: 'available' }); + expect(searched.body).toEqual({ count: 1, countStatus: 'available' }); + }); + + it('should count the rows a relation-extended search returns', async () => { + const response = await request(app.callback()) + .post('/agent/v1/books/count') + .send({ search: 'asimov', searchExtended: true }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ count: 2, countStatus: 'available' }); + }); + }); + + describe('a collection whose search is disabled', () => { + // Outside production the agent dumps every handled error to stderr, so the two rejections + // asserted here would drown the suite output in expected stack traces. + let consoleError: jest.SpyInstance; + + beforeAll(() => { + consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterAll(() => { + consoleError.mockRestore(); + }); + + it('should reject a list search rather than return an unfiltered listing', async () => { + const response = await request(app.callback()) + .post('/agent/v1/ledgers/list') + .send({ projection: ['id', 'label'], search: 'foundation' }); + + expect(response.status).toBe(400); + expect(response.body.error).toMatchObject({ + type: 'validation_error', + status: 400, + message: 'Collection is not searchable', + }); + }); + + it('should reject a count search the same way list does', async () => { + const response = await request(app.callback()) + .post('/agent/v1/ledgers/count') + .send({ search: 'foundation' }); + + expect(response.status).toBe(400); + expect(response.body.error).toMatchObject({ + type: 'validation_error', + status: 400, + message: 'Collection is not searchable', + }); + }); + + it('should still serve it when no search is sent', async () => { + const response = await request(app.callback()) + .post('/agent/v1/ledgers/list') + .send({ projection: ['id', 'label'] }); + + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(1); + }); + }); +}); diff --git a/packages/agent-bff/test/openapi/openapi-document.test.ts b/packages/agent-bff/test/openapi/openapi-document.test.ts index aa8993306c..ac3dcefd00 100644 --- a/packages/agent-bff/test/openapi/openapi-document.test.ts +++ b/packages/agent-bff/test/openapi/openapi-document.test.ts @@ -377,6 +377,48 @@ describe('generateOpenApiDocument', () => { }); }); +describe('the documented search inputs', () => { + function propertiesOf(name: string): Record { + return (schemas[name] as { properties: Record }).properties; + } + + function refsOf(name: string): string[] { + return (schemas[name] as { allOf: Array<{ $ref?: string }> }).allOf + .map(entry => entry.$ref) + .filter(Boolean) as string[]; + } + + it.each([['ListRequest'], ['CountRequest']])('should expose search on %s', name => { + expect(propertiesOf(name).search).toEqual({ $ref: '#/components/schemas/Search' }); + expect(propertiesOf(name).searchExtended).toEqual({ + $ref: '#/components/schemas/SearchExtended', + }); + }); + + it.each([ + ['RelationListRequest', 'ListRequest'], + ['RelationCountRequest', 'CountRequest'], + ])('should let %s inherit the search inputs from %s', (relation, base) => { + expect(refsOf(relation)).toContain(`#/components/schemas/${base}`); + }); + + it('should say a blank search is treated as absent rather than rejected', () => { + expect(schemas.Search.description).toContain('treated as absent'); + }); + + it('should say a non-searchable collection is answered by the agent, not the BFF', () => { + expect(schemas.Search.description).toContain('Collection is not searchable'); + }); + + it('should say searchExtended alone changes nothing', () => { + expect(schemas.SearchExtended.description).toContain('without `search` it is ignored'); + }); + + it('should warn that searchExtended reads relation fields the response cannot show', () => { + expect(schemas.SearchExtended.description).toContain('relation_field_not_supported'); + }); +}); + describe('serializeOpenApi', () => { it('should produce indented JSON that parses back to the same document', () => { const serialized = serializeOpenApi(document);