diff --git a/.env.example b/.env.example index 31f8284d..356e97fc 100644 --- a/.env.example +++ b/.env.example @@ -44,3 +44,11 @@ SPOTIFY_CLIENT_SECRET=your_spotify_client_secret METADATA_ALBUM_CACHE_MAX_SIZE=1000 METADATA_ARTIST_CACHE_MAX_SIZE=500 METADATA_ROTATION_PRIORITY=2.0 + +### Elasticsearch (optional — omit ELASTICSEARCH_URL to use PostgreSQL pg_trgm only) +# ELASTICSEARCH_URL=http://localhost:9200 +# ELASTICSEARCH_USERNAME= +# ELASTICSEARCH_PASSWORD= +# ELASTICSEARCH_INDEX_PREFIX= +# ELASTICSEARCH_PORT=9200 +# CI_ELASTICSEARCH_PORT=9201 diff --git a/CLAUDE.md b/CLAUDE.md index 4061ebc5..0a382964 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,20 @@ Server timeout is 5 seconds globally; SSE routes have no timeout. Swagger API docs are served at `/api-docs` from `app.yaml`. +### Search (`apps/backend/services/search/`) + +Library catalog search uses a facade pattern that routes to either Elasticsearch or PostgreSQL pg_trgm: + +- **`elasticsearch.client.ts`** -- Singleton ES client. Returns `null` when `ELASTICSEARCH_URL` is unset (graceful degradation). +- **`elasticsearch.indices.ts`** -- Index mapping and lifecycle (`ensureLibraryIndex()` called at startup). +- **`elasticsearch.search.ts`** -- ES query implementations (`searchLibraryES`, `findSimilarArtistES`, `searchAlbumsByTitleES`, `searchByArtistES`). All return `LibraryArtistViewEntry[]`. +- **`elasticsearch.sync.ts`** -- Stub for dual-write sync and bulk reindex (PR 2). +- **`index.ts`** -- Facade: tries ES first, falls back to pg_trgm on error. Exports `searchLibrary`, `findSimilarArtist`, `searchAlbumsByTitle`, `searchByArtist`. + +The original pg_trgm implementations in `library.service.ts` are renamed with `pgTrgm` prefix (e.g., `pgTrgmSearchLibrary`) and re-exported via the facade under the original names. No callers need to change imports. + +Feature flag: set `ELASTICSEARCH_URL` to enable ES, unset to disable. Instant rollback by unsetting the env var. + ### Auth Server (`apps/auth`) Express wrapper around better-auth with these plugins: admin, username, anonymous, bearer, jwt, organization. @@ -250,6 +264,13 @@ GitHub Actions workflow (`.github/workflows/test.yml`) runs on PRs to `main`: - `DISCOGS_API_KEY`, `DISCOGS_API_SECRET` - `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET` +### Elasticsearch (optional) + +- `ELASTICSEARCH_URL` -- e.g. `http://localhost:9200` (omit to disable ES and use pg_trgm only) +- `ELASTICSEARCH_USERNAME`, `ELASTICSEARCH_PASSWORD` -- optional, for production auth +- `ELASTICSEARCH_INDEX_PREFIX` -- optional, for test isolation (e.g. `ci_`) +- `ELASTICSEARCH_PORT` (default 9200), `CI_ELASTICSEARCH_PORT` (default 9201) + ### Slack - `SLACK_WXYC_REQUESTS_APP_ID`, `SLACK_WXYC_REQUESTS_CLIENT_ID` diff --git a/apps/backend/app.ts b/apps/backend/app.ts index e6abbba2..9c4e2a12 100644 --- a/apps/backend/app.ts +++ b/apps/backend/app.ts @@ -17,6 +17,8 @@ import { activeShow } from './middleware/checkActiveShow.js'; import errorHandler from './middleware/errorHandler.js'; import { requestIdMiddleware } from './middleware/requestId.js'; import { requirePermissions } from '@wxyc/authentication'; +import { isElasticsearchEnabled, getElasticsearchClient } from './services/search/elasticsearch.client.js'; +import { ensureLibraryIndex } from './services/search/elasticsearch.indices.js'; const port = process.env.PORT || 8080; const app = express(); @@ -73,12 +75,29 @@ app.get('/testAuth', requirePermissions({ flowsheet: ['read'] }), async (req, re //endpoint for healthchecks app.get('/healthcheck', async (req, res) => { - res.json({ message: 'Healthy!' }); + let elasticsearch: 'disabled' | 'connected' | 'unavailable' = 'disabled'; + + if (isElasticsearchEnabled()) { + try { + const client = getElasticsearchClient(); + await client!.ping(); + elasticsearch = 'connected'; + } catch { + elasticsearch = 'unavailable'; + } + } + + res.json({ message: 'Healthy!', elasticsearch }); }); Sentry.setupExpressErrorHandler(app); app.use(errorHandler); +// Ensure ES index exists at startup (non-blocking — failure doesn't prevent server start) +ensureLibraryIndex().catch((err) => { + console.error('[Elasticsearch] Failed to ensure library index at startup:', err); +}); + const server = app.listen(port, () => { console.log(`listening on port: ${port}!`); }); diff --git a/apps/backend/package.json b/apps/backend/package.json index a760d684..ebbd4e93 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -16,6 +16,7 @@ "author": "AyBruno", "license": "MIT", "dependencies": { + "@elastic/elasticsearch": "^8.19.1", "@sentry/node": "^10.40.0", "@wxyc/authentication": "*", "@wxyc/database": "*", diff --git a/apps/backend/services/library.service.ts b/apps/backend/services/library.service.ts index 2f9b8738..b7845747 100644 --- a/apps/backend/services/library.service.ts +++ b/apps/backend/services/library.service.ts @@ -323,7 +323,7 @@ function viewRowToLibraryResult(row: LibraryArtistViewEntry): LibraryResult { * @param limit - Maximum results to return * @returns Array of enriched library results */ -export async function searchLibrary( +export async function pgTrgmSearchLibrary( query?: string, artist?: string, title?: string, @@ -387,7 +387,7 @@ export async function searchLibrary( * @param threshold - Minimum similarity score (0.0 to 1.0) to accept * @returns Corrected artist name if a good match is found, null otherwise */ -export async function findSimilarArtist(artistName: string, threshold = 0.85): Promise { +export async function pgTrgmFindSimilarArtist(artistName: string, threshold = 0.85): Promise { // Use pg_trgm similarity function to find close matches const query = sql` SELECT DISTINCT artist_name, @@ -424,7 +424,7 @@ export async function findSimilarArtist(artistName: string, threshold = 0.85): P * @param limit - Maximum results to return * @returns Array of enriched library results */ -export async function searchAlbumsByTitle(albumTitle: string, limit = 5): Promise { +export async function pgTrgmSearchAlbumsByTitle(albumTitle: string, limit = 5): Promise { const query = sql` SELECT *, similarity(${library_artist_view.album_title}, ${albumTitle}) as sim @@ -467,7 +467,7 @@ export async function searchAlbumsByTitle(albumTitle: string, limit = 5): Promis * @param limit - Maximum results to return * @returns Array of enriched library results */ -export async function searchByArtist(artistName: string, limit = 5): Promise { +export async function pgTrgmSearchByArtist(artistName: string, limit = 5): Promise { const query = sql` SELECT *, similarity(${library_artist_view.artist_name}, ${artistName}) as sim @@ -516,3 +516,7 @@ export function filterResultsByArtist( return filtered; } + +// Re-export the search facade under the original function names. +// All callers continue to import from this file unchanged. +export { searchLibrary, findSimilarArtist, searchAlbumsByTitle, searchByArtist } from './search/index.js'; diff --git a/apps/backend/services/search/elasticsearch.client.ts b/apps/backend/services/search/elasticsearch.client.ts new file mode 100644 index 00000000..241553a6 --- /dev/null +++ b/apps/backend/services/search/elasticsearch.client.ts @@ -0,0 +1,40 @@ +import { Client } from '@elastic/elasticsearch'; + +let client: Client | null = null; + +/** + * Returns true when Elasticsearch is configured via the ELASTICSEARCH_URL env var. + */ +export function isElasticsearchEnabled(): boolean { + return !!process.env.ELASTICSEARCH_URL; +} + +/** + * Returns the singleton Elasticsearch client, or null when ES is disabled. + * + * Does NOT throw on missing config — callers use null to degrade gracefully. + */ +export function getElasticsearchClient(): Client | null { + if (!isElasticsearchEnabled()) { + return null; + } + + if (!client) { + const config: ConstructorParameters[0] = { + node: process.env.ELASTICSEARCH_URL, + requestTimeout: 5000, + maxRetries: 2, + }; + + if (process.env.ELASTICSEARCH_USERNAME && process.env.ELASTICSEARCH_PASSWORD) { + config.auth = { + username: process.env.ELASTICSEARCH_USERNAME, + password: process.env.ELASTICSEARCH_PASSWORD, + }; + } + + client = new Client(config); + } + + return client; +} diff --git a/apps/backend/services/search/elasticsearch.indices.ts b/apps/backend/services/search/elasticsearch.indices.ts new file mode 100644 index 00000000..ec98b86f --- /dev/null +++ b/apps/backend/services/search/elasticsearch.indices.ts @@ -0,0 +1,52 @@ +import { getElasticsearchClient } from './elasticsearch.client.js'; + +const INDEX_BASE_NAME = 'wxyc_library'; + +export const LIBRARY_INDEX_MAPPING = { + settings: { number_of_shards: 1, number_of_replicas: 0 }, + mappings: { + properties: { + id: { type: 'integer' }, + artist_name: { type: 'text', fields: { keyword: { type: 'keyword' } } }, + alphabetical_name: { type: 'text', fields: { keyword: { type: 'keyword' } } }, + album_title: { type: 'text', fields: { keyword: { type: 'keyword' } } }, + label: { type: 'text', fields: { keyword: { type: 'keyword' } } }, + genre_name: { type: 'keyword' }, + format_name: { type: 'keyword' }, + rotation_bin: { type: 'keyword' }, + code_letters: { type: 'keyword' }, + code_artist_number: { type: 'integer' }, + code_number: { type: 'integer' }, + add_date: { type: 'date' }, + }, + }, +} as const; + +/** + * Returns the index name, honoring the optional ELASTICSEARCH_INDEX_PREFIX + * env var for test isolation (e.g., `ci_wxyc_library`). + */ +export function getLibraryIndexName(): string { + const prefix = process.env.ELASTICSEARCH_INDEX_PREFIX ?? ''; + return `${prefix}${INDEX_BASE_NAME}`; +} + +/** + * Idempotently creates the library index if it does not already exist. + * Safe to call at startup — does nothing when ES is disabled or the index exists. + */ +export async function ensureLibraryIndex(): Promise { + const client = getElasticsearchClient(); + if (!client) return; + + const indexName = getLibraryIndexName(); + const exists = await client.indices.exists({ index: indexName }); + + if (!exists) { + await client.indices.create({ + index: indexName, + body: LIBRARY_INDEX_MAPPING, + }); + console.log(`[Elasticsearch] Created index '${indexName}'`); + } +} diff --git a/apps/backend/services/search/elasticsearch.search.ts b/apps/backend/services/search/elasticsearch.search.ts new file mode 100644 index 00000000..afc6e20a --- /dev/null +++ b/apps/backend/services/search/elasticsearch.search.ts @@ -0,0 +1,161 @@ +import type { LibraryArtistViewEntry } from '@wxyc/database'; +import { getElasticsearchClient } from './elasticsearch.client.js'; +import { getLibraryIndexName } from './elasticsearch.indices.js'; + +/** + * Map an ES hit _source to the LibraryArtistViewEntry shape used by the rest + * of the codebase, so the facade can swap backends transparently. + */ +function hitToViewEntry(source: Record): LibraryArtistViewEntry { + return { + id: source.id as number, + artist_name: source.artist_name as string, + alphabetical_name: source.alphabetical_name as string, + album_title: source.album_title as string, + label: (source.label as string) ?? null, + genre_name: source.genre_name as string, + format_name: source.format_name as string, + rotation_bin: (source.rotation_bin as string) ?? null, + code_letters: source.code_letters as string, + code_artist_number: source.code_artist_number as number, + code_number: source.code_number as number, + add_date: source.add_date as unknown as Date, + }; +} + +/** + * Full library search via Elasticsearch. + * + * Supports three modes matching the pg_trgm implementation: + * 1. Free-text query across artist_name and album_title (multi_match) + * 2. Separate artist + title filters (bool/should) + * 3. Returns empty when no parameters are provided + */ +export async function searchLibraryES( + query?: string, + artist?: string, + title?: string, + limit = 5 +): Promise { + if (!query && !artist && !title) return []; + + const client = getElasticsearchClient()!; + const index = getLibraryIndexName(); + + let body: Record; + + if (query) { + body = { + query: { + multi_match: { + query, + fields: ['artist_name^2', 'album_title'], + fuzziness: 'AUTO', + }, + }, + }; + } else { + const should: Record[] = []; + if (artist) { + should.push({ match: { artist_name: { query: artist, fuzziness: 'AUTO', boost: 2 } } }); + } + if (title) { + should.push({ match: { album_title: { query: title, fuzziness: 'AUTO' } } }); + } + body = { + query: { + bool: { + should, + minimum_should_match: 1, + }, + }, + }; + } + + const response = await client.search({ index, size: limit, body }); + return (response.hits.hits as Array<{ _source: Record }>).map((hit) => hitToViewEntry(hit._source)); +} + +/** + * Find a similar artist name using ES fuzzy matching. + * Returns the corrected name if a close but different match is found, null otherwise. + */ +export async function findSimilarArtistES(artistName: string, _threshold?: number): Promise { + const client = getElasticsearchClient()!; + const index = getLibraryIndexName(); + + const response = await client.search({ + index, + size: 1, + body: { + query: { + match: { + artist_name: { + query: artistName, + fuzziness: 'AUTO', + }, + }, + }, + _source: ['artist_name'], + }, + }); + + const hits = response.hits.hits as Array<{ _source: { artist_name: string } }>; + if (hits.length === 0) return null; + + const match = hits[0]._source.artist_name; + if (match.toLowerCase() === artistName.toLowerCase()) return null; + + console.log(`[Elasticsearch] Corrected artist '${artistName}' to '${match}'`); + return match; +} + +/** + * Search for albums by title with fuzzy matching. + */ +export async function searchAlbumsByTitleES(albumTitle: string, limit = 5): Promise { + const client = getElasticsearchClient()!; + const index = getLibraryIndexName(); + + const response = await client.search({ + index, + size: limit, + body: { + query: { + match: { + album_title: { + query: albumTitle, + fuzziness: 'AUTO', + }, + }, + }, + }, + }); + + return (response.hits.hits as Array<{ _source: Record }>).map((hit) => hitToViewEntry(hit._source)); +} + +/** + * Search the library by artist name with fuzzy matching. + */ +export async function searchByArtistES(artistName: string, limit = 5): Promise { + const client = getElasticsearchClient()!; + const index = getLibraryIndexName(); + + const response = await client.search({ + index, + size: limit, + body: { + query: { + match: { + artist_name: { + query: artistName, + fuzziness: 'AUTO', + }, + }, + }, + }, + }); + + return (response.hits.hits as Array<{ _source: Record }>).map((hit) => hitToViewEntry(hit._source)); +} diff --git a/apps/backend/services/search/elasticsearch.sync.ts b/apps/backend/services/search/elasticsearch.sync.ts new file mode 100644 index 00000000..4d1d4325 --- /dev/null +++ b/apps/backend/services/search/elasticsearch.sync.ts @@ -0,0 +1,25 @@ +import type { LibraryArtistViewEntry } from '@wxyc/database'; + +/** + * Index a single library document into Elasticsearch. + * Stub — implemented in PR 2. + */ +export async function indexLibraryDocument(_doc: LibraryArtistViewEntry): Promise { + // TODO: PR 2 — dual-write sync +} + +/** + * Remove a library document from the Elasticsearch index by ID. + * Stub — implemented in PR 2. + */ +export async function removeLibraryDocument(_id: number): Promise { + // TODO: PR 2 — dual-write sync +} + +/** + * Full reindex: read all rows from library_artist_view and bulk-index into ES. + * Stub — implemented in PR 2. + */ +export async function bulkIndexLibrary(): Promise { + // TODO: PR 2 — bulk reindex job +} diff --git a/apps/backend/services/search/index.ts b/apps/backend/services/search/index.ts new file mode 100644 index 00000000..9ac51a4c --- /dev/null +++ b/apps/backend/services/search/index.ts @@ -0,0 +1,121 @@ +/** + * Search facade — routes queries to Elasticsearch or PostgreSQL pg_trgm. + * + * When ELASTICSEARCH_URL is set the facade tries ES first and falls back to + * pg_trgm on any error. When the env var is unset, pg_trgm is used directly. + */ +import type { EnrichedLibraryResult } from '../requestLine/types.js'; +import { enrichLibraryResult } from '../requestLine/types.js'; +import { isElasticsearchEnabled } from './elasticsearch.client.js'; +import { + searchLibraryES, + findSimilarArtistES, + searchAlbumsByTitleES, + searchByArtistES, +} from './elasticsearch.search.js'; +import { + pgTrgmSearchLibrary, + pgTrgmFindSimilarArtist, + pgTrgmSearchAlbumsByTitle, + pgTrgmSearchByArtist, +} from '../library.service.js'; + +/** + * Search the library catalog. Tries ES first when enabled, falls back to pg_trgm. + */ +export async function searchLibrary( + query?: string, + artist?: string, + title?: string, + limit = 5 +): Promise { + if (isElasticsearchEnabled()) { + try { + const results = await searchLibraryES(query, artist, title, limit); + return results.map((row) => + enrichLibraryResult({ + id: row.id, + title: row.album_title, + artist: row.artist_name, + alphabeticalName: row.alphabetical_name, + codeLetters: row.code_letters, + codeArtistNumber: row.code_artist_number, + codeNumber: row.code_number, + genre: row.genre_name, + format: row.format_name, + }) + ); + } catch (error) { + console.error('[Search] ES query failed, falling back to pg_trgm:', error); + } + } + return pgTrgmSearchLibrary(query, artist, title, limit); +} + +/** + * Find a similar artist name. Tries ES first when enabled, falls back to pg_trgm. + */ +export async function findSimilarArtist(artistName: string, threshold = 0.85): Promise { + if (isElasticsearchEnabled()) { + try { + return await findSimilarArtistES(artistName, threshold); + } catch (error) { + console.error('[Search] ES findSimilarArtist failed, falling back to pg_trgm:', error); + } + } + return pgTrgmFindSimilarArtist(artistName, threshold); +} + +/** + * Search for albums by title. Tries ES first when enabled, falls back to pg_trgm. + */ +export async function searchAlbumsByTitle(albumTitle: string, limit = 5): Promise { + if (isElasticsearchEnabled()) { + try { + const results = await searchAlbumsByTitleES(albumTitle, limit); + return results.map((row) => + enrichLibraryResult({ + id: row.id, + title: row.album_title, + artist: row.artist_name, + alphabeticalName: row.alphabetical_name, + codeLetters: row.code_letters, + codeArtistNumber: row.code_artist_number, + codeNumber: row.code_number, + genre: row.genre_name, + format: row.format_name, + }) + ); + } catch (error) { + console.error('[Search] ES searchAlbumsByTitle failed, falling back to pg_trgm:', error); + } + } + return pgTrgmSearchAlbumsByTitle(albumTitle, limit); +} + +/** + * Search the library by artist name. Tries ES first when enabled, falls back to pg_trgm. + */ +export async function searchByArtist(artistName: string, limit = 5): Promise { + if (isElasticsearchEnabled()) { + try { + const results = await searchByArtistES(artistName, limit); + return results.map((row) => + enrichLibraryResult({ + id: row.id, + title: row.album_title, + artist: row.artist_name, + alphabeticalName: row.alphabetical_name, + codeLetters: row.code_letters, + codeArtistNumber: row.code_artist_number, + codeNumber: row.code_number, + genre: row.genre_name, + format: row.format_name, + }) + ); + } catch (error) { + console.error('[Search] ES searchByArtist failed, falling back to pg_trgm:', error); + } + } + return pgTrgmSearchByArtist(artistName, limit); +} diff --git a/dev_env/docker-compose.yml b/dev_env/docker-compose.yml index e8136b18..738bfa1d 100644 --- a/dev_env/docker-compose.yml +++ b/dev_env/docker-compose.yml @@ -37,6 +37,42 @@ services: - ./install_extensions.sql:/init/install_extensions.sql - ./seed_db.sql:/init/seed_db.sql + elasticsearch: + image: elasticsearch:8.17.0 + profiles: [dev] + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - 'ES_JAVA_OPTS=-Xms256m -Xmx256m' + ports: + - '${ELASTICSEARCH_PORT:-9200}:9200' + volumes: + - es-data:/usr/share/elasticsearch/data + healthcheck: + test: ['CMD-SHELL', 'curl -sf http://localhost:9200/_cluster/health || exit 1'] + interval: 5s + timeout: 10s + retries: 10 + start_period: 30s + + ci-elasticsearch: + image: elasticsearch:8.17.0 + profiles: [ci] + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - 'ES_JAVA_OPTS=-Xms256m -Xmx256m' + ports: + - '${CI_ELASTICSEARCH_PORT:-9201}:9200' + volumes: + - ci-es-data:/usr/share/elasticsearch/data + healthcheck: + test: ['CMD-SHELL', 'curl -sf http://localhost:9200/_cluster/health || exit 1'] + interval: 5s + timeout: 10s + retries: 10 + start_period: 30s + ci-db: image: postgres:18.0-alpine profiles: [ci] @@ -283,3 +319,5 @@ volumes: pg-data: ci-pg-data: e2e-pg-data: + es-data: + ci-es-data: diff --git a/package-lock.json b/package-lock.json index b9e2e513..031ff430 100644 --- a/package-lock.json +++ b/package-lock.json @@ -75,6 +75,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@elastic/elasticsearch": "^8.19.1", "@sentry/node": "^10.40.0", "@wxyc/authentication": "*", "@wxyc/database": "*", @@ -1378,6 +1379,39 @@ "@noble/ciphers": "^1.0.0" } }, + "node_modules/@elastic/elasticsearch": { + "version": "8.19.1", + "resolved": "https://registry.npmjs.org/@elastic/elasticsearch/-/elasticsearch-8.19.1.tgz", + "integrity": "sha512-+1j9NnQVOX+lbWB8LhCM7IkUmjU05Y4+BmSLfusq0msCsQb1Va+OUKFCoOXjCJqQrcgdRdQCjYYyolQ/npQALQ==", + "license": "Apache-2.0", + "dependencies": { + "@elastic/transport": "^8.9.6", + "apache-arrow": "18.x - 21.x", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@elastic/transport": { + "version": "8.10.1", + "resolved": "https://registry.npmjs.org/@elastic/transport/-/transport-8.10.1.tgz", + "integrity": "sha512-xo2lPBAJEt81fQRAKa9T/gUq1SPGBHpSnVUXhoSpL996fPZRAfQwFA4BZtEUQL1p8Dezodd3ZN8Wwno+mYyKuw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "1.x", + "@opentelemetry/core": "2.x", + "debug": "^4.4.1", + "hpagent": "^1.2.0", + "ms": "^2.1.3", + "secure-json-parse": "^3.0.1", + "tslib": "^2.8.1", + "undici": "^6.21.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", @@ -4812,6 +4846,15 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, + "node_modules/@swc/helpers": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz", + "integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -4907,6 +4950,18 @@ "@types/node": "*" } }, + "node_modules/@types/command-line-args": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.2.3.tgz", + "integrity": "sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==", + "license": "MIT" + }, + "node_modules/@types/command-line-usage": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/command-line-usage/-/command-line-usage-5.0.4.tgz", + "integrity": "sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==", + "license": "MIT" + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -6068,7 +6123,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -6114,6 +6168,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/apache-arrow": { + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-21.1.0.tgz", + "integrity": "sha512-kQrYLxhC+NTVVZ4CCzGF6L/uPVOzJmD1T3XgbiUnP7oTeVFOFgEUu6IKNwCDkpFoBVqDKQivlX4RUFqqnWFlEA==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.11", + "@types/command-line-args": "^5.2.3", + "@types/command-line-usage": "^5.0.4", + "@types/node": "^24.0.3", + "command-line-args": "^6.0.1", + "command-line-usage": "^7.0.1", + "flatbuffers": "^25.1.24", + "json-bignum": "^0.0.3", + "tslib": "^2.6.2" + }, + "bin": { + "arrow2csv": "bin/arrow2csv.js" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -6131,6 +6205,15 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/array-back": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.2.tgz", + "integrity": "sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -6695,7 +6778,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -6708,11 +6790,25 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, "node_modules/chalk/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -6878,7 +6974,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -6891,7 +6986,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -6906,6 +7000,44 @@ "node": ">= 0.8" } }, + "node_modules/command-line-args": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-6.0.1.tgz", + "integrity": "sha512-Jr3eByUjqyK0qd8W0SGFW1nZwqCaNCtbXjRo2cRJC1OYxWl3MZ5t1US3jq+cO4sPavqgw4l9BMGX0CBe+trepg==", + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "find-replace": "^5.0.2", + "lodash.camelcase": "^4.3.0", + "typical": "^7.2.0" + }, + "engines": { + "node": ">=12.20" + }, + "peerDependencies": { + "@75lb/nature": "latest" + }, + "peerDependenciesMeta": { + "@75lb/nature": { + "optional": true + } + } + }, + "node_modules/command-line-usage": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.4.tgz", + "integrity": "sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg==", + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "chalk-template": "^0.4.0", + "table-layout": "^4.1.1", + "typical": "^7.3.0" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/commander": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", @@ -8206,6 +8338,23 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-replace": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-5.0.2.tgz", + "integrity": "sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@75lb/nature": "latest" + }, + "peerDependenciesMeta": { + "@75lb/nature": { + "optional": true + } + } + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -8246,6 +8395,12 @@ "node": ">=16" } }, + "node_modules/flatbuffers": { + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", + "license": "Apache-2.0" + }, "node_modules/flatted": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", @@ -8683,7 +8838,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8728,6 +8882,15 @@ "node": ">= 0.4" } }, + "node_modules/hpagent": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", + "integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -9852,6 +10015,14 @@ "node": ">=6" } }, + "node_modules/json-bignum": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/json-bignum/-/json-bignum-0.0.3.tgz", + "integrity": "sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -9999,6 +10170,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -11353,6 +11530,22 @@ "node": ">= 8" } }, + "node_modules/secure-json-parse": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-3.0.2.tgz", + "integrity": "sha512-H6nS2o8bWfpFEV6U38sOSjS7bTbdgbCGU9wEM6W14P5H0QOsz94KCusifV44GpHDTu2nqZbuDNhTzu+mjDSw1w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -11976,6 +12169,19 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/table-layout": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-4.1.1.tgz", + "integrity": "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==", + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "wordwrapjs": "^5.1.0" + }, + "engines": { + "node": ">=12.17" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -13589,6 +13795,15 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/typical": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-7.3.0.tgz", + "integrity": "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, "node_modules/ufo": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", @@ -13617,6 +13832,15 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", + "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -13837,6 +14061,15 @@ "dev": true, "license": "MIT" }, + "node_modules/wordwrapjs": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.1.tgz", + "integrity": "sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", diff --git a/scripts/ci-env.sh b/scripts/ci-env.sh index d446dc09..8974d5c9 100755 --- a/scripts/ci-env.sh +++ b/scripts/ci-env.sh @@ -51,6 +51,14 @@ fi # Start database $COMPOSE_CMD up -d ci-db +# Start Elasticsearch if ELASTICSEARCH_URL is configured +if [ -n "$ELASTICSEARCH_URL" ]; then + echo " - Elasticsearch: ENABLED" + $COMPOSE_CMD up -d ci-elasticsearch +else + echo " - Elasticsearch: DISABLED (ELASTICSEARCH_URL not set)" +fi + # Run database initialization $COMPOSE_CMD up ci-db-init diff --git a/tests/unit/services/search/elasticsearch.client.test.ts b/tests/unit/services/search/elasticsearch.client.test.ts new file mode 100644 index 00000000..7971339d --- /dev/null +++ b/tests/unit/services/search/elasticsearch.client.test.ts @@ -0,0 +1,65 @@ +const originalEnv = process.env; + +beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; +}); + +afterAll(() => { + process.env = originalEnv; +}); + +describe('elasticsearch.client', () => { + describe('isElasticsearchEnabled', () => { + it('returns false when ELASTICSEARCH_URL is not set', async () => { + delete process.env.ELASTICSEARCH_URL; + const { isElasticsearchEnabled } = await import('../../../../apps/backend/services/search/elasticsearch.client'); + expect(isElasticsearchEnabled()).toBe(false); + }); + + it('returns false when ELASTICSEARCH_URL is empty string', async () => { + process.env.ELASTICSEARCH_URL = ''; + const { isElasticsearchEnabled } = await import('../../../../apps/backend/services/search/elasticsearch.client'); + expect(isElasticsearchEnabled()).toBe(false); + }); + + it('returns true when ELASTICSEARCH_URL is set', async () => { + process.env.ELASTICSEARCH_URL = 'http://localhost:9200'; + const { isElasticsearchEnabled } = await import('../../../../apps/backend/services/search/elasticsearch.client'); + expect(isElasticsearchEnabled()).toBe(true); + }); + }); + + describe('getElasticsearchClient', () => { + it('returns null when ELASTICSEARCH_URL is not set', async () => { + delete process.env.ELASTICSEARCH_URL; + const { getElasticsearchClient } = await import('../../../../apps/backend/services/search/elasticsearch.client'); + expect(getElasticsearchClient()).toBeNull(); + }); + + it('returns a Client instance when ELASTICSEARCH_URL is set', async () => { + process.env.ELASTICSEARCH_URL = 'http://localhost:9200'; + const { getElasticsearchClient } = await import('../../../../apps/backend/services/search/elasticsearch.client'); + const client = getElasticsearchClient(); + expect(client).not.toBeNull(); + expect(client).toBeDefined(); + }); + + it('returns the same instance on subsequent calls', async () => { + process.env.ELASTICSEARCH_URL = 'http://localhost:9200'; + const { getElasticsearchClient } = await import('../../../../apps/backend/services/search/elasticsearch.client'); + const client1 = getElasticsearchClient(); + const client2 = getElasticsearchClient(); + expect(client1).toBe(client2); + }); + + it('configures auth when username and password are set', async () => { + process.env.ELASTICSEARCH_URL = 'http://localhost:9200'; + process.env.ELASTICSEARCH_USERNAME = 'elastic'; + process.env.ELASTICSEARCH_PASSWORD = 'changeme'; + const { getElasticsearchClient } = await import('../../../../apps/backend/services/search/elasticsearch.client'); + const client = getElasticsearchClient(); + expect(client).not.toBeNull(); + }); + }); +}); diff --git a/tests/unit/services/search/elasticsearch.search.test.ts b/tests/unit/services/search/elasticsearch.search.test.ts new file mode 100644 index 00000000..ad23fc8b --- /dev/null +++ b/tests/unit/services/search/elasticsearch.search.test.ts @@ -0,0 +1,250 @@ +import { jest } from '@jest/globals'; + +// Mock the client module +const mockSearch = jest.fn(); +jest.mock('../../../../apps/backend/services/search/elasticsearch.client', () => ({ + getElasticsearchClient: jest.fn(() => ({ + search: mockSearch, + })), + isElasticsearchEnabled: jest.fn(() => true), +})); + +jest.mock('../../../../apps/backend/services/search/elasticsearch.indices', () => ({ + getLibraryIndexName: jest.fn(() => 'wxyc_library'), +})); + +import { + searchLibraryES, + findSimilarArtistES, + searchAlbumsByTitleES, + searchByArtistES, +} from '../../../../apps/backend/services/search/elasticsearch.search'; + +const sampleHit = { + _source: { + id: 42, + artist_name: 'Juana Molina', + alphabetical_name: 'Molina, Juana', + album_title: 'Segundo', + label: 'Domino', + genre_name: 'Rock', + format_name: 'CD', + rotation_bin: null, + code_letters: 'RO', + code_artist_number: 5, + code_number: 2, + add_date: '2024-01-15', + }, +}; + +describe('elasticsearch.search', () => { + beforeEach(() => { + mockSearch.mockReset(); + }); + + describe('searchLibraryES', () => { + it('builds a multi_match query from a general query string', async () => { + mockSearch.mockResolvedValue({ hits: { hits: [sampleHit] } }); + + await searchLibraryES('Juana Molina'); + + expect(mockSearch).toHaveBeenCalledWith( + expect.objectContaining({ + index: 'wxyc_library', + size: 5, + body: expect.objectContaining({ + query: expect.objectContaining({ + multi_match: expect.objectContaining({ + query: 'Juana Molina', + fuzziness: 'AUTO', + }), + }), + }), + }) + ); + }); + + it('builds a bool query when artist and title are provided', async () => { + mockSearch.mockResolvedValue({ hits: { hits: [sampleHit] } }); + + await searchLibraryES(undefined, 'Juana Molina', 'Segundo'); + + expect(mockSearch).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + query: expect.objectContaining({ + bool: expect.objectContaining({ + should: expect.arrayContaining([ + expect.objectContaining({ match: expect.objectContaining({ artist_name: expect.any(Object) }) }), + expect.objectContaining({ match: expect.objectContaining({ album_title: expect.any(Object) }) }), + ]), + }), + }), + }), + }) + ); + }); + + it('maps ES results to LibraryArtistViewEntry shape', async () => { + mockSearch.mockResolvedValue({ hits: { hits: [sampleHit] } }); + + const results = await searchLibraryES('Juana Molina'); + + expect(results).toHaveLength(1); + expect(results[0]).toEqual({ + id: 42, + artist_name: 'Juana Molina', + alphabetical_name: 'Molina, Juana', + album_title: 'Segundo', + label: 'Domino', + genre_name: 'Rock', + format_name: 'CD', + rotation_bin: null, + code_letters: 'RO', + code_artist_number: 5, + code_number: 2, + add_date: '2024-01-15', + }); + }); + + it('returns empty array when no hits', async () => { + mockSearch.mockResolvedValue({ hits: { hits: [] } }); + const results = await searchLibraryES('nonexistent'); + expect(results).toEqual([]); + }); + + it('respects custom limit parameter', async () => { + mockSearch.mockResolvedValue({ hits: { hits: [] } }); + await searchLibraryES('test', undefined, undefined, 10); + expect(mockSearch).toHaveBeenCalledWith(expect.objectContaining({ size: 10 })); + }); + + it('returns empty array when no query, artist, or title provided', async () => { + const results = await searchLibraryES(); + expect(results).toEqual([]); + expect(mockSearch).not.toHaveBeenCalled(); + }); + }); + + describe('findSimilarArtistES', () => { + it('searches for similar artist names with fuzzy matching', async () => { + mockSearch.mockResolvedValue({ + hits: { + hits: [ + { + _source: { artist_name: 'Juana Molina' }, + _score: 5.2, + }, + ], + }, + }); + + await findSimilarArtistES('Juana Mollina'); + + expect(mockSearch).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + query: expect.objectContaining({ + match: expect.objectContaining({ + artist_name: expect.objectContaining({ + query: 'Juana Mollina', + fuzziness: 'AUTO', + }), + }), + }), + }), + }) + ); + }); + + it('returns corrected artist name when a different match is found', async () => { + mockSearch.mockResolvedValue({ + hits: { + hits: [{ _source: { artist_name: 'Juana Molina' }, _score: 5.2 }], + }, + }); + + const result = await findSimilarArtistES('Juana Mollina'); + expect(result).toBe('Juana Molina'); + }); + + it('returns null when the best match is the same name', async () => { + mockSearch.mockResolvedValue({ + hits: { + hits: [{ _source: { artist_name: 'Juana Molina' }, _score: 5.2 }], + }, + }); + + const result = await findSimilarArtistES('Juana Molina'); + expect(result).toBeNull(); + }); + + it('returns null when no matches found', async () => { + mockSearch.mockResolvedValue({ hits: { hits: [] } }); + const result = await findSimilarArtistES('zzzznonexistent'); + expect(result).toBeNull(); + }); + }); + + describe('searchAlbumsByTitleES', () => { + it('searches albums by title with fuzzy matching', async () => { + mockSearch.mockResolvedValue({ hits: { hits: [sampleHit] } }); + + const results = await searchAlbumsByTitleES('Segundo'); + + expect(mockSearch).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + query: expect.objectContaining({ + match: expect.objectContaining({ + album_title: expect.objectContaining({ + query: 'Segundo', + fuzziness: 'AUTO', + }), + }), + }), + }), + }) + ); + expect(results).toHaveLength(1); + expect(results[0].album_title).toBe('Segundo'); + }); + + it('returns empty array when no matches', async () => { + mockSearch.mockResolvedValue({ hits: { hits: [] } }); + const results = await searchAlbumsByTitleES('nonexistent'); + expect(results).toEqual([]); + }); + }); + + describe('searchByArtistES', () => { + it('searches by artist name with fuzzy matching', async () => { + mockSearch.mockResolvedValue({ hits: { hits: [sampleHit] } }); + + const results = await searchByArtistES('Juana Molina'); + + expect(mockSearch).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + query: expect.objectContaining({ + match: expect.objectContaining({ + artist_name: expect.objectContaining({ + query: 'Juana Molina', + fuzziness: 'AUTO', + }), + }), + }), + }), + }) + ); + expect(results).toHaveLength(1); + expect(results[0].artist_name).toBe('Juana Molina'); + }); + + it('returns empty array when no matches', async () => { + mockSearch.mockResolvedValue({ hits: { hits: [] } }); + const results = await searchByArtistES('nonexistent'); + expect(results).toEqual([]); + }); + }); +}); diff --git a/tests/unit/services/search/search.facade.test.ts b/tests/unit/services/search/search.facade.test.ts new file mode 100644 index 00000000..f6d0daa2 --- /dev/null +++ b/tests/unit/services/search/search.facade.test.ts @@ -0,0 +1,211 @@ +import { jest } from '@jest/globals'; +import type { LibraryArtistViewEntry } from '@wxyc/database'; +import type { EnrichedLibraryResult } from '../../../../apps/backend/services/requestLine/types'; + +// Mock the ES client module +const mockIsEnabled = jest.fn<() => boolean>(); +jest.mock('../../../../apps/backend/services/search/elasticsearch.client', () => ({ + isElasticsearchEnabled: mockIsEnabled, +})); + +// Mock ES search functions +const mockSearchLibraryES = jest.fn<(...args: unknown[]) => Promise>(); +const mockFindSimilarArtistES = jest.fn<(...args: unknown[]) => Promise>(); +const mockSearchAlbumsByTitleES = jest.fn<(...args: unknown[]) => Promise>(); +const mockSearchByArtistES = jest.fn<(...args: unknown[]) => Promise>(); +jest.mock('../../../../apps/backend/services/search/elasticsearch.search', () => ({ + searchLibraryES: mockSearchLibraryES, + findSimilarArtistES: mockFindSimilarArtistES, + searchAlbumsByTitleES: mockSearchAlbumsByTitleES, + searchByArtistES: mockSearchByArtistES, +})); + +// Mock pg_trgm functions from library.service +const mockPgTrgmSearchLibrary = jest.fn<(...args: unknown[]) => Promise>(); +const mockPgTrgmFindSimilarArtist = jest.fn<(...args: unknown[]) => Promise>(); +const mockPgTrgmSearchAlbumsByTitle = jest.fn<(...args: unknown[]) => Promise>(); +const mockPgTrgmSearchByArtist = jest.fn<(...args: unknown[]) => Promise>(); +jest.mock('../../../../apps/backend/services/library.service', () => ({ + pgTrgmSearchLibrary: mockPgTrgmSearchLibrary, + pgTrgmFindSimilarArtist: mockPgTrgmFindSimilarArtist, + pgTrgmSearchAlbumsByTitle: mockPgTrgmSearchAlbumsByTitle, + pgTrgmSearchByArtist: mockPgTrgmSearchByArtist, +})); + +// Mock enrichLibraryResult — just passes through with stub fields +jest.mock('../../../../apps/backend/services/requestLine/types', () => ({ + enrichLibraryResult: jest.fn((result: Record) => ({ + ...result, + callNumber: 'STUB', + libraryUrl: 'STUB', + })), +})); + +import { + searchLibrary, + findSimilarArtist, + searchAlbumsByTitle, + searchByArtist, +} from '../../../../apps/backend/services/search/index'; + +const sampleESResult: LibraryArtistViewEntry = { + id: 42, + artist_name: 'Juana Molina', + alphabetical_name: 'Molina, Juana', + album_title: 'Segundo', + label: 'Domino', + genre_name: 'Rock', + format_name: 'CD', + rotation_bin: null, + code_letters: 'RO', + code_artist_number: 5, + code_number: 2, + add_date: new Date('2024-01-15'), +}; + +const samplePgResult: EnrichedLibraryResult = { + id: 42, + title: 'Segundo', + artist: 'Juana Molina', + alphabeticalName: 'Molina, Juana', + codeLetters: 'RO', + codeArtistNumber: 5, + codeNumber: 2, + genre: 'Rock', + format: 'CD', + callNumber: 'Rock CD RO 5/2', + libraryUrl: 'http://www.wxyc.info/wxycdb/libraryRelease?id=42', +}; + +describe('search facade', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('searchLibrary', () => { + it('routes to ES when enabled and healthy', async () => { + mockIsEnabled.mockReturnValue(true); + mockSearchLibraryES.mockResolvedValue([sampleESResult]); + + const results = await searchLibrary('Juana Molina'); + + expect(mockSearchLibraryES).toHaveBeenCalledWith('Juana Molina', undefined, undefined, 5); + expect(mockPgTrgmSearchLibrary).not.toHaveBeenCalled(); + expect(results).toHaveLength(1); + }); + + it('falls back to pg_trgm when ES throws', async () => { + mockIsEnabled.mockReturnValue(true); + mockSearchLibraryES.mockRejectedValue(new Error('ES connection refused')); + mockPgTrgmSearchLibrary.mockResolvedValue([samplePgResult]); + + const results = await searchLibrary('Juana Molina'); + + expect(mockSearchLibraryES).toHaveBeenCalled(); + expect(mockPgTrgmSearchLibrary).toHaveBeenCalledWith('Juana Molina', undefined, undefined, 5); + expect(results).toEqual([samplePgResult]); + }); + + it('routes directly to pg_trgm when ES is disabled', async () => { + mockIsEnabled.mockReturnValue(false); + mockPgTrgmSearchLibrary.mockResolvedValue([samplePgResult]); + + const results = await searchLibrary('Juana Molina'); + + expect(mockSearchLibraryES).not.toHaveBeenCalled(); + expect(mockPgTrgmSearchLibrary).toHaveBeenCalledWith('Juana Molina', undefined, undefined, 5); + expect(results).toEqual([samplePgResult]); + }); + + it('passes artist and title parameters through', async () => { + mockIsEnabled.mockReturnValue(true); + mockSearchLibraryES.mockResolvedValue([]); + + await searchLibrary(undefined, 'Stereolab', 'Aluminum Tunes', 10); + + expect(mockSearchLibraryES).toHaveBeenCalledWith(undefined, 'Stereolab', 'Aluminum Tunes', 10); + }); + }); + + describe('findSimilarArtist', () => { + it('routes to ES when enabled', async () => { + mockIsEnabled.mockReturnValue(true); + mockFindSimilarArtistES.mockResolvedValue('Juana Molina'); + + const result = await findSimilarArtist('Juana Mollina'); + + expect(mockFindSimilarArtistES).toHaveBeenCalledWith('Juana Mollina', 0.85); + expect(mockPgTrgmFindSimilarArtist).not.toHaveBeenCalled(); + expect(result).toBe('Juana Molina'); + }); + + it('falls back to pg_trgm when ES throws', async () => { + mockIsEnabled.mockReturnValue(true); + mockFindSimilarArtistES.mockRejectedValue(new Error('timeout')); + mockPgTrgmFindSimilarArtist.mockResolvedValue('Juana Molina'); + + const result = await findSimilarArtist('Juana Mollina'); + + expect(mockPgTrgmFindSimilarArtist).toHaveBeenCalledWith('Juana Mollina', 0.85); + expect(result).toBe('Juana Molina'); + }); + + it('routes to pg_trgm when ES is disabled', async () => { + mockIsEnabled.mockReturnValue(false); + mockPgTrgmFindSimilarArtist.mockResolvedValue(null); + + await findSimilarArtist('Juana Molina'); + + expect(mockFindSimilarArtistES).not.toHaveBeenCalled(); + expect(mockPgTrgmFindSimilarArtist).toHaveBeenCalled(); + }); + }); + + describe('searchAlbumsByTitle', () => { + it('routes to ES when enabled', async () => { + mockIsEnabled.mockReturnValue(true); + mockSearchAlbumsByTitleES.mockResolvedValue([sampleESResult]); + + const results = await searchAlbumsByTitle('Segundo'); + + expect(mockSearchAlbumsByTitleES).toHaveBeenCalledWith('Segundo', 5); + expect(mockPgTrgmSearchAlbumsByTitle).not.toHaveBeenCalled(); + expect(results).toHaveLength(1); + }); + + it('falls back to pg_trgm when ES throws', async () => { + mockIsEnabled.mockReturnValue(true); + mockSearchAlbumsByTitleES.mockRejectedValue(new Error('ES down')); + mockPgTrgmSearchAlbumsByTitle.mockResolvedValue([samplePgResult]); + + const results = await searchAlbumsByTitle('Segundo'); + + expect(mockPgTrgmSearchAlbumsByTitle).toHaveBeenCalledWith('Segundo', 5); + expect(results).toEqual([samplePgResult]); + }); + }); + + describe('searchByArtist', () => { + it('routes to ES when enabled', async () => { + mockIsEnabled.mockReturnValue(true); + mockSearchByArtistES.mockResolvedValue([sampleESResult]); + + const results = await searchByArtist('Juana Molina'); + + expect(mockSearchByArtistES).toHaveBeenCalledWith('Juana Molina', 5); + expect(mockPgTrgmSearchByArtist).not.toHaveBeenCalled(); + expect(results).toHaveLength(1); + }); + + it('falls back to pg_trgm when ES throws', async () => { + mockIsEnabled.mockReturnValue(true); + mockSearchByArtistES.mockRejectedValue(new Error('ES down')); + mockPgTrgmSearchByArtist.mockResolvedValue([samplePgResult]); + + const results = await searchByArtist('Juana Molina'); + + expect(mockPgTrgmSearchByArtist).toHaveBeenCalledWith('Juana Molina', 5); + expect(results).toEqual([samplePgResult]); + }); + }); +});