From 624bb7f63c870722fea485b206376d0bc3731001 Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Wed, 20 May 2026 14:57:39 -0700 Subject: [PATCH] Wire catalog cascade into GET /library/ via fuzzySearchLibrary (BS#972) Push the catalog-track-search cascade (CTA -> LML /lookup) down from searchLibrary into searchLibraryBothMode so both /library/search and /library/ (the catalog-facing route dj-site + iOS call) traverse it. Catalog responses now carry `matched_via` for cascade-sourced hits. Refactor: extract searchLibraryByCTARaw + searchLibraryByTrackRaw that return TaggedLibraryViewEntry[] (LibraryArtistViewEntry + optional matched_via), keeping the enriched wrappers for request-line callers. The LRU cache for Track 2 moves to the raw layer so both call sites share it. fuzzySearchLibrary's both-mode branch now returns the tagged shape, which serializeLibraryArtistViewEntry preserves through to the AlbumSearchResult wire format. Behavior is byte-identical when CATALOG_TRACK_SEARCH_CTA_ENABLED and CATALOG_TRACK_SEARCH_DISCOGS_ENABLED are both off (the default), so this is the cascade wiring alone -- no behavior change without a flag flip. --- apps/backend/services/library.service.ts | 184 +++++++++++++------- tests/integration/library.spec.js | 120 +++++++++++++ tests/unit/services/library.service.test.ts | 133 ++++++++++++++ 3 files changed, 377 insertions(+), 60 deletions(-) diff --git a/apps/backend/services/library.service.ts b/apps/backend/services/library.service.ts index bd4a86c6..987d196e 100644 --- a/apps/backend/services/library.service.ts +++ b/apps/backend/services/library.service.ts @@ -44,6 +44,15 @@ const RECONCILED_IDENTITY_KEYS = [ type ReconciledIdentityKey = (typeof RECONCILED_IDENTITY_KEYS)[number]; +/** + * A library_artist_view row that may carry an attached `matched_via` hint when + * the cascade's CTA or LML `/lookup` fallback surfaced it (catalog-track-search + * plan §5.1). Wraps `LibraryArtistViewEntry` rather than replacing it so + * downstream functions (enrichWithArtwork, serializeReconciledIdentity) accept + * tagged rows without signature changes. + */ +export type TaggedLibraryViewEntry = LibraryArtistViewEntry & { matched_via?: TrackMatchHint[] }; + /** A row that carries the six external-ID fields (artist row, view row, or any join projection). */ type ReconciledIdentitySource = { discogs_artist_id: number | null; @@ -569,24 +578,48 @@ async function searchLibraryByTrigramBoth( } /** - * Run the Both-mode search: tsvector first, trigram fallback only when - * tsvector returns 0 rows. The fallback is gated on the query having at - * least 2 characters and at least one alphanumeric character — pure - * punctuation and 1-char queries return empty without a second roundtrip. + * Run the Both-mode search cascade: tsvector → trigram → CTA → LML `/lookup`. + * + * Stages 1-2 (tsvector, trigram) read `library` directly via the per-column + * GIN indexes. Stages 3-4 (CTA, LML) are the catalog-track-search cascade, + * gated on the `CATALOG_TRACK_SEARCH_*` feature flags (default off). + * + * Tsvector / trigram return plain `LibraryArtistViewEntry` rows; CTA / LML + * return the same shape with a `matched_via` field tagging the fallback + * source. The catalog read-path serializes the union via + * `serializeLibraryArtistViewEntry`, so `matched_via` rides through to the + * wire unchanged. + * + * Both feature flags default off, so for any deployment that hasn't opted in, + * behavior is byte-identical to the pre-#972 baseline (tsvector → trigram → []). */ async function searchLibraryBothMode( query: string, n: number, on_streaming?: boolean -): Promise { +): Promise { const trimmed = query.trim(); if (trimmed.length === 0 || !hasAlphanumeric(trimmed)) return []; const tsvectorResults = await searchLibraryByTsvector(trimmed, n, on_streaming); if (tsvectorResults.length > 0) return tsvectorResults; - if (trimmed.length < 2) return []; - return searchLibraryByTrigramBoth(trimmed, n, on_streaming); + if (trimmed.length >= 2) { + const trigramResults = await searchLibraryByTrigramBoth(trimmed, n, on_streaming); + if (trigramResults.length > 0) return trigramResults; + } + + const flags = getCatalogTrackSearchConfig(); + if (flags.ctaEnabled) { + const ctaResults = await searchLibraryByCTARaw(trimmed, n, on_streaming); + if (ctaResults.length > 0) return ctaResults; + } + if (flags.discogsEnabled) { + const trackResults = await searchLibraryByTrackRaw(trimmed, n); + if (trackResults.length > 0) return trackResults; + } + + return []; } export const fuzzySearchLibrary = async ( @@ -594,11 +627,12 @@ export const fuzzySearchLibrary = async ( album_title?: string, n = 5, on_streaming?: boolean -): Promise => { +): Promise => { await checkLibraryArtistNameHealth(); // Both-mode default (dj-site sends the same string as artist and title). - // Route through tsvector + plays. + // Route through tsvector → trigram → CTA → LML cascade so catalog clients + // see `matched_via` for fallback-sourced hits (BS#972, plan §4.1 / §5.1). if (artist_name && album_title && artist_name === album_title) { return searchLibraryBothMode(artist_name, n, on_streaming); } @@ -629,19 +663,23 @@ export const fuzzySearchLibrary = async ( /** * Public wire-format for a library_artist_view row: the six flat external-ID * columns are stripped and replaced with a nested `reconciled_identity`. + * `matched_via` rides through when the row came from the catalog-track-search + * cascade (CTA / LML `/lookup` fallback), otherwise absent. */ export type LibraryArtistViewResponse = Omit & { reconciled_identity: ReconciledIdentity | null; + matched_via?: TrackMatchHint[]; }; /** * Serialize a library_artist_view row for the wire (or any iterable of them). * Used at the read-endpoint boundary so the four `/library*` endpoints all * return the same nested-identity shape, regardless of whether they read the - * view or join `artists` directly. + * view or join `artists` directly. Tagged rows (carrying `matched_via`) + * preserve the tag through serialization. */ -export function serializeLibraryArtistViewEntry(row: LibraryArtistViewEntry): LibraryArtistViewResponse { - return serializeReconciledIdentity(row); +export function serializeLibraryArtistViewEntry(row: TaggedLibraryViewEntry): LibraryArtistViewResponse { + return serializeReconciledIdentity(row) as LibraryArtistViewResponse; } /** @@ -881,29 +919,21 @@ export async function searchLibrary( ): Promise { await checkLibraryArtistNameHealth(); - if (query) { - const primary = await searchLibraryBothMode(query, limit, on_streaming); - if (primary.length > 0) { - return primary.map((row) => enrichLibraryResult(viewRowToLibraryResult(row))); - } - - // Both flags default off so behavior is unchanged until rollout (plan §4). - const flags = getCatalogTrackSearchConfig(); - if (flags.ctaEnabled) { - const ctaResults = await searchLibraryByCTA(query, limit, on_streaming); - if (ctaResults.length > 0) return ctaResults; - } - - if (flags.discogsEnabled) { - const trackResults = await searchLibraryByTrack(query, limit); - if (trackResults.length > 0) return trackResults; - } - - return []; - } - - const results = artist || title ? await fuzzySearchLibrary(artist, title, limit, on_streaming) : []; - return results.map((row) => enrichLibraryResult(viewRowToLibraryResult(row))); + // searchLibraryBothMode now owns the full cascade (tsvector → trigram → CTA + // → LML); BS#972 unified the cascade location so the catalog read-path at + // GET /library/ can reach it via fuzzySearchLibrary. Map view rows to + // EnrichedLibraryResult and carry `matched_via` through. + const rows = query + ? await searchLibraryBothMode(query, limit, on_streaming) + : artist || title + ? await fuzzySearchLibrary(artist, title, limit, on_streaming) + : []; + + return rows.map((row) => { + const enriched = enrichLibraryResult(viewRowToLibraryResult(row)); + if (row.matched_via) enriched.matched_via = row.matched_via; + return enriched; + }); } /** @@ -999,7 +1029,7 @@ export async function searchAlbumsByTitle(albumTitle: string, limit = 5): Promis * @returns Array of enriched library results with `matched_via` populated * @throws Whatever `lookupBySong` throws — the wrapper handles the boundary. */ -async function searchLibraryByTrackUncachedOrThrow(query: string): Promise { +async function searchLibraryByTrackUncachedOrThrow(query: string): Promise { const lookupStart = performance.now(); const response: LookupResponse = await lookupBySong(query); try { @@ -1073,18 +1103,19 @@ async function searchLibraryByTrackUncachedOrThrow(query: string): Promise 0) { - enriched.matched_via = item.matched_via; + tagged.matched_via = item.matched_via; } - results.push(enriched); + results.push(tagged); } return results; } @@ -1107,7 +1138,7 @@ async function searchLibraryByTrackUncachedOrThrow(query: string): Promise({ +const trackSearchCache = new LRUCache({ max: 1000, ttl: 1000 * 60 * 10, // 10 minutes }); @@ -1163,7 +1194,7 @@ export function __resetTrackSearchCacheForTests(): void { * @param limit - Maximum results to return * @returns Array of enriched library results with `matched_via` populated */ -export async function searchLibraryByTrack(query: string, limit: number): Promise { +export async function searchLibraryByTrackRaw(query: string, limit: number): Promise { return Sentry.startSpan({ name: 'searchLibraryByTrack', op: 'catalog.track_search' }, async (span) => { const start = performance.now(); // master_lookup_ms is set by searchLibraryByTrackUncachedOrThrow on the @@ -1171,7 +1202,7 @@ export async function searchLibraryByTrack(query: string, limit: number): Promis // pre-LML failures still emit a numeric value — p95 dashboards then // see one row per call without coalesce. let lmlSucceeded = true; - let results: EnrichedLibraryResult[]; + let results: TaggedLibraryViewEntry[]; const key = trackSearchCacheKey(query); const cached = trackSearchCache.get(key); @@ -1204,6 +1235,20 @@ export async function searchLibraryByTrack(query: string, limit: number): Promis }); } +/** + * Enriched-shape wrapper around {@link searchLibraryByTrackRaw}. Returns + * `EnrichedLibraryResult[]` for request-line callers; catalog callers use the + * raw form via `searchLibraryBothMode`. + */ +export async function searchLibraryByTrack(query: string, limit: number): Promise { + const rows = await searchLibraryByTrackRaw(query, limit); + return rows.map((row) => { + const enriched = enrichLibraryResult(viewRowToLibraryResult(row)); + if (row.matched_via) enriched.matched_via = row.matched_via; + return enriched; + }); +} + /** * Search the library for releases by a specific artist. * @@ -1235,28 +1280,26 @@ type CTASearchRow = LibraryArtistViewEntry & { /** * Search the library for compilation tracks whose `track_title` or - * `artist_name` matches `query` via ILIKE. JOINs back to `library` (with the - * usual `artists` / `format` / `genres` / `genre_artist_crossreference` joins - * used by `library_artist_view`) and returns one enriched library row per - * matched release, with `matched_via` listing every matching CTA row. - * - * Confidence is hardcoded to 1.0 per the catalog-track-search plan's - * confidence-by-source table — the curated VA-disambiguation data in - * `compilation_track_artist` is treated as authoritative. + * `artist_name` matches `query` via ILIKE. Returns raw library_artist_view + * rows (one per matched release) with `matched_via` attached. Used by + * `searchLibraryBothMode` as the Track 1 (CTA) cascade layer; the enriched + * wrapper {@link searchLibraryByCTA} maps these to `EnrichedLibraryResult[]` + * for request-line callers. * - * Method is exported for E1-3 to wire into `searchLibraryBothMode`; it is - * not yet on the public search path. + * Returning tagged view rows (rather than enriched results) lets catalog + * read-paths reuse the wire-shape serializer (`serializeLibraryArtistViewEntry`) + * without losing `add_date`, `label`, `artwork_url`, etc. (BS#972). * * @param query - Free text query matched against `track_title` and `artist_name` * @param limit - Maximum results to return (counts library rows, not CTA rows) * @param on_streaming - Optional filter on `library.on_streaming` - * @returns Array of enriched library results with `matched_via` populated + * @returns Array of tagged view rows with `matched_via` populated */ -export async function searchLibraryByCTA( +export async function searchLibraryByCTARaw( query: string, limit: number, on_streaming?: boolean -): Promise { +): Promise { const trimmed = query.trim(); // Mirror searchLibraryBothMode's guard: pure-punctuation queries (`!!!`, // `---`) would otherwise run an unanchored ILIKE scan over every CTA row. @@ -1341,10 +1384,31 @@ export async function searchLibraryByCTA( } } - return Array.from(byLibraryId.values()).map(({ row, hints }) => ({ - ...enrichLibraryResult(viewRowToLibraryResult(row)), - matched_via: hints, - })); + return Array.from(byLibraryId.values()).map(({ row, hints }) => { + // Strip the CTA-only join columns so the returned row conforms to + // `LibraryArtistViewEntry`. The wire-shape serializer would otherwise + // emit `cta_track_title` / `cta_artist_name` next to `matched_via`. + const { cta_track_title: _t, cta_artist_name: _a, ...viewRow } = row; + return { ...viewRow, matched_via: hints } as TaggedLibraryViewEntry; + }); +} + +/** + * Enriched-shape wrapper around {@link searchLibraryByCTARaw}. Returns + * `EnrichedLibraryResult[]` for request-line callers that compose with the + * other search strategies (`searchAlbumsByTitle`, `searchByArtist`). + */ +export async function searchLibraryByCTA( + query: string, + limit: number, + on_streaming?: boolean +): Promise { + const rows = await searchLibraryByCTARaw(query, limit, on_streaming); + return rows.map((row) => { + const enriched = enrichLibraryResult(viewRowToLibraryResult(row)); + if (row.matched_via) enriched.matched_via = row.matched_via; + return enriched; + }); } /** diff --git a/tests/integration/library.spec.js b/tests/integration/library.spec.js index 98af8e62..7016da5e 100644 --- a/tests/integration/library.spec.js +++ b/tests/integration/library.spec.js @@ -862,6 +862,126 @@ describe('Library Catalog Track Search (Discogs cross-ref fallback)', () => { }); }); +/** + * GET /library/ — catalog-route cascade (BS#972). + * + * `searchForAlbum` (mounted at `GET /library/`) is what dj-site's classic + + * modern catalog live-search and the iOS DJ tool both call. Today it + * shortcuts straight to `libraryService.fuzzySearchLibrary`, which tops out + * at tsvector + trigram — the CTA + LML `/lookup` cascade in + * `libraryService.searchLibrary` is unreachable from any catalog HTTP route. + * That makes `matched_via` empty on every wire response, so dj-site's + * MatchedTrackChips and iOS's MatchedTrackBadge stay dark even with both + * flags strict-`true`. + * + * These cases drive the same CTA + Track 2 fixtures as the `/library/search` + * suites above, but through the catalog route. They follow the + * skip-if-flag-off pattern: a 0-result response in CI means the flag isn't + * set; the test warns and short-circuits. + * + * dj-site sends the same string as both `artist_name` and `album_title` + * (both-mode) for its live search; that's the case wired here. Split-field + * queries stay on the legacy `fuzzySearchLibrary` path and don't run the + * cascade — out of scope for this ticket. + */ +describe('GET /library cascade — catalog route serves matched_via (BS#972)', () => { + let auth; + // CTA fixture (mirror of the BS#819 block above). + const CTA_LIBRARY_ID = 7000; + const CTA_ALBUM_TITLE = 'Shape Fixture Album Alpha 1'; + const CTA_ARTIST = 'Shape Fixture Artist Alpha'; + // Track 2 fixture (mirror of the BS#825 block above). + const CONFIELD_LIBRARY_ID = 7100; + const CONFIELD_ALBUM_TITLE = 'Confield'; + const CONFIELD_TRACK_QUERY = 'vi scose poise'; + + beforeAll(() => { + auth = createAuthRequest(request, global.access_token); + }); + + test('both-mode CTA query returns the comp library row with matched_via.source = "cta"', async () => { + const res = await auth + .get('/library') + .query({ artist_name: 'Bioluminescence', album_title: 'Bioluminescence', n: 10 }) + .expect(200); + + expectArray(res); + if (res.body.length === 0) { + console.warn( + '[BS#972] /library/ catalog cascade returned no results. Likely the backend is running ' + + 'without CATALOG_TRACK_SEARCH_CTA_ENABLED=true. Set it in .env and restart `npm run dev`.' + ); + return; + } + + const hit = res.body.find((row) => row.id === CTA_LIBRARY_ID); + expect(hit).toBeDefined(); + expect(hit.album_title).toBe(CTA_ALBUM_TITLE); + expect(hit.artist_name).toBe(CTA_ARTIST); + expect(Array.isArray(hit.matched_via)).toBe(true); + expect(hit.matched_via.length).toBeGreaterThanOrEqual(1); + const bioHints = hit.matched_via.filter((m) => m.title === 'Bioluminescence'); + expect(bioHints.length).toBeGreaterThanOrEqual(1); + bioHints.forEach((hint) => { + expect(hint.source).toBe('cta'); + expect(hint.confidence).toBe(1.0); + }); + }); + + test('both-mode Track 2 query ("vi scose poise") returns Confield via LML fallback', async () => { + const res = await auth + .get('/library') + .query({ artist_name: CONFIELD_TRACK_QUERY, album_title: CONFIELD_TRACK_QUERY, n: 10 }) + .expect(200); + + expectArray(res); + if (res.body.length === 0) { + console.warn( + '[BS#972] /library/ catalog cascade returned no Track 2 results. Likely the backend is running ' + + 'without CATALOG_TRACK_SEARCH_DISCOGS_ENABLED=true. Set it in .env and restart `npm run dev`.' + ); + return; + } + + const hit = res.body.find((row) => row.id === CONFIELD_LIBRARY_ID); + expect(hit).toBeDefined(); + expect(hit.album_title).toBe(CONFIELD_ALBUM_TITLE); + expect(Array.isArray(hit.matched_via)).toBe(true); + expect(hit.matched_via.length).toBeGreaterThanOrEqual(1); + // Mock LML's songLookup map returns matched_via.source = 'discogs_master' + // for the Confield row; bridged via library.legacy_release_id back to BS. + expect(hit.matched_via.some((m) => m.source && m.source.startsWith('discogs'))).toBe(true); + }); + + test('direct tsvector hit never carries matched_via (cascade only fires on primary 0-hit)', async () => { + // 'Built to Spill' is in the seed fixture and matches tsvector cleanly; + // the cascade must NOT fire, so the response must not carry matched_via. + const res = await auth + .get('/library') + .query({ artist_name: 'Built to Spill', album_title: 'Built to Spill', n: 5 }) + .expect(200); + + expectArray(res); + expect(res.body.length).toBeGreaterThan(0); + res.body.forEach((row) => { + expect(row.matched_via).toBeUndefined(); + }); + }); + + test('split-field queries skip the cascade (not in scope for #972)', async () => { + // artist_name !== album_title routes through the legacy fuzzySearchLibrary + // path. With nonsense values for both fields, the response must be empty + // (no cascade backfill, no matched_via). + const res = await auth + .get('/library') + .query({ artist_name: 'xyznoartist', album_title: 'xyznoalbum', n: 5 }) + .expect(200); + + expectArray(res); + expect(res.body.length).toBe(0); + }); +}); + describe('Library artist_name cascade trigger (A.3 / 0060)', () => { const postgres = require('postgres'); let sql; diff --git a/tests/unit/services/library.service.test.ts b/tests/unit/services/library.service.test.ts index 8f702cf1..cd1eb7d4 100644 --- a/tests/unit/services/library.service.test.ts +++ b/tests/unit/services/library.service.test.ts @@ -516,6 +516,139 @@ describe('library.service', () => { }); }); + /** + * Both-mode catalog cascade reachable from fuzzySearchLibrary (BS#972). + * + * The catalog route GET /library/ calls fuzzySearchLibrary directly. With + * both fields equal (dj-site's live-search shape), the both-mode branch + * must run the same tsvector → trigram → CTA → LML cascade that + * searchLibrary already runs at /library/search. Without this, catalog + * clients never see matched_via even with the feature flags strict-`true`. + * + * These mirror the searchLibrary cascade cases — same mock chain shape — + * but drive fuzzySearchLibrary(query, query, ...) directly. + */ + describe('fuzzySearchLibrary Both-mode cascade (BS#972)', () => { + const ctaRow = { + id: 11, + code_letters: 'VA', + code_artist_number: 1, + code_number: 5, + artist_name: 'Various Artists', + alphabetical_name: 'Various Artists', + album_title: 'Edits', + format_name: 'cd', + genre_name: 'Electronic', + rotation_bin: null, + add_date: new Date('2024-01-15'), + label: 'self-released', + label_id: null, + on_streaming: true, + album_artist: null, + plays: 0, + artwork_url: null, + discogs_artist_id: null, + musicbrainz_artist_id: null, + wikidata_qid: null, + spotify_artist_id: null, + apple_music_artist_id: null, + bandcamp_id: null, + cta_track_title: 'Call Your Name', + cta_artist_name: 'Chuquimamani-Condori', + }; + + const originalCta = process.env.CATALOG_TRACK_SEARCH_CTA_ENABLED; + const originalDiscogs = process.env.CATALOG_TRACK_SEARCH_DISCOGS_ENABLED; + + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.CATALOG_TRACK_SEARCH_CTA_ENABLED; + delete process.env.CATALOG_TRACK_SEARCH_DISCOGS_ENABLED; + resetCatalogTrackSearchConfig(); + __resetTrackSearchCacheForTests(); + }); + + afterAll(() => { + if (originalCta === undefined) delete process.env.CATALOG_TRACK_SEARCH_CTA_ENABLED; + else process.env.CATALOG_TRACK_SEARCH_CTA_ENABLED = originalCta; + if (originalDiscogs === undefined) delete process.env.CATALOG_TRACK_SEARCH_DISCOGS_ENABLED; + else process.env.CATALOG_TRACK_SEARCH_DISCOGS_ENABLED = originalDiscogs; + resetCatalogTrackSearchConfig(); + }); + + /** Tsvector returns 0 rows; trigram returns whatever caller provides. */ + function setUpPrimarySearchMocks(trigramRows: object[] = []): void { + const tsvectorChain = createMockQueryChain([]); + tsvectorChain.limit = jest.fn().mockResolvedValue([]); + const trigramChain = createMockQueryChain(trigramRows); + trigramChain.limit = jest.fn().mockResolvedValue(trigramRows); + let callIndex = 0; + db.select.mockReset(); + db.select.mockImplementation(() => { + const chain = callIndex === 0 ? tsvectorChain : trigramChain; + callIndex += 1; + return chain; + }); + } + + it('flag-off: tsvector hit returns plain row, no matched_via', async () => { + const chain = createMockQueryChain([mockViewRow]); + db.select.mockReturnValue(chain); + chain.limit = jest.fn().mockResolvedValue([mockViewRow]); + + const results = await fuzzySearchLibrary('Autechre', 'Autechre', 5); + + expect(results).toHaveLength(1); + // matched_via must not be set on direct hits. + expect((results[0] as { matched_via?: unknown }).matched_via).toBeUndefined(); + expect(mockLookupBySong).not.toHaveBeenCalled(); + }); + + it('flag-off baseline: primary 0 → no LML, returns []', async () => { + setUpPrimarySearchMocks(); + db.execute.mockResolvedValue([]); + + const results = await fuzzySearchLibrary('nilufer yanya', 'nilufer yanya', 5); + + expect(results).toEqual([]); + expect(mockLookupBySong).not.toHaveBeenCalled(); + }); + + it('CTA flag on, primary 0 → CTA fires and matched_via.source=cta surfaces on the wire row', async () => { + process.env.CATALOG_TRACK_SEARCH_CTA_ENABLED = 'true'; + setUpPrimarySearchMocks(); + db.execute.mockResolvedValue([ctaRow]); + + const results = await fuzzySearchLibrary('Call Your Name', 'Call Your Name', 5); + + expect(results).toHaveLength(1); + expect(results[0].id).toBe(11); + // Preserves the LibraryArtistViewEntry shape (label, add_date, etc.) + // so the controller can serialize it as AlbumSearchResult. + expect(results[0]).toHaveProperty('label', 'self-released'); + expect(results[0]).toHaveProperty('add_date'); + const matchedVia = (results[0] as { matched_via?: Array<{ source: string; title: string }> }).matched_via; + expect(Array.isArray(matchedVia)).toBe(true); + expect(matchedVia?.[0]).toMatchObject({ source: 'cta', title: 'Call Your Name' }); + expect(mockLookupBySong).not.toHaveBeenCalled(); + }); + + it('split-field query (artist !== title) does NOT trigger cascade', async () => { + process.env.CATALOG_TRACK_SEARCH_CTA_ENABLED = 'true'; + const chain = createMockQueryChain([]); + db.select.mockReturnValue(chain); + chain.limit = jest.fn().mockResolvedValue([]); + + const results = await fuzzySearchLibrary('xyznoartist', 'xyznoalbum', 5); + + expect(results).toEqual([]); + // Split-field path stays on the legacy fuzzy trigram path — no + // db.execute (CTA SQL) and no LML lookup. + expect(db.execute).not.toHaveBeenCalled(); + expect(mockLookupBySong).not.toHaveBeenCalled(); + }); + }); + describe('searchByArtist', () => { beforeEach(() => { jest.clearAllMocks();