From 698205117d02d960e96b351e116f25093998741b Mon Sep 17 00:00:00 2001 From: scottbuscemi Date: Fri, 29 May 2026 16:09:12 -0700 Subject: [PATCH 1/3] fix: add filename search and type filtering to the media library The Media Library page and the content-editor media picker had no way to search or filter local media, making large libraries hard to use. Add a `q` filename substring filter (case-insensitive, matches extensions, LIKE wildcards escaped) to the media list endpoint alongside the existing mimeType filter, and wire both surfaces: the Media page gets a search box plus a type filter (images/video/audio/documents) and the picker searches the local library by filename. Closes #1221. --- .changeset/fix-media-search.md | 6 ++ .../admin/src/components/MediaLibrary.tsx | 77 ++++++++++++++++--- .../admin/src/components/MediaPickerModal.tsx | 13 +++- packages/admin/src/lib/api/media.ts | 3 + packages/admin/src/router.tsx | 11 ++- .../tests/components/MediaLibrary.test.tsx | 27 +++++++ packages/core/src/api/handlers/media.ts | 2 + packages/core/src/api/schemas/media.ts | 2 + packages/core/src/astro/routes/api/media.ts | 1 + .../core/src/database/repositories/media.ts | 17 ++++ packages/core/src/emdash-runtime.ts | 1 + .../database/media-filename-search.test.ts | 65 ++++++++++++++++ 12 files changed, 208 insertions(+), 17 deletions(-) create mode 100644 .changeset/fix-media-search.md create mode 100644 packages/core/tests/integration/database/media-filename-search.test.ts diff --git a/.changeset/fix-media-search.md b/.changeset/fix-media-search.md new file mode 100644 index 000000000..b363e7232 --- /dev/null +++ b/.changeset/fix-media-search.md @@ -0,0 +1,6 @@ +--- +"emdash": patch +"@emdash-cms/admin": patch +--- + +Add search and filtering to the media library (#1221). The media list endpoint now accepts a `q` parameter for a case-insensitive filename substring search (which also matches extensions, with LIKE wildcards escaped), alongside the existing `mimeType` filter. The Media Library page gains a filename search box and a type filter (images / video / audio / documents), and the media picker in the content editor now searches the local library by filename too. Previously neither surface could search or filter local media, which made large libraries hard to navigate. diff --git a/packages/admin/src/components/MediaLibrary.tsx b/packages/admin/src/components/MediaLibrary.tsx index 529b7d339..5a7a5a225 100644 --- a/packages/admin/src/components/MediaLibrary.tsx +++ b/packages/admin/src/components/MediaLibrary.tsx @@ -1,4 +1,4 @@ -import { Button, Input, Loader } from "@cloudflare/kumo"; +import { Button, Input, Loader, Select } from "@cloudflare/kumo"; import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { Upload, Image, SquaresFour, List, MagnifyingGlass, Check, X } from "@phosphor-icons/react"; @@ -13,10 +13,27 @@ import { fetchProviderMedia, uploadToProvider, } from "../lib/api"; +import { useDebouncedValue } from "../lib/hooks.js"; import { providerItemToMediaItem, getFileIcon, formatFileSize } from "../lib/media-utils"; import { cn } from "../lib/utils"; import { MediaDetailPanel } from "./MediaDetailPanel"; +/** Maps a coarse type-filter choice to the media list's `mimeType` filter. */ +function mimeForTypeFilter(value: string): string | string[] | undefined { + switch (value) { + case "image": + return "image/"; + case "video": + return "video/"; + case "audio": + return "audio/"; + case "document": + return ["application/", "text/"]; + default: + return undefined; + } +} + export interface MediaLibraryProps { items?: MediaItem[]; isLoading?: boolean; @@ -28,6 +45,10 @@ export interface MediaLibraryProps { hasMore?: boolean; /** Triggered to fetch the next page of local-library items */ onLoadMore?: () => void; + /** Called (debounced) with the filename search term for the local library. */ + onLocalSearchChange?: (q: string) => void; + /** Called with the MIME filter for the local library (undefined = all types). */ + onLocalMimeFilterChange?: (mimeType: string | string[] | undefined) => void; } /** @@ -41,12 +62,22 @@ export function MediaLibrary({ onItemUpdated, hasMore, onLoadMore, + onLocalSearchChange, + onLocalMimeFilterChange, }: MediaLibraryProps) { const { t } = useLingui(); const [viewMode, setViewMode] = React.useState<"grid" | "list">("grid"); const [selectedItem, setSelectedItem] = React.useState(null); const [activeProvider, setActiveProvider] = React.useState("local"); const [searchQuery, setSearchQuery] = React.useState(""); + const [localTypeFilter, setLocalTypeFilter] = React.useState("all"); + // Debounced filename search reported up for the local library's server query. + const debouncedSearch = useDebouncedValue(searchQuery, 300); + React.useEffect(() => { + if (activeProvider === "local" && onLocalSearchChange) { + onLocalSearchChange(debouncedSearch.trim()); + } + }, [debouncedSearch, activeProvider, onLocalSearchChange]); const [uploadState, setUploadState] = React.useState<{ status: "idle" | "uploading" | "success" | "error"; message?: string; @@ -333,17 +364,39 @@ export function MediaLibrary({ - {/* Search (for providers that support it) */} - {canSearch && ( -
- - setSearchQuery(e.target.value)} - className="ps-9" - /> + {/* Search — providers that support it, plus the local library + (filename/extension search + type filter, handled server-side). */} + {(canSearch || activeProvider === "local") && ( +
+
+ + setSearchQuery(e.target.value)} + className="ps-9" + /> +
+ {activeProvider === "local" && ( + setSearchQuery(e.target.value)} diff --git a/packages/admin/src/lib/api/media.ts b/packages/admin/src/lib/api/media.ts index 94b11f79c..febe7ce1d 100644 --- a/packages/admin/src/lib/api/media.ts +++ b/packages/admin/src/lib/api/media.ts @@ -39,6 +39,8 @@ export async function fetchMediaList(options?: { cursor?: string; limit?: number; mimeType?: string | string[]; + /** Case-insensitive filename substring search (also matches extensions). */ + search?: string; }): Promise> { const params = new URLSearchParams(); if (options?.cursor) params.set("cursor", options.cursor); @@ -47,6 +49,7 @@ export async function fetchMediaList(options?: { const value = Array.isArray(options.mimeType) ? options.mimeType.join(",") : options.mimeType; if (value) params.set("mimeType", value); } + if (options?.search) params.set("q", options.search); const url = `${API_BASE}/media${params.toString() ? `?${params}` : ""}`; const response = await apiFetch(url); diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index 19176321e..59dbf7bd0 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -1003,13 +1003,20 @@ const mediaRoute = createRoute({ function MediaPage() { const queryClient = useQueryClient(); + // Filename search + MIME type filter for the local library (server-side). + const [search, setSearch] = React.useState(""); + const [mimeFilter, setMimeFilter] = React.useState(undefined); + const mimeKey = Array.isArray(mimeFilter) ? mimeFilter.join(",") : (mimeFilter ?? ""); + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, error } = useInfiniteQuery({ - queryKey: ["media"], + queryKey: ["media", { search, mime: mimeKey }], queryFn: ({ pageParam }) => fetchMediaList({ cursor: pageParam, limit: 100, + search: search || undefined, + mimeType: mimeFilter, }), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.nextCursor, @@ -1045,6 +1052,8 @@ function MediaPage() { onLoadMore={() => void fetchNextPage()} onUpload={(file) => uploadMutation.mutate(file)} onDelete={(id) => deleteMutation.mutate(id)} + onLocalSearchChange={setSearch} + onLocalMimeFilterChange={setMimeFilter} /> ); } diff --git a/packages/admin/tests/components/MediaLibrary.test.tsx b/packages/admin/tests/components/MediaLibrary.test.tsx index d23f9493f..cc5ff17b1 100644 --- a/packages/admin/tests/components/MediaLibrary.test.tsx +++ b/packages/admin/tests/components/MediaLibrary.test.tsx @@ -218,4 +218,31 @@ describe("MediaLibrary", () => { await expect.element(screen.getByAltText("first-page.jpg")).toBeInTheDocument(); }); }); + + // #1221: the local library gained filename search + a type filter. + describe("local search and filter", () => { + it("reports the debounced filename query upward", async () => { + const onLocalSearchChange = vi.fn(); + const items = [makeMediaItem({ id: "1", filename: "a.jpg" })]; + const screen = await renderLibrary({ items, onLocalSearchChange }); + + await screen.getByRole("searchbox", { name: "Search media" }).fill("vacation"); + + await vi.waitFor(() => { + expect(onLocalSearchChange).toHaveBeenCalledWith("vacation"); + }); + }); + + it("reports a MIME filter when a type is chosen", async () => { + const onLocalMimeFilterChange = vi.fn(); + const items = [makeMediaItem({ id: "1", filename: "a.jpg" })]; + const screen = await renderLibrary({ items, onLocalMimeFilterChange }); + + // Open the type filter and choose Images. + await screen.getByRole("combobox", { name: "Filter by type" }).click(); + await screen.getByRole("option", { name: "Images" }).click(); + + expect(onLocalMimeFilterChange).toHaveBeenCalledWith("image/"); + }); + }); }); diff --git a/packages/core/src/api/handlers/media.ts b/packages/core/src/api/handlers/media.ts index 89e6c6fa6..2859a2410 100644 --- a/packages/core/src/api/handlers/media.ts +++ b/packages/core/src/api/handlers/media.ts @@ -27,6 +27,7 @@ export async function handleMediaList( cursor?: string; limit?: number; mimeType?: string | readonly string[]; + q?: string; }, ): Promise> { try { @@ -35,6 +36,7 @@ export async function handleMediaList( cursor: params.cursor, limit: Math.min(params.limit || 50, 100), mimeType: params.mimeType, + q: params.q, }); return { diff --git a/packages/core/src/api/schemas/media.ts b/packages/core/src/api/schemas/media.ts index 9b0554536..3b2519e10 100644 --- a/packages/core/src/api/schemas/media.ts +++ b/packages/core/src/api/schemas/media.ts @@ -21,6 +21,8 @@ const mimeTypeFilter = z export const mediaListQuery = cursorPaginationQuery .extend({ mimeType: mimeTypeFilter, + /** Case-insensitive filename substring search (also matches extensions). */ + q: z.string().trim().min(1).max(200).optional(), }) .meta({ id: "MediaListQuery" }); diff --git a/packages/core/src/astro/routes/api/media.ts b/packages/core/src/astro/routes/api/media.ts index 4ed579e4e..9fa764443 100644 --- a/packages/core/src/astro/routes/api/media.ts +++ b/packages/core/src/astro/routes/api/media.ts @@ -56,6 +56,7 @@ export const GET: APIRoute = async ({ request, locals }) => { cursor: query.cursor, limit: query.limit, mimeType: query.mimeType, + q: query.q, }); if (!result.success) { diff --git a/packages/core/src/database/repositories/media.ts b/packages/core/src/database/repositories/media.ts index a94b27274..d589b7032 100644 --- a/packages/core/src/database/repositories/media.ts +++ b/packages/core/src/database/repositories/media.ts @@ -81,8 +81,13 @@ export interface FindManyMediaOptions { /** Filter by MIME type. Pass a string for a single prefix/exact, or an array to match any. Strings ending with "/" are treated as LIKE prefix matches; others are exact equality. */ mimeType?: string | readonly string[]; status?: MediaStatus | "all"; // Filter by status, defaults to "ready" + /** Case-insensitive substring matched against the filename (covers filename and extension). */ + q?: string; } +// LIKE wildcards that must be escaped so user search input is matched literally. +const LIKE_WILDCARD_RE = /[\\%_]/g; + /** * Media repository for database operations */ @@ -250,6 +255,18 @@ export class MediaRepository { query = query.where((eb) => mimeMatchExpr(eb, mimeFilters)); } + // Case-insensitive filename substring search (also matches extensions). + // LIKE wildcards in the term are escaped so they're treated literally. + const term = options.q?.trim(); + if (term) { + const pattern = `%${term.replace(LIKE_WILDCARD_RE, (c) => `\\${c}`)}%`; + query = query.where( + sql`lower(filename)`, + "like", + sql`lower(${pattern}) escape '\\'`, + ); + } + // Default to only showing ready items if (options.status !== "all") { query = query.where("status", "=", options.status ?? "ready"); diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 388e474ba..9f48ce899 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -2554,6 +2554,7 @@ export class EmDashRuntime { cursor?: string; limit?: number; mimeType?: string | readonly string[]; + q?: string; }) { return handleMediaList(this.db, params); } diff --git a/packages/core/tests/integration/database/media-filename-search.test.ts b/packages/core/tests/integration/database/media-filename-search.test.ts new file mode 100644 index 000000000..712301218 --- /dev/null +++ b/packages/core/tests/integration/database/media-filename-search.test.ts @@ -0,0 +1,65 @@ +import { it, expect, beforeEach, afterEach } from "vitest"; + +import { MediaRepository } from "../../../src/database/repositories/media.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +// #1221: the media library lacked filename search. The repository now accepts +// a case-insensitive `q` substring filter against the filename. +describeEachDialect("MediaRepository.findMany filename search (#1221)", (dialect) => { + let ctx: DialectTestContext; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + const repo = new MediaRepository(ctx.db); + await repo.create({ + filename: "Summer-Vacation.png", + mimeType: "image/png", + storageKey: "1.png", + }); + await repo.create({ + filename: "invoice-2024.pdf", + mimeType: "application/pdf", + storageKey: "2.pdf", + }); + await repo.create({ filename: "logo.svg", mimeType: "image/svg+xml", storageKey: "3.svg" }); + await repo.create({ + filename: "100%_complete.png", + mimeType: "image/png", + storageKey: "4.png", + }); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + it("matches a filename substring case-insensitively", async () => { + const repo = new MediaRepository(ctx.db); + const result = await repo.findMany({ q: "vacation" }); + expect(result.items.map((i) => i.filename)).toEqual(["Summer-Vacation.png"]); + }); + + it("matches by extension", async () => { + const repo = new MediaRepository(ctx.db); + const result = await repo.findMany({ q: ".pdf" }); + expect(result.items.map((i) => i.filename)).toEqual(["invoice-2024.pdf"]); + }); + + it("combines with the mimeType filter", async () => { + const repo = new MediaRepository(ctx.db); + const result = await repo.findMany({ q: "logo", mimeType: "image/" }); + expect(result.items.map((i) => i.filename)).toEqual(["logo.svg"]); + }); + + it("treats LIKE wildcards in the query literally", async () => { + const repo = new MediaRepository(ctx.db); + // "100%" must match only the literal "100%_complete.png", not every row. + const result = await repo.findMany({ q: "100%" }); + expect(result.items.map((i) => i.filename)).toEqual(["100%_complete.png"]); + }); +}); From 2b4789f69b1a996a3c874a7269748654b89e6c35 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 2 Jun 2026 08:47:54 +0100 Subject: [PATCH 2/3] fix: address media library review feedback - Scope the list-view 'Type' header assertion with exact text matching so it no longer collides with the 'All types' filter label under the browser-mode locator's substring matching. - Hoist the media query key into one const so useInfiniteQuery and the optimistic setQueryData dimension update share an exact key. - Reuse the existing escapeLike() helper for filename search instead of a redundant inline LIKE-wildcard regex. --- packages/admin/src/components/MediaPickerModal.tsx | 7 +++++-- packages/admin/tests/components/MediaLibrary.test.tsx | 2 +- packages/core/src/database/repositories/media.ts | 5 +---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/admin/src/components/MediaPickerModal.tsx b/packages/admin/src/components/MediaPickerModal.tsx index d53767685..be1941902 100644 --- a/packages/admin/src/components/MediaPickerModal.tsx +++ b/packages/admin/src/components/MediaPickerModal.tsx @@ -204,6 +204,9 @@ export function MediaPickerModal({ // Fetch local media list (cursor-paginated so libraries beyond the // first page remain selectable from the picker, not just the first 50). + // setQueryData is exact-match, so the optimistic dimension update below + // must share this exact key with the query that populates it. + const mediaQueryKey = ["media", filters?.join(",") ?? "", debouncedSearch.trim()]; const { data: localData, isLoading: localLoading, @@ -211,7 +214,7 @@ export function MediaPickerModal({ hasNextPage: hasNextLocalPage, isFetchingNextPage: isFetchingNextLocalPage, } = useInfiniteQuery({ - queryKey: ["media", filters?.join(",") ?? "", debouncedSearch.trim()], + queryKey: mediaQueryKey, queryFn: ({ pageParam }) => fetchMediaList({ mimeType: filters, @@ -282,7 +285,7 @@ export function MediaPickerModal({ updateMedia(id, { width, height }), onSuccess: (_updated, { id, width, height }) => { queryClient.setQueryData( - ["media", filters?.join(",") ?? ""], + mediaQueryKey, ( old: | { diff --git a/packages/admin/tests/components/MediaLibrary.test.tsx b/packages/admin/tests/components/MediaLibrary.test.tsx index cc5ff17b1..7ad36ad67 100644 --- a/packages/admin/tests/components/MediaLibrary.test.tsx +++ b/packages/admin/tests/components/MediaLibrary.test.tsx @@ -105,7 +105,7 @@ describe("MediaLibrary", () => { await expect.element(screen.getByText("test.jpg")).toBeInTheDocument(); // Table headers should be visible await expect.element(screen.getByText("Filename")).toBeInTheDocument(); - await expect.element(screen.getByText("Type")).toBeInTheDocument(); + await expect.element(screen.getByText("Type", { exact: true })).toBeInTheDocument(); await expect.element(screen.getByText("Size")).toBeInTheDocument(); }); }); diff --git a/packages/core/src/database/repositories/media.ts b/packages/core/src/database/repositories/media.ts index d589b7032..59c0ddf4a 100644 --- a/packages/core/src/database/repositories/media.ts +++ b/packages/core/src/database/repositories/media.ts @@ -85,9 +85,6 @@ export interface FindManyMediaOptions { q?: string; } -// LIKE wildcards that must be escaped so user search input is matched literally. -const LIKE_WILDCARD_RE = /[\\%_]/g; - /** * Media repository for database operations */ @@ -259,7 +256,7 @@ export class MediaRepository { // LIKE wildcards in the term are escaped so they're treated literally. const term = options.q?.trim(); if (term) { - const pattern = `%${term.replace(LIKE_WILDCARD_RE, (c) => `\\${c}`)}%`; + const pattern = `%${escapeLike(term)}%`; query = query.where( sql`lower(filename)`, "like", From e8958ca3a38395faf26e7ea7f4253a569e77bf59 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 2 Jun 2026 11:24:19 +0100 Subject: [PATCH 3/3] fix: clamp media search term to server limit Trim and clamp the filename search term to the server-accepted range (max 200 chars) in fetchMediaList and cap the search inputs with maxLength, so a long or whitespace-only query no longer triggers an avoidable 400 (addresses review feedback). --- .../admin/src/components/MediaLibrary.tsx | 2 ++ .../admin/src/components/MediaPickerModal.tsx | 2 ++ packages/admin/src/lib/api/index.ts | 1 + packages/admin/src/lib/api/media.ts | 18 ++++++++++++- packages/admin/tests/lib/media-search.test.ts | 25 +++++++++++++++++++ 5 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 packages/admin/tests/lib/media-search.test.ts diff --git a/packages/admin/src/components/MediaLibrary.tsx b/packages/admin/src/components/MediaLibrary.tsx index 5a7a5a225..bda2223c1 100644 --- a/packages/admin/src/components/MediaLibrary.tsx +++ b/packages/admin/src/components/MediaLibrary.tsx @@ -9,6 +9,7 @@ import { type MediaItem, type MediaProviderInfo, type MediaProviderItem, + MEDIA_SEARCH_MAX_LENGTH, fetchMediaProviders, fetchProviderMedia, uploadToProvider, @@ -376,6 +377,7 @@ export function MediaLibrary({ aria-label={t`Search media`} value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} + maxLength={MEDIA_SEARCH_MAX_LENGTH} className="ps-9" />
diff --git a/packages/admin/src/components/MediaPickerModal.tsx b/packages/admin/src/components/MediaPickerModal.tsx index be1941902..ce4871202 100644 --- a/packages/admin/src/components/MediaPickerModal.tsx +++ b/packages/admin/src/components/MediaPickerModal.tsx @@ -15,6 +15,7 @@ import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from "@tansta import * as React from "react"; import { + MEDIA_SEARCH_MAX_LENGTH, fetchMediaList, fetchMediaProviders, fetchProviderMedia, @@ -573,6 +574,7 @@ export function MediaPickerModal({ aria-label={t`Search media`} value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} + maxLength={MEDIA_SEARCH_MAX_LENGTH} className="ps-9" />
diff --git a/packages/admin/src/lib/api/index.ts b/packages/admin/src/lib/api/index.ts index bd0a57c32..b1daf9fd8 100644 --- a/packages/admin/src/lib/api/index.ts +++ b/packages/admin/src/lib/api/index.ts @@ -58,6 +58,7 @@ export { type MediaProviderCapabilities, type MediaProviderInfo, type MediaProviderItem, + MEDIA_SEARCH_MAX_LENGTH, fetchMediaList, uploadMedia, deleteMedia, diff --git a/packages/admin/src/lib/api/media.ts b/packages/admin/src/lib/api/media.ts index febe7ce1d..cc4af43a1 100644 --- a/packages/admin/src/lib/api/media.ts +++ b/packages/admin/src/lib/api/media.ts @@ -13,6 +13,17 @@ import { type FindManyResult, } from "./client.js"; +/** + * Maximum length of the media filename search term. Mirrors the server-side + * zod schema (`q: z.string().trim().min(1).max(200)`); keep in sync. + */ +export const MEDIA_SEARCH_MAX_LENGTH = 200; + +/** Trim and clamp a search term to the server-accepted range. */ +export function normalizeMediaSearch(value: string | undefined | null): string { + return (value ?? "").trim().slice(0, MEDIA_SEARCH_MAX_LENGTH); +} + export interface MediaItem { id: string; filename: string; @@ -49,7 +60,12 @@ export async function fetchMediaList(options?: { const value = Array.isArray(options.mimeType) ? options.mimeType.join(",") : options.mimeType; if (value) params.set("mimeType", value); } - if (options?.search) params.set("q", options.search); + if (options?.search) { + // Trim and clamp to the server's accepted range so a long or + // whitespace-only term can't trigger an avoidable 400. + const q = normalizeMediaSearch(options.search); + if (q) params.set("q", q); + } const url = `${API_BASE}/media${params.toString() ? `?${params}` : ""}`; const response = await apiFetch(url); diff --git a/packages/admin/tests/lib/media-search.test.ts b/packages/admin/tests/lib/media-search.test.ts new file mode 100644 index 000000000..552e3fe6d --- /dev/null +++ b/packages/admin/tests/lib/media-search.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; + +import { MEDIA_SEARCH_MAX_LENGTH, normalizeMediaSearch } from "../../src/lib/api/media"; + +describe("normalizeMediaSearch", () => { + it("trims surrounding whitespace", () => { + expect(normalizeMediaSearch(" hero.png ")).toBe("hero.png"); + }); + + it("returns an empty string for nullish or whitespace-only input", () => { + expect(normalizeMediaSearch(undefined)).toBe(""); + expect(normalizeMediaSearch(null)).toBe(""); + expect(normalizeMediaSearch(" ")).toBe(""); + }); + + it("clamps to the server-accepted maximum length to avoid 400s", () => { + const long = "a".repeat(MEDIA_SEARCH_MAX_LENGTH + 50); + const result = normalizeMediaSearch(long); + expect(result).toHaveLength(MEDIA_SEARCH_MAX_LENGTH); + }); + + it("leaves a normal term untouched", () => { + expect(normalizeMediaSearch("logo")).toBe("logo"); + }); +});