diff --git a/backend/src/index.ts b/backend/src/index.ts index 7fdbbc9..c15979a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -65,9 +65,16 @@ const typeDefs = `#graphql username: String! } + enum ArtworkSort { + TITLE_ASC + TITLE_DESC + ARTIST_ASC + ARTIST_DESC + } + type Query { hello: String! - artworks(limit: Int!, offset: Int!, search: String, artist: String): ArtworkPage! + artworks(limit: Int!, offset: Int!, search: String, artist: String, sort: ArtworkSort): ArtworkPage! artists(limit: Int!, offset: Int!, search: String): [String!]! } @@ -151,11 +158,13 @@ const resolvers = { offset, search, artist, + sort, }: { limit: number; offset: number; search?: string | null; artist?: string | null; + sort?: "TITLE_ASC" | "TITLE_DESC" | "ARTIST_ASC" | "ARTIST_DESC" | null; }, ) => { if (limit < 1 || limit > 100 || offset < 0) { @@ -182,14 +191,60 @@ const resolvers = { ...(artist ? { artist_display: artist } : {}), }; + let sortOrder: Record; + + switch (sort) { + case "TITLE_ASC": + sortOrder = { titleSort: 1, id: 1 }; + break; + case "TITLE_DESC": + sortOrder = { titleSort: -1, id: 1 }; + break; + case "ARTIST_ASC": + sortOrder = { artist_display: 1, id: 1 }; + break; + case "ARTIST_DESC": + sortOrder = { artist_display: -1, id: 1 }; + break; + default: + sortOrder = { id: 1 }; + } + const [artworks, totalCount] = await Promise.all([ collection - .find(filter) - .sort({ id: 1 }) - .skip(offset) - .limit(limit) + .aggregate( + [ + { $match: filter }, + { + $set: { + titleSort: { + $reduce: { + input: ['"', "“", "”"], + initialValue: "$title", + in: { + $replaceAll: { + input: "$$value", + find: "$$this", + replacement: "", + }, + }, + }, + }, + }, + }, + { $sort: sortOrder }, + { $skip: offset }, + { $limit: limit }, + { $unset: "titleSort" }, + ], + { + collation: { locale: "en", strength: 1 }, + }, + ) .toArray(), - collection.countDocuments(filter), + collection.countDocuments(filter, { + collation: { locale: "en", strength: 1 }, + }), ]); return { diff --git a/backend/tests/artworks-sorting.test.ts b/backend/tests/artworks-sorting.test.ts new file mode 100644 index 0000000..10f3e00 --- /dev/null +++ b/backend/tests/artworks-sorting.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vitest"; + +type ArtworkSort = "TITLE_ASC" | "TITLE_DESC" | "ARTIST_ASC" | "ARTIST_DESC"; + +type Artwork = { + id: number; + title: string; + artist_display: string | null; +}; + +type ArtworkPage = { + totalCount: number; + artworks: Artwork[]; +}; + +async function fetchArtworks( + sort?: ArtworkSort, + limit = 6, + offset = 0, + search?: string, + artist?: string, +): Promise { + const response = await fetch("http://127.0.0.1:3001/", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query: ` + query SortedArtworks( + $sort: ArtworkSort + $limit: Int! + $offset: Int! + $search: String + $artist: String + ) { + artworks( + sort: $sort + limit: $limit + offset: $offset + search: $search + artist: $artist + ) { + totalCount + artworks { + id + title + artist_display + } + } + } + `, + variables: { sort, limit, offset, search, artist }, + }), + }); + + expect(response.ok).toBe(true); + + const result = await response.json(); + + expect(result.errors).toBeUndefined(); + expect(result.data?.artworks).toBeDefined(); + + return result.data.artworks; +} + +describe("Artwork sorting API", () => { + it("sorts by ascending ID when no sorting option is provided", async () => { + const result = await fetchArtworks(); + + expect(result.artworks.length).toBeGreaterThan(1); + + const ids = result.artworks.map((artwork) => artwork.id); + const sortedIds = [...ids].sort((a, b) => a - b); + + expect(ids).toEqual(sortedIds); + }); + + it.each(["TITLE_ASC", "TITLE_DESC"])( + "sorts artwork titles correctly with %s", + async (sort) => { + const result = await fetchArtworks( + sort, + 10, + 0, + undefined, + "John Singer Sargent", + ); + const direction = sort === "TITLE_ASC" ? 1 : -1; + + expect(result.artworks.length).toBeGreaterThan(1); + + const collator = new Intl.Collator("en", { + sensitivity: "base", + }); + + const expected = [...result.artworks].sort((a, b) => { + const comparison = collator.compare(a.title, b.title); + + return comparison === 0 ? a.id - b.id : comparison * direction; + }); + + expect(result.artworks).toEqual(expected); + }, + ); + + it.each(["ARTIST_ASC", "ARTIST_DESC"])( + "sorts artists correctly with %s", + async (sort) => { + const result = await fetchArtworks(sort); + const direction = sort === "ARTIST_ASC" ? 1 : -1; + + expect(result.artworks.length).toBeGreaterThan(1); + + const collator = new Intl.Collator("en", { + sensitivity: "base", + }); + + const expected = [...result.artworks].sort((a, b) => { + const comparison = collator.compare( + a.artist_display ?? "", + b.artist_display ?? "", + ); + + return comparison === 0 ? a.id - b.id : comparison * direction; + }); + + expect(result.artworks).toEqual(expected); + }, + ); + + it.each([ + "TITLE_ASC", + "TITLE_DESC", + "ARTIST_ASC", + "ARTIST_DESC", + ])("keeps sorting consistent across pages with %s", async (sort) => { + const combined = await fetchArtworks(sort, 6, 0); + const firstPage = await fetchArtworks(sort, 3, 0); + const secondPage = await fetchArtworks(sort, 3, 3); + + expect(combined.artworks).toHaveLength(6); + expect(firstPage.artworks).toHaveLength(3); + expect(secondPage.artworks).toHaveLength(3); + + expect(firstPage.totalCount).toBe(combined.totalCount); + expect(secondPage.totalCount).toBe(combined.totalCount); + + expect([...firstPage.artworks, ...secondPage.artworks]).toEqual( + combined.artworks, + ); + + const ids = combined.artworks.map((artwork) => artwork.id); + + expect(new Set(ids).size).toBe(6); + }); + + it.each([ + "TITLE_ASC", + "TITLE_DESC", + "ARTIST_ASC", + "ARTIST_DESC", + ])("combines search and artist filtering with %s", async (sort) => { + const result = await fetchArtworks( + sort, + 10, + 0, + "woman", + "John Singer Sargent", + ); + + expect(result.totalCount).toBe(1); + expect(result.artworks).toEqual([ + { + id: 12072, + title: "Egyptian Woman", + artist_display: "John Singer Sargent", + }, + ]); + }); + it("ignores curly double quotes when sorting titles", async () => { + const result = await fetchArtworks("TITLE_ASC", 100, 0, "painting"); + + const ids = result.artworks.map((artwork) => artwork.id); + + const artistsIndex = ids.indexOf(340799); + const bambooIndex = ids.indexOf(744431); + const benjaminIndex = ids.indexOf(436109); + + expect(artistsIndex).toBeGreaterThanOrEqual(0); + expect(bambooIndex).toBeGreaterThanOrEqual(0); + expect(benjaminIndex).toBeGreaterThanOrEqual(0); + + expect(artistsIndex).toBeLessThan(bambooIndex); + expect(bambooIndex).toBeLessThan(benjaminIndex); + + expect(result.artworks[bambooIndex].title).toBe( + "“Bamboo” section, from the Mustard Seed Garden Manual of Painting", + ); + }); +});