From fdf457770baa5ba9b65a442cd9c9282928713794 Mon Sep 17 00:00:00 2001 From: gestchild Date: Thu, 18 Dec 2025 01:14:17 +0000 Subject: [PATCH 1/6] update README --- api/README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/api/README.md b/api/README.md index d0d0d1bf..69714f7c 100644 --- a/api/README.md +++ b/api/README.md @@ -9,3 +9,23 @@ Then run The tests save [Jest snapshots](https://jestjs.io/docs/snapshot-testing) of the expected output. To update them, you'll need to run `yarn jest --updateSnapshot`. + +## Event formats filtering + +The `/events` endpoint supports inclusive and exclusive format filters via the `format` query parameter. + +- To include specific formats, pass their IDs: `?format=,`. +- To exclude formats, prefix them with `!`: `?format=!,!`. + +Additionally, the API supports human-friendly aliases for the +Prismic format IDs. + +Examples: + +- Use a label to include only `workshop` events: + + `/events?format=workshop` + +- Use a label to exclude `shopping` events: + + `/events?format=!shopping` From e78c9887168a47a9c25c31c488c5886d5c6a8784 Mon Sep 17 00:00:00 2001 From: gestchild Date: Thu, 18 Dec 2025 01:28:20 +0000 Subject: [PATCH 2/6] adds aliases for ids --- api/src/controllers/events.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/api/src/controllers/events.ts b/api/src/controllers/events.ts index e331587a..763748e0 100644 --- a/api/src/controllers/events.ts +++ b/api/src/controllers/events.ts @@ -94,6 +94,28 @@ const timespanValidator = queryValidator({ singleValue: true, }); +const formatAliasMap: Record = { + exhibitions: EVENT_EXHIBITION_FORMAT_ID, + shopping: 'W-BjXhEAAASpa8Kb', + screening: 'W5fV0iYAACYAMxF9', + festival: 'W5fV5iYAACQAMxHb', + 'send-workshop': 'W5ZIZyYAACMALDSB', + 'walking-tour': 'WcKmcSsAACx_A8La', + 'study-day': 'WcKmeisAALN8A8MB', + workshop: 'WcKmiysAACx_A8NR', + discussion: 'Wd-QYCcAACcAoiJS', + seminar: 'WlYVBiQAACcAWcu9', + 'gallery-tour': 'WmYRpCQAACUAn-Ap', + symposium: 'Wn3NiioAACsAIdNK', + performance: 'Wn3Q3SoAACsAIeFI', + late: 'Ww_LyiEAAFOTlJ4-', + 'chill-out': 'Xa7NJhAAAGpKv4uR', + installation: 'XiCd_BQAACQA36bS', + game: 'XiCdcxQAACIA36RO', + session: 'YzGUuBEAANURf3dM', + 'relaxed-opening': 'ZCv01hQAAOAiVLeR', +}; + const paramsValidator = (params: QueryParams): QueryParams => { const { isAvailableOnline, filterOutExhibitions, ...rest } = params; From ab8b5d66abbc339c2cda7b76803b06c7b541d1da Mon Sep 17 00:00:00 2001 From: gestchild Date: Thu, 18 Dec 2025 01:30:20 +0000 Subject: [PATCH 3/6] adds exclude functionality for format param --- api/src/controllers/events.ts | 60 +++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/api/src/controllers/events.ts b/api/src/controllers/events.ts index 763748e0..70361690 100644 --- a/api/src/controllers/events.ts +++ b/api/src/controllers/events.ts @@ -34,6 +34,11 @@ type QueryParams = { sortOrder?: string; aggregations?: string; format?: string; + // `excludeFormat` comes from the `paramsValidator` when the public `format` + // query parameter contains negated values (e.g. `format=!exhibitions`). It + // is a comma-separated list of Prismic format IDs to exclude from results + // and is used to build an Elasticsearch `must_not` clause. + excludeFormat?: string; audience?: string; interpretation?: string; location?: string; @@ -117,7 +122,7 @@ const formatAliasMap: Record = { }; const paramsValidator = (params: QueryParams): QueryParams => { - const { isAvailableOnline, filterOutExhibitions, ...rest } = params; + const { isAvailableOnline, filterOutExhibitions, format, ...rest } = params; if (params.location) locationsValidator({ @@ -133,7 +138,44 @@ const paramsValidator = (params: QueryParams): QueryParams => { if (params.audience) prismicIdValidator(params.audience, 'audiences'); if (params.interpretation) prismicIdValidator(params.interpretation, 'interpretations'); - if (params.format) prismicIdValidator(params.format, 'formats'); + + const includeFormats: string[] = []; + const excludeFormats: string[] = []; + + if (format) { + const rawFormats = format.split(',').map(value => value.trim()); + + rawFormats.forEach(value => { + if (!value) return; + + if (value.startsWith('!')) { + const rawExclusion = value.slice(1).trim(); + + if (!rawExclusion) return; + + // Normalise alias -> id; keep original value if no mapping exists + let normalized = rawExclusion; + if (formatAliasMap[normalized.toLowerCase()]) { + normalized = formatAliasMap[normalized.toLowerCase()]; + } + excludeFormats.push(normalized); + } else { + let normalized = value; + if (formatAliasMap[normalized.toLowerCase()]) { + normalized = formatAliasMap[normalized.toLowerCase()]; + } + includeFormats.push(normalized); + } + }); + + if (includeFormats.length > 0) { + prismicIdValidator(includeFormats.join(','), 'formats'); + } + + if (excludeFormats.length > 0) { + prismicIdValidator(excludeFormats.join(','), 'formats'); + } + } // Validate linkedWork parameter(s) if (params.linkedWork) { @@ -160,6 +202,10 @@ const paramsValidator = (params: QueryParams): QueryParams => { // Anything else should remove the param from the query return { ...rest, + ...(includeFormats.length > 0 ? { format: includeFormats.join(',') } : {}), + ...(excludeFormats.length > 0 + ? { excludeFormat: excludeFormats.join(',') } + : {}), ...(hasIsAvailableOnline ? { isAvailableOnline } : {}), ...(hasFilterOutExhibitions ? { filterOutExhibitions } : {}), }; @@ -308,6 +354,16 @@ const eventsController = (clients: Clients, config: Config): EventsHandler => { }, ] : []), + ...(validParams.excludeFormat + ? [ + { + terms: { + 'filter.format': + validParams.excludeFormat.split(','), + }, + }, + ] + : []), ], }, }, From f36bfb1c07e337ac38d9c9d939b02cc0966cdd42 Mon Sep 17 00:00:00 2001 From: gestchild Date: Thu, 18 Dec 2025 15:00:22 +0000 Subject: [PATCH 4/6] test elastic search queries are correct --- api/test/events.test.ts | 79 +++++++++++++++++++++++++++++++++++----- api/test/fixtures/api.ts | 8 +++- 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/api/test/events.test.ts b/api/test/events.test.ts index 36f3d84e..fd81f8ba 100644 --- a/api/test/events.test.ts +++ b/api/test/events.test.ts @@ -1,3 +1,5 @@ +import { EVENT_EXHIBITION_FORMAT_ID } from '@weco/content-common/data/defaultValues'; + import { mockedApi } from './fixtures/api'; describe('GET /events', () => { @@ -8,9 +10,9 @@ describe('GET /events', () => { title: `test event ${i}`, }, })); - const api = mockedApi(events); + const { agent } = mockedApi(events); - const response = await api.get(`/events`); + const response = await agent.get(`/events`); expect(response.statusCode).toBe(200); expect(response.body.results).toStrictEqual(events.map(x => x.display)); }); @@ -22,17 +24,17 @@ describe('GET /events', () => { display: { title: 'Event with works' }, }, ]; - const api = mockedApi(events); + const { agent } = mockedApi(events); - const response = await api.get(`/events?linkedWork=work123`); + const response = await agent.get(`/events?linkedWork=work123`); expect(response.statusCode).toBe(200); expect(response.body.results).toBeDefined(); }); it('returns 400 for invalid linkedWork format', async () => { - const api = mockedApi([]); + const { agent } = mockedApi([]); - const response = await api.get(`/events?linkedWork=invalid-work-id!`); + const response = await agent.get(`/events?linkedWork=invalid-work-id!`); expect(response.statusCode).toBe(400); expect(response.body.description).toContain('Invalid work ID format'); }); @@ -44,9 +46,9 @@ describe('GET /events', () => { display: { title: 'Health event' }, }, ]; - const api = mockedApi(events); + const { agent } = mockedApi(events); - const response = await api.get(`/events?query=health&linkedWork=work123`); + const response = await agent.get(`/events?query=health&linkedWork=work123`); expect(response.statusCode).toBe(200); expect(response.body.results).toBeDefined(); }); @@ -58,10 +60,67 @@ describe('GET /events', () => { display: { title: 'Event with multiple works' }, }, ]; - const api = mockedApi(events); + const { agent } = mockedApi(events); - const response = await api.get(`/events?linkedWork=work123,work456`); + const response = await agent.get(`/events?linkedWork=work123,work456`); expect(response.statusCode).toBe(200); expect(response.body.results).toBeDefined(); }); + + describe('format alias mapping', () => { + it('maps exhibitions slug to correct Prismic ID in ES query', async () => { + const events = [{ id: 'event-1', display: { title: 'Test' } }]; + const { agent, mocks } = mockedApi(events); + + await agent.get('/events?format=!exhibitions'); + + expect(mocks.elasticClientSearch).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ + bool: expect.objectContaining({ + filter: expect.arrayContaining([ + expect.objectContaining({ + bool: expect.objectContaining({ + must_not: expect.arrayContaining([ + { + terms: { + 'filter.format': [EVENT_EXHIBITION_FORMAT_ID], + }, + }, + ]), + }), + }), + ]), + }), + }), + }) + ); + }); + + it('passes raw Prismic ID through unchanged in ES query', async () => { + const events = [{ id: 'event-1', display: { title: 'Test' } }]; + const { agent, mocks } = mockedApi(events); + + const rawId = 'WcKmiysAACx_A8NR'; + await agent.get(`/events?format=!${rawId}`); + + expect(mocks.elasticClientSearch).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ + bool: expect.objectContaining({ + filter: expect.arrayContaining([ + expect.objectContaining({ + bool: expect.objectContaining({ + must_not: expect.arrayContaining([ + { terms: { 'filter.format': [rawId] } }, + ]), + }), + }), + ]), + }), + }), + }) + ); + }); + }); }); diff --git a/api/test/fixtures/api.ts b/api/test/fixtures/api.ts index ac0f9498..d20b8c33 100644 --- a/api/test/fixtures/api.ts +++ b/api/test/fixtures/api.ts @@ -90,5 +90,11 @@ export const mockedApi = ( mockConfig ); - return supertest.agent(app); + return { + agent: supertest.agent(app), + mocks: { + elasticClientSearch, + elasticClientGet, + }, + }; }; From 03e8fc1cb7b0c83f076f51371ef943f9d80cf2bb Mon Sep 17 00:00:00 2001 From: gestchild Date: Tue, 23 Dec 2025 16:07:56 +0000 Subject: [PATCH 5/6] fix tests --- api/test/addressable.test.ts | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/api/test/addressable.test.ts b/api/test/addressable.test.ts index 5a525f52..d1de57c0 100644 --- a/api/test/addressable.test.ts +++ b/api/test/addressable.test.ts @@ -4,17 +4,17 @@ describe('GET /all/:id', () => { it('returns a document for the given ID', async () => { const testId = 'Z-L8zREAACUAxTSz.exhibitions'; const testDoc = { title: 'test-addressable' }; - const api = mockedApi([{ id: testId, display: testDoc }]); + const { agent } = mockedApi([{ id: testId, display: testDoc }]); - const response = await api.get(`/all/${encodeURIComponent(testId)}`); + const response = await agent.get(`/all/${encodeURIComponent(testId)}`); expect(response.statusCode).toBe(200); expect(response.body).toStrictEqual(testDoc); }); it('returns a 404 if no document for the given ID exists', async () => { - const api = mockedApi([]); + const { agent } = mockedApi([]); - const response = await api.get( + const response = await agent.get( `/all/${encodeURIComponent('Z-L8zREAACUAxTSz.exhibitions')}` ); expect(response.statusCode).toBe(404); @@ -22,25 +22,27 @@ describe('GET /all/:id', () => { it('returns a 400 for invalid content types', async () => { const invalidTestId = 'ZX123.invalid-content-type'; - const api = mockedApi([]); + const { agent } = mockedApi([]); - const response = await api.get(`/all/${encodeURIComponent(invalidTestId)}`); + const response = await agent.get( + `/all/${encodeURIComponent(invalidTestId)}` + ); expect(response.statusCode).toBe(400); expect(response.body.description).toContain('Invalid content type'); }); it('returns a 400 for invalid id format (missing content type)', async () => { - const api = mockedApi([]); + const { agent } = mockedApi([]); - const response = await api.get(`/all/Z-L8zREAACUAxTSz`); + const response = await agent.get(`/all/Z-L8zREAACUAxTSz`); expect(response.statusCode).toBe(400); expect(response.body.description).toContain('Invalid id format'); }); it('returns a 400 for invalid Prismic ID format', async () => { - const api = mockedApi([]); + const { agent } = mockedApi([]); - const response = await api.get( + const response = await agent.get( `/all/${encodeURIComponent('invalid%20id.exhibitions')}` ); expect(response.statusCode).toBe(400); @@ -48,9 +50,9 @@ describe('GET /all/:id', () => { }); it('returns a 400 for invalid exhibition highlight tour type', async () => { - const api = mockedApi([]); + const { agent } = mockedApi([]); - const response = await api.get( + const response = await agent.get( `/all/${encodeURIComponent('Z-L8zREAACUAxTSz.exhibition-highlight-tours.invalid')}` ); expect(response.statusCode).toBe(400); @@ -60,9 +62,9 @@ describe('GET /all/:id', () => { it('accepts valid exhibition highlight tour with audio type', async () => { const testId = 'Z-L8zREAACUAxTSz.exhibition-highlight-tours.audio'; const testDoc = { title: 'test-tour' }; - const api = mockedApi([{ id: testId, display: testDoc }]); + const { agent } = mockedApi([{ id: testId, display: testDoc }]); - const response = await api.get(`/all/${encodeURIComponent(testId)}`); + const response = await agent.get(`/all/${encodeURIComponent(testId)}`); expect(response.statusCode).toBe(200); expect(response.body).toStrictEqual(testDoc); }); @@ -70,9 +72,9 @@ describe('GET /all/:id', () => { it('accepts valid exhibition highlight tour with BSL type', async () => { const testId = 'Z-L8zREAACUAxTSz.exhibition-highlight-tours.bsl'; const testDoc = { title: 'test-bsl-tour' }; - const api = mockedApi([{ id: testId, display: testDoc }]); + const { agent } = mockedApi([{ id: testId, display: testDoc }]); - const response = await api.get(`/all/${encodeURIComponent(testId)}`); + const response = await agent.get(`/all/${encodeURIComponent(testId)}`); expect(response.statusCode).toBe(200); expect(response.body).toStrictEqual(testDoc); }); From 44e8cdd509c4c1a1b5c62bb606d7011bd06a9709 Mon Sep 17 00:00:00 2001 From: gestchild Date: Tue, 23 Dec 2025 16:30:16 +0000 Subject: [PATCH 6/6] fix tests --- api/test/__snapshots__/query.test.ts.snap | 49 +++++++++++++++++++++++ api/test/addressables.test.ts | 16 ++++---- api/test/article.test.ts | 8 ++-- api/test/articles.test.ts | 22 +++++----- api/test/event.test.ts | 8 ++-- api/test/venues.test.ts | 4 +- 6 files changed, 79 insertions(+), 28 deletions(-) diff --git a/api/test/__snapshots__/query.test.ts.snap b/api/test/__snapshots__/query.test.ts.snap index fd20fb8e..b7d67f53 100644 --- a/api/test/__snapshots__/query.test.ts.snap +++ b/api/test/__snapshots__/query.test.ts.snap @@ -46,6 +46,55 @@ exports[`addressables query makes the expected query to ES for a given set of qu } `; +exports[`addressables query makes the expected query to ES for multiple linkedWork parameters 1`] = ` +{ + "_source": [ + "display", + ], + "from": 0, + "index": "test-addressables", + "query": { + "bool": { + "must": [ + { + "multi_match": { + "fields": [ + "id", + "uid", + "query.title.*^100", + "query.contributors.*^10", + "query.contributors.keyword^100", + "query.body.*", + "query.description.*", + ], + "minimum_should_match": "-25%", + "operator": "or", + "query": "sculpture", + "type": "cross_fields", + }, + }, + { + "terms": { + "query.linkedWorks": [ + "work123", + "work456", + ], + }, + }, + ], + "must_not": [ + { + "term": { + "query.tags": "delist", + }, + }, + ], + }, + }, + "size": 20, +} +`; + exports[`articles query makes the expected query to ES for a given set of query parameters 1`] = ` { "_source": [ diff --git a/api/test/addressables.test.ts b/api/test/addressables.test.ts index 3b89e457..05f2f4f9 100644 --- a/api/test/addressables.test.ts +++ b/api/test/addressables.test.ts @@ -8,9 +8,9 @@ describe('GET /all', () => { title: `test doc ${i}`, }, })); - const api = mockedApi(docs); + const { agent } = mockedApi(docs); - const response = await api.get(`/all`); + const response = await agent.get(`/all`); expect(response.statusCode).toBe(200); expect(response.body.results).toStrictEqual(docs.map(d => d.display)); }); @@ -22,17 +22,17 @@ describe('GET /all', () => { display: { title: 'Document with works' }, }, ]; - const api = mockedApi(docs); + const { agent } = mockedApi(docs); - const response = await api.get(`/all?linkedWork=work123`); + const response = await agent.get(`/all?linkedWork=work123`); expect(response.statusCode).toBe(200); expect(response.body.results).toBeDefined(); }); it('returns 400 for invalid linkedWork format', async () => { - const api = mockedApi([]); + const { agent } = mockedApi([]); - const response = await api.get(`/all?linkedWork=invalid-work-id!`); + const response = await agent.get(`/all?linkedWork=invalid-work-id!`); expect(response.statusCode).toBe(400); expect(response.body.description).toContain('Invalid work ID format'); }); @@ -44,9 +44,9 @@ describe('GET /all', () => { display: { title: 'Health article' }, }, ]; - const api = mockedApi(docs); + const { agent } = mockedApi(docs); - const response = await api.get(`/all?query=health&linkedWork=work123`); + const response = await agent.get(`/all?query=health&linkedWork=work123`); expect(response.statusCode).toBe(200); expect(response.body.results).toBeDefined(); }); diff --git a/api/test/article.test.ts b/api/test/article.test.ts index a9415314..11a78fa7 100644 --- a/api/test/article.test.ts +++ b/api/test/article.test.ts @@ -4,17 +4,17 @@ describe('GET /articles/:id', () => { it('returns a document for the given ID', async () => { const testId = '123'; const testDoc = { title: 'test-article' }; - const api = mockedApi([{ id: testId, display: testDoc }]); + const { agent } = mockedApi([{ id: testId, display: testDoc }]); - const response = await api.get(`/articles/${testId}`); + const response = await agent.get(`/articles/${testId}`); expect(response.statusCode).toBe(200); expect(response.body).toStrictEqual(testDoc); }); it('returns a 404 if no document for the given ID exists', async () => { - const api = mockedApi([]); + const { agent } = mockedApi([]); - const response = await api.get(`/articles/123`); + const response = await agent.get(`/articles/123`); expect(response.statusCode).toBe(404); }); }); diff --git a/api/test/articles.test.ts b/api/test/articles.test.ts index b9fb4222..25c10bef 100644 --- a/api/test/articles.test.ts +++ b/api/test/articles.test.ts @@ -8,9 +8,9 @@ describe('GET /articles', () => { title: `test doc ${i}`, }, })); - const api = mockedApi(docs); + const { agent } = mockedApi(docs); - const response = await api.get(`/articles`); + const response = await agent.get(`/articles`); expect(response.statusCode).toBe(200); expect(response.body.results).toStrictEqual(docs.map(d => d.display)); }); @@ -22,17 +22,17 @@ describe('GET /articles', () => { display: { title: 'Article with works' }, }, ]; - const api = mockedApi(docs); + const { agent } = mockedApi(docs); - const response = await api.get(`/articles?linkedWork=work123`); + const response = await agent.get(`/articles?linkedWork=work123`); expect(response.statusCode).toBe(200); expect(response.body.results).toBeDefined(); }); it('returns 400 for invalid linkedWork format', async () => { - const api = mockedApi([]); + const { agent } = mockedApi([]); - const response = await api.get(`/articles?linkedWork=invalid-work-id!`); + const response = await agent.get(`/articles?linkedWork=invalid-work-id!`); expect(response.statusCode).toBe(400); expect(response.body.description).toContain('Invalid work ID format'); }); @@ -44,9 +44,11 @@ describe('GET /articles', () => { display: { title: 'Health article' }, }, ]; - const api = mockedApi(docs); + const { agent } = mockedApi(docs); - const response = await api.get(`/articles?query=health&linkedWork=work123`); + const response = await agent.get( + `/articles?query=health&linkedWork=work123` + ); expect(response.statusCode).toBe(200); expect(response.body.results).toBeDefined(); }); @@ -58,9 +60,9 @@ describe('GET /articles', () => { display: { title: 'Article with multiple works' }, }, ]; - const api = mockedApi(docs); + const { agent } = mockedApi(docs); - const response = await api.get(`/articles?linkedWork=work123,work456`); + const response = await agent.get(`/articles?linkedWork=work123,work456`); expect(response.statusCode).toBe(200); expect(response.body.results).toBeDefined(); }); diff --git a/api/test/event.test.ts b/api/test/event.test.ts index dfd9bfce..05ac3528 100644 --- a/api/test/event.test.ts +++ b/api/test/event.test.ts @@ -4,17 +4,17 @@ describe('GET /events/:id', () => { it('returns a document for the given ID', async () => { const testId = 'abc'; const testDoc = { title: 'test-event' }; - const api = mockedApi([{ id: testId, display: testDoc }]); + const { agent } = mockedApi([{ id: testId, display: testDoc }]); - const response = await api.get(`/events/${testId}`); + const response = await agent.get(`/events/${testId}`); expect(response.statusCode).toBe(200); expect(response.body).toStrictEqual(testDoc); }); it('returns a 404 if no document for the given ID exists', async () => { - const api = mockedApi([]); + const { agent } = mockedApi([]); - const response = await api.get(`/events/abc`); + const response = await agent.get(`/events/abc`); expect(response.statusCode).toBe(404); }); }); diff --git a/api/test/venues.test.ts b/api/test/venues.test.ts index 20210937..1a00659c 100644 --- a/api/test/venues.test.ts +++ b/api/test/venues.test.ts @@ -249,11 +249,11 @@ describe('GET /venues', () => { jest.useFakeTimers().setSystemTime(new Date('2024-04-02T08:00:00.000Z')); const testId = '123'; - const api = mockedApi([ + const { agent } = mockedApi([ { id: testId, display: venueDisplay, data: venueData }, ]); - const response = await api.get(`/venues`); + const response = await agent.get(`/venues`); expect(response.statusCode).toBe(200); expect(response.body.results[0]).toEqual({ ...venueDisplay,