From 0ef3121d42aae04c101f67a0701378563023cc47 Mon Sep 17 00:00:00 2001 From: Karen Skauge Date: Tue, 20 Oct 2026 15:05:59 +0200 Subject: [PATCH 1/2] feat: add artist filtering and paginated artist options --- backend/src/index.ts | 80 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index 1f9b5e6..7fdbbc9 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -67,7 +67,8 @@ const typeDefs = `#graphql type Query { hello: String! - artworks(limit: Int!, offset: Int!, search: String): ArtworkPage! + artworks(limit: Int!, offset: Int!, search: String, artist: String): ArtworkPage! + artists(limit: Int!, offset: Int!, search: String): [String!]! } type Mutation { @@ -81,13 +82,81 @@ const resolvers = { Query: { hello: () => "Hello from Artex backend!", + artists: async ( + _: unknown, + { + limit, + offset, + search, + }: { + limit: number; + offset: number; + search?: string | null; + }, + ) => { + if (limit < 1 || limit > 100 || offset < 0) { + throw new Error("Invalid pagination parameters"); + } + + const collection = db.collection("met_artworks"); + const searchTerm = search?.trim() ?? ""; + const escapedSearchTerm = searchTerm.replace( + /[.*+?^${}()|[\]\\]/g, + "\\$&", + ); + + const artists = await collection + .aggregate<{ name: string }>([ + { + $match: { + artist_display: { $type: "string" }, + }, + }, + { + $match: { + artist_display: { + $regex: escapedSearchTerm, + $options: "i", + }, + }, + }, + { + $group: { + _id: "$artist_display", + }, + }, + { + $match: { + _id: { $regex: "\\S" }, + }, + }, + { $sort: { _id: 1 } }, + { $skip: offset }, + { $limit: limit }, + { + $project: { + _id: 0, + name: "$_id", + }, + }, + ]) + .toArray(); + + return artists.map((artist) => artist.name); + }, artworks: async ( _: unknown, { limit, offset, search, - }: { limit: number; offset: number; search?: string | null }, + artist, + }: { + limit: number; + offset: number; + search?: string | null; + artist?: string | null; + }, ) => { if (limit < 1 || limit > 100 || offset < 0) { throw new Error("Invalid pagination parameters"); @@ -99,7 +168,7 @@ const resolvers = { "\\$&", ); - const filter = searchTerm + const searchFilter = searchTerm ? { $or: [ { title: { $regex: escapedSearchTerm, $options: "i" } }, @@ -108,6 +177,11 @@ const resolvers = { } : {}; + const filter = { + ...searchFilter, + ...(artist ? { artist_display: artist } : {}), + }; + const [artworks, totalCount] = await Promise.all([ collection .find(filter) From 0e7afd4eb1652d563d3c6e37807187ca53661616 Mon Sep 17 00:00:00 2001 From: Karen Skauge Date: Tue, 20 Oct 2026 15:14:16 +0200 Subject: [PATCH 2/2] test: add artist filtering and artist options API tests --- backend/tests/artworks-filtering.test.ts | 165 +++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 backend/tests/artworks-filtering.test.ts diff --git a/backend/tests/artworks-filtering.test.ts b/backend/tests/artworks-filtering.test.ts new file mode 100644 index 0000000..66a077d --- /dev/null +++ b/backend/tests/artworks-filtering.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; + +async function fetchArtworks( + artist?: string, + search?: string, + limit = 10, + offset = 0, +) { + const response = await fetch("http://127.0.0.1:3001/", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query: ` + query FilterArtworks( + $artist: String + $search: String + $limit: Int! + $offset: Int! + ) { + artworks( + artist: $artist + search: $search + limit: $limit + offset: $offset + ) { + totalCount + artworks { + id + title + artist_display + } + } + } + `, + variables: { artist, search, limit, offset }, + }), + }); + + expect(response.ok).toBe(true); + + const result = await response.json(); + + expect(result.errors).toBeUndefined(); + expect(result.data?.artworks).toBeDefined(); + + return result.data.artworks; +} + +async function fetchArtists(search?: string, limit = 5, offset = 0) { + const response = await fetch("http://127.0.0.1:3001/", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query: ` + query ArtistOptions( + $search: String + $limit: Int! + $offset: Int! + ) { + artists(search: $search, limit: $limit, offset: $offset) + } + `, + variables: { search, limit, offset }, + }), + }); + + expect(response.ok).toBe(true); + + const result = await response.json(); + + expect(result.errors).toBeUndefined(); + expect(result.data?.artists).toBeDefined(); + + return result.data.artists as string[]; +} + +describe("Artwork filtering API", () => { + it("returns only artworks by the selected artist", async () => { + const artist = "John Singer Sargent"; + const result = await fetchArtworks(artist); + + expect(result.totalCount).toBeGreaterThan(0); + expect(result.artworks.length).toBeGreaterThan(0); + + for (const artwork of result.artworks) { + expect(artwork.artist_display).toBe(artist); + } + }); + + it("combines artist filtering with artwork search", async () => { + const result = await fetchArtworks("John Singer Sargent", "woman"); + + expect(result.totalCount).toBe(1); + expect(result.artworks).toEqual([ + { + id: 12072, + title: "Egyptian Woman", + artist_display: "John Singer Sargent", + }, + ]); + }); + + it("paginates filtered artworks while keeping the total count", async () => { + const artist = "John Singer Sargent"; + + const combined = await fetchArtworks(artist, undefined, 2, 0); + const firstPage = await fetchArtworks(artist, undefined, 1, 0); + const secondPage = await fetchArtworks(artist, undefined, 1, 1); + + expect(combined.totalCount).toBeGreaterThanOrEqual(2); + expect(firstPage.totalCount).toBe(combined.totalCount); + expect(secondPage.totalCount).toBe(combined.totalCount); + + expect(firstPage.artworks).toHaveLength(1); + expect(secondPage.artworks).toHaveLength(1); + + expect([...firstPage.artworks, ...secondPage.artworks]).toEqual( + combined.artworks, + ); + + expect(firstPage.artworks[0].id).not.toBe(secondPage.artworks[0].id); + }); + + it("returns no artworks when the selected artist does not exist", async () => { + const result = await fetchArtworks("nonexistent-artist-987654"); + + expect(result.totalCount).toBe(0); + expect(result.artworks).toEqual([]); + }); + + it("returns a limited list of unique, non-empty artist names", async () => { + const artists = await fetchArtists(undefined, 5, 0); + + expect(artists).toHaveLength(5); + expect(new Set(artists).size).toBe(artists.length); + + for (const artist of artists) { + expect(artist.trim().length).toBeGreaterThan(0); + } + }); + + it("searches artist options regardless of letter case", async () => { + const lowercase = await fetchArtists("sargent"); + const uppercase = await fetchArtists("SARGENT"); + + expect(lowercase).toContain("John Singer Sargent"); + expect(uppercase).toEqual(lowercase); + }); + + it("paginates artist options without overlap", async () => { + const combined = await fetchArtists(undefined, 10, 0); + const firstPage = await fetchArtists(undefined, 5, 0); + const secondPage = await fetchArtists(undefined, 5, 5); + + expect(firstPage).toHaveLength(5); + expect(secondPage).toHaveLength(5); + + expect([...firstPage, ...secondPage]).toEqual(combined); + expect(new Set(combined).size).toBe(10); + }); +});