From 84cb255b0da3a108911d53a1b45331c38c45e4e8 Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Tue, 3 Mar 2026 15:30:22 -0800 Subject: [PATCH] feat: add proxy endpoints for iOS app secret migration Add server-side proxy endpoints that allow the iOS app to call Discogs, Spotify, and Apple Music APIs through Backend-Service instead of embedding API credentials in the binary. New endpoints: - GET /config -- unauthenticated bootstrap configuration (PostHog key, request-o-matic URL) - GET /proxy/artwork/search -- artwork lookup via ArtworkFinder - GET /proxy/metadata/album -- album metadata from Discogs + Spotify + Apple Music + search URLs - GET /proxy/metadata/artist -- artist bio + Wikipedia from Discogs by artist ID - GET /proxy/entity/resolve -- resolve Discogs entity (artist/release/master) by ID - GET /proxy/spotify/track/:id -- Spotify track metadata using backend credentials All /proxy/* endpoints require anonymous session auth (requireAnonymousAuth) and rate limiting (120 req/60s per user). The /config endpoint is intentionally unauthenticated since the app needs it before authenticating. --- .env.example | 6 + CLAUDE.md | 22 +- apps/backend/app.ts | 8 + apps/backend/controllers/config.controller.ts | 32 ++ apps/backend/controllers/proxy.controller.ts | 306 ++++++++++++ apps/backend/middleware/rateLimiting.ts | 38 ++ apps/backend/routes/config.route.ts | 7 + apps/backend/routes/proxy.route.ts | 15 + .../controllers/config.controller.test.ts | 77 +++ .../unit/controllers/proxy.controller.test.ts | 459 ++++++++++++++++++ 10 files changed, 960 insertions(+), 10 deletions(-) create mode 100644 apps/backend/controllers/config.controller.ts create mode 100644 apps/backend/controllers/proxy.controller.ts create mode 100644 apps/backend/routes/config.route.ts create mode 100644 apps/backend/routes/proxy.route.ts create mode 100644 tests/unit/controllers/config.controller.test.ts create mode 100644 tests/unit/controllers/proxy.controller.test.ts diff --git a/.env.example b/.env.example index 31f8284d..42b3fdf4 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,12 @@ DISCOGS_API_SECRET=your_discogs_api_secret SPOTIFY_CLIENT_ID=your_spotify_client_id SPOTIFY_CLIENT_SECRET=your_spotify_client_secret +### iOS App Configuration (served via GET /config) +POSTHOG_API_KEY=your_posthog_api_key +POSTHOG_HOST=https://us.i.posthog.com +REQUEST_O_MATIC_URL=https://request-o-matic-production.up.railway.app/request +API_BASE_URL=https://api.wxyc.org + ### Metadata Cache Configuration METADATA_ALBUM_CACHE_MAX_SIZE=1000 METADATA_ARTIST_CACHE_MAX_SIZE=500 diff --git a/CLAUDE.md b/CLAUDE.md index 4061ebc5..4044668d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,16 +19,18 @@ npm workspaces with four packages: Express 5 application with these route groups: -| Route | Purpose | -| --------------- | --------------------------------------- | -| `/library` | Music library catalog | -| `/flowsheet` | V1 flowsheet (legacy) | -| `/v2/flowsheet` | V2 flowsheet (uses `@wxyc/shared` DTOs) | -| `/djs` | DJ profiles and management | -| `/request` | Song request line | -| `/schedule` | Schedule management | -| `/events` | SSE for real-time updates | -| `/healthcheck` | Health check | +| Route | Purpose | +| --------------- | ---------------------------------------------- | +| `/config` | Public app bootstrap configuration | +| `/proxy` | iOS proxy endpoints (anonymous auth + rate limit) | +| `/library` | Music library catalog | +| `/flowsheet` | V1 flowsheet (legacy) | +| `/v2/flowsheet` | V2 flowsheet (uses `@wxyc/shared` DTOs) | +| `/djs` | DJ profiles and management | +| `/request` | Song request line | +| `/schedule` | Schedule management | +| `/events` | SSE for real-time updates | +| `/healthcheck` | Health check | Code is organized as controllers (HTTP handling) -> services (business logic) -> database (Drizzle queries). diff --git a/apps/backend/app.ts b/apps/backend/app.ts index 4ac40de3..9875017c 100644 --- a/apps/backend/app.ts +++ b/apps/backend/app.ts @@ -10,6 +10,8 @@ import { library_route } from './routes/library.route.js'; import { schedule_route } from './routes/schedule.route.js'; import { events_route } from './routes/events.route.js'; import { request_line_route } from './routes/requestLine.route.js'; +import { config_route } from './routes/config.route.js'; +import { proxy_route } from './routes/proxy.route.js'; import { showMemberMiddleware } from './middleware/checkShowMember.js'; import { activeShow } from './middleware/checkActiveShow.js'; import errorHandler from './middleware/errorHandler.js'; @@ -35,6 +37,12 @@ app.use( const swaggerDoc = parse_yaml(swaggerContent); app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDoc)); +// Public configuration endpoint (unauthenticated) +app.use('/config', config_route); + +// Proxy endpoints for iOS app (anonymous auth + rate limiting) +app.use('/proxy', proxy_route); + // Business logic routes app.use('/library', library_route); diff --git a/apps/backend/controllers/config.controller.ts b/apps/backend/controllers/config.controller.ts new file mode 100644 index 00000000..36d9845c --- /dev/null +++ b/apps/backend/controllers/config.controller.ts @@ -0,0 +1,32 @@ +/** + * Config controller - serves non-sensitive app configuration. + * + * GET /config is unauthenticated because the app needs it before it can + * authenticate (bootstrap chicken-and-egg). + */ +import { RequestHandler } from 'express'; + +export interface AppConfig { + posthogApiKey: string; + posthogHost: string; + requestOMaticUrl: string; + apiBaseUrl: string; +} + +/** + * GET /config + * + * Returns public, non-sensitive configuration for app bootstrap. + * Cache-Control: public, max-age=3600 (1 hour). + */ +export const getConfig: RequestHandler = (_req, res) => { + const config: AppConfig = { + posthogApiKey: process.env.POSTHOG_API_KEY || '', + posthogHost: process.env.POSTHOG_HOST || 'https://us.i.posthog.com', + requestOMaticUrl: process.env.REQUEST_O_MATIC_URL || '', + apiBaseUrl: process.env.API_BASE_URL || 'https://api.wxyc.org', + }; + + res.set('Cache-Control', 'public, max-age=3600'); + res.status(200).json(config); +}; diff --git a/apps/backend/controllers/proxy.controller.ts b/apps/backend/controllers/proxy.controller.ts new file mode 100644 index 00000000..209e73c1 --- /dev/null +++ b/apps/backend/controllers/proxy.controller.ts @@ -0,0 +1,306 @@ +/** + * Proxy controller - thin HTTP layer over existing services. + * + * All handlers require `requireAnonymousAuth` + `proxyRateLimit` middleware + * applied at the route level. + */ +import { RequestHandler } from 'express'; +import { DiscogsProvider } from '../services/metadata/providers/discogs.provider.js'; +import { SpotifyProvider } from '../services/metadata/providers/spotify.provider.js'; +import { AppleMusicProvider } from '../services/metadata/providers/apple.provider.js'; +import { SearchUrlProvider } from '../services/metadata/providers/search-urls.provider.js'; +import { getArtworkFinder } from '../services/artwork/finder.js'; +import { DiscogsService } from '../services/discogs/discogs.service.js'; +import { AlbumMetadataResult, ArtistMetadataResult, SpotifyTokenResponse } from '../services/metadata/metadata.types.js'; + +interface SpotifyTrackApiResponse { + name: string; + artists?: Array<{ name: string }>; + album?: { + name: string; + images?: Array<{ url: string }>; + }; +} + +// Reuse the existing singleton provider instances +const discogs = new DiscogsProvider(); +const spotify = new SpotifyProvider(); +const appleMusic = new AppleMusicProvider(); +const searchUrls = new SearchUrlProvider(); + +// --- Query parameter types --- + +type ArtworkSearchQuery = { + artistName?: string; + releaseTitle?: string; +}; + +type AlbumMetadataQuery = { + artistName?: string; + releaseTitle?: string; + trackTitle?: string; +}; + +type ArtistMetadataQuery = { + artistId?: string; +}; + +type EntityResolveQuery = { + type?: string; + id?: string; +}; + +type SpotifyTrackParams = { + id: string; +}; + +// --- Handlers --- + +/** + * GET /proxy/artwork/search + * + * Searches for album artwork via the Discogs-backed ArtworkFinder. + */ +export const searchArtwork: RequestHandler = async (req, res, next) => { + const { artistName, releaseTitle } = req.query; + + if (!artistName) { + res.status(400).json({ message: 'artistName query parameter is required' }); + return; + } + + try { + const finder = getArtworkFinder(); + const result = await finder.find({ + artist: artistName, + album: releaseTitle || undefined, + }); + + res.set('Cache-Control', 'private, max-age=600'); + res.status(200).json({ + artworkUrl: result.artworkUrl, + source: result.source, + confidence: result.confidence, + }); + } catch (e) { + console.error('[ProxyController] searchArtwork error:', e); + next(e); + } +}; + +/** + * GET /proxy/metadata/album + * + * Fetches album metadata from Discogs, Spotify, Apple Music, and search URL + * providers in parallel. Mirrors the existing MetadataService.fetchAlbumMetadata + * logic. + */ +export const getAlbumMetadata: RequestHandler = async ( + req, + res, + next +) => { + const { artistName, releaseTitle, trackTitle } = req.query; + + if (!artistName) { + res.status(400).json({ message: 'artistName query parameter is required' }); + return; + } + + try { + const [discogsResult, spotifyUrl, appleMusicUrl] = await Promise.allSettled([ + discogs.fetchAlbumMetadata(artistName, releaseTitle || trackTitle || ''), + spotify.getSpotifyUrl(artistName, releaseTitle, trackTitle), + appleMusic.getAppleMusicUrl(artistName, releaseTitle, trackTitle), + ]); + + const metadata: AlbumMetadataResult = {}; + + if (discogsResult.status === 'fulfilled' && discogsResult.value) { + Object.assign(metadata, discogsResult.value); + } + + if (spotifyUrl.status === 'fulfilled' && spotifyUrl.value) { + metadata.spotifyUrl = spotifyUrl.value; + } + + if (appleMusicUrl.status === 'fulfilled' && appleMusicUrl.value) { + metadata.appleMusicUrl = appleMusicUrl.value; + } + + const urls = searchUrls.getAllSearchUrls(artistName, releaseTitle, trackTitle); + metadata.youtubeMusicUrl = urls.youtubeMusicUrl; + metadata.bandcampUrl = urls.bandcampUrl; + metadata.soundcloudUrl = urls.soundcloudUrl; + + res.set('Cache-Control', 'private, max-age=600'); + res.status(200).json(metadata); + } catch (e) { + console.error('[ProxyController] getAlbumMetadata error:', e); + next(e); + } +}; + +/** + * GET /proxy/metadata/artist + * + * Fetches artist metadata (bio, Wikipedia URL) from Discogs by artist ID. + */ +export const getArtistMetadata: RequestHandler = async ( + req, + res, + next +) => { + const { artistId } = req.query; + + if (!artistId) { + res.status(400).json({ message: 'artistId query parameter is required' }); + return; + } + + const id = parseInt(artistId, 10); + if (isNaN(id)) { + res.status(400).json({ message: 'artistId must be an integer' }); + return; + } + + try { + const result: ArtistMetadataResult | null = await discogs.fetchArtistMetadataById(id); + + if (!result) { + res.status(404).json({ message: 'Artist not found' }); + return; + } + + res.set('Cache-Control', 'private, max-age=3600'); + res.status(200).json(result); + } catch (e) { + console.error('[ProxyController] getArtistMetadata error:', e); + next(e); + } +}; + +/** + * GET /proxy/entity/resolve + * + * Resolves a Discogs entity (artist, release, master) by type and ID. + * Returns the entity's name and basic info. + */ +export const resolveEntity: RequestHandler = async (req, res, next) => { + const { type, id } = req.query; + + if (!type || !id) { + res.status(400).json({ message: 'type and id query parameters are required' }); + return; + } + + const validTypes = ['artist', 'release', 'master']; + if (!validTypes.includes(type)) { + res.status(400).json({ message: `type must be one of: ${validTypes.join(', ')}` }); + return; + } + + const entityId = parseInt(id, 10); + if (isNaN(entityId)) { + res.status(400).json({ message: 'id must be an integer' }); + return; + } + + try { + let name: string | null = null; + + if (type === 'artist') { + const artist = await DiscogsService.getArtist(entityId); + name = artist?.name || null; + } else if (type === 'release') { + const release = await DiscogsService.getRelease(entityId); + name = release?.title || null; + } else if (type === 'master') { + const master = await DiscogsService.getMaster(entityId); + name = master?.title || null; + } + + if (!name) { + res.status(404).json({ message: `${type} not found` }); + return; + } + + res.set('Cache-Control', 'private, max-age=86400'); + res.status(200).json({ name, type, id: entityId }); + } catch (e) { + console.error('[ProxyController] resolveEntity error:', e); + next(e); + } +}; + +/** + * GET /proxy/spotify/track/:id + * + * Fetches Spotify track metadata using backend credentials. + */ +export const getSpotifyTrack: RequestHandler = async (req, res, next) => { + const { id } = req.params; + + if (!id) { + res.status(400).json({ message: 'Track ID is required' }); + return; + } + + try { + // Use the SpotifyProvider's internal auth to call the Spotify API + const spotifyClientId = process.env.SPOTIFY_CLIENT_ID; + const spotifyClientSecret = process.env.SPOTIFY_CLIENT_SECRET; + + if (!spotifyClientId || !spotifyClientSecret) { + res.status(503).json({ message: 'Spotify integration not configured' }); + return; + } + + // Get or refresh Spotify access token + const tokenResponse = await fetch('https://accounts.spotify.com/api/token', { + method: 'POST', + headers: { + Authorization: `Basic ${Buffer.from(`${spotifyClientId}:${spotifyClientSecret}`).toString('base64')}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'grant_type=client_credentials', + }); + + if (!tokenResponse.ok) { + console.error(`[ProxyController] Spotify auth failed: ${tokenResponse.status}`); + res.status(502).json({ message: 'Spotify authentication failed' }); + return; + } + + const tokenData: SpotifyTokenResponse = await tokenResponse.json() as SpotifyTokenResponse; + + const trackResponse = await fetch(`https://api.spotify.com/v1/tracks/${encodeURIComponent(id)}`, { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + }, + }); + + if (!trackResponse.ok) { + if (trackResponse.status === 404) { + res.status(404).json({ message: 'Track not found' }); + return; + } + console.error(`[ProxyController] Spotify track fetch failed: ${trackResponse.status}`); + res.status(502).json({ message: 'Failed to fetch track from Spotify' }); + return; + } + + const track: SpotifyTrackApiResponse = await trackResponse.json() as SpotifyTrackApiResponse; + + res.set('Cache-Control', 'private, max-age=600'); + res.status(200).json({ + title: track.name, + artist: track.artists?.[0]?.name || '', + album: track.album?.name || '', + artworkUrl: track.album?.images?.[0]?.url || null, + }); + } catch (e) { + console.error('[ProxyController] getSpotifyTrack error:', e); + next(e); + } +}; diff --git a/apps/backend/middleware/rateLimiting.ts b/apps/backend/middleware/rateLimiting.ts index c32788bd..be12265a 100644 --- a/apps/backend/middleware/rateLimiting.ts +++ b/apps/backend/middleware/rateLimiting.ts @@ -11,9 +11,13 @@ const REGISTRATION_MAX = parseInt(process.env.RATE_LIMIT_REGISTRATION_MAX || '5' const REQUEST_WINDOW_MS = parseInt(process.env.RATE_LIMIT_REQUEST_WINDOW_MS || '900000', 10); // 15 min default const REQUEST_MAX = parseInt(process.env.RATE_LIMIT_REQUEST_MAX || '10', 10); +const PROXY_WINDOW_MS = parseInt(process.env.RATE_LIMIT_PROXY_WINDOW_MS || '60000', 10); // 60 seconds default +const PROXY_MAX = parseInt(process.env.RATE_LIMIT_PROXY_MAX || '120', 10); + // Shared stores so we can reset them in tests const registrationStore = new MemoryStore(); const songRequestStore = new MemoryStore(); +const proxyStore = new MemoryStore(); /** * Reset all rate limit stores. Only works in test environment. @@ -23,6 +27,7 @@ export const resetRateLimitStores = (): void => { if (isTestEnv) { void registrationStore.resetAll(); void songRequestStore.resetAll(); + void proxyStore.resetAll(); } }; @@ -93,3 +98,36 @@ export const songRequestRateLimit = shouldEnableRateLimiting validate: { xForwardedForHeader: false } as Partial, }) : passThrough; + +/** + * Rate limiter for proxy endpoints (artwork, metadata, entity, Spotify). + * Limits requests per user ID (from anonymous auth session). + * + * Configurable via environment: + * - RATE_LIMIT_PROXY_WINDOW_MS (default: 60000 = 60 seconds) + * - RATE_LIMIT_PROXY_MAX (default: 120) + * + * Disabled in test environment unless TEST_RATE_LIMITING=true + */ +export const proxyRateLimit = shouldEnableRateLimiting + ? rateLimit({ + windowMs: PROXY_WINDOW_MS, + max: PROXY_MAX, + standardHeaders: true, + legacyHeaders: false, + store: proxyStore, + keyGenerator: (req: Request) => { + if (req.user?.id) { + return req.user.id; + } + return 'unknown'; + }, + handler: (_req: Request, res: Response) => { + res.status(429).json({ + message: 'Too many proxy requests. Please try again shortly.', + retryAfter: Math.ceil(PROXY_WINDOW_MS / 1000), + }); + }, + validate: { xForwardedForHeader: false } as Partial, + }) + : passThrough; diff --git a/apps/backend/routes/config.route.ts b/apps/backend/routes/config.route.ts new file mode 100644 index 00000000..6a1dc0c0 --- /dev/null +++ b/apps/backend/routes/config.route.ts @@ -0,0 +1,7 @@ +import { Router } from 'express'; +import * as configController from '../controllers/config.controller.js'; + +export const config_route = Router(); + +// GET /config - unauthenticated bootstrap configuration +config_route.get('/', configController.getConfig); diff --git a/apps/backend/routes/proxy.route.ts b/apps/backend/routes/proxy.route.ts new file mode 100644 index 00000000..4906c0fb --- /dev/null +++ b/apps/backend/routes/proxy.route.ts @@ -0,0 +1,15 @@ +import { Router } from 'express'; +import * as proxyController from '../controllers/proxy.controller.js'; +import { requireAnonymousAuth } from '../middleware/anonymousAuth.js'; +import { proxyRateLimit } from '../middleware/rateLimiting.js'; + +export const proxy_route = Router(); + +// All proxy routes require anonymous auth + rate limiting +proxy_route.use(requireAnonymousAuth, proxyRateLimit); + +proxy_route.get('/artwork/search', proxyController.searchArtwork); +proxy_route.get('/metadata/album', proxyController.getAlbumMetadata); +proxy_route.get('/metadata/artist', proxyController.getArtistMetadata); +proxy_route.get('/entity/resolve', proxyController.resolveEntity); +proxy_route.get('/spotify/track/:id', proxyController.getSpotifyTrack); diff --git a/tests/unit/controllers/config.controller.test.ts b/tests/unit/controllers/config.controller.test.ts new file mode 100644 index 00000000..ad573967 --- /dev/null +++ b/tests/unit/controllers/config.controller.test.ts @@ -0,0 +1,77 @@ +/** + * Unit tests for the config controller. + */ +import type { Request, Response } from 'express'; + +import { getConfig } from '../../../apps/backend/controllers/config.controller'; + +const createMockRes = () => { + const res: Partial = {}; + res.status = jest.fn().mockReturnValue(res) as unknown as Response['status']; + res.json = jest.fn().mockReturnValue(res) as unknown as Response['json']; + res.set = jest.fn().mockReturnValue(res) as unknown as Response['set']; + return res; +}; + +describe('config.controller', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe('getConfig', () => { + it('returns config values from environment variables', () => { + process.env.POSTHOG_API_KEY = 'phc_test123'; + process.env.POSTHOG_HOST = 'https://custom.posthog.com'; + process.env.REQUEST_O_MATIC_URL = 'https://rom.example.com/request'; + process.env.API_BASE_URL = 'https://api.example.com'; + + const req = {} as Request; + const res = createMockRes(); + + getConfig(req, res as Response, jest.fn()); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + posthogApiKey: 'phc_test123', + posthogHost: 'https://custom.posthog.com', + requestOMaticUrl: 'https://rom.example.com/request', + apiBaseUrl: 'https://api.example.com', + }); + }); + + it('returns defaults when environment variables are not set', () => { + delete process.env.POSTHOG_API_KEY; + delete process.env.POSTHOG_HOST; + delete process.env.REQUEST_O_MATIC_URL; + delete process.env.API_BASE_URL; + + const req = {} as Request; + const res = createMockRes(); + + getConfig(req, res as Response, jest.fn()); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + posthogApiKey: '', + posthogHost: 'https://us.i.posthog.com', + requestOMaticUrl: '', + apiBaseUrl: 'https://api.wxyc.org', + }); + }); + + it('sets Cache-Control header to public, max-age=3600', () => { + const req = {} as Request; + const res = createMockRes(); + + getConfig(req, res as Response, jest.fn()); + + expect(res.set).toHaveBeenCalledWith('Cache-Control', 'public, max-age=3600'); + }); + }); +}); diff --git a/tests/unit/controllers/proxy.controller.test.ts b/tests/unit/controllers/proxy.controller.test.ts new file mode 100644 index 00000000..dde57a4c --- /dev/null +++ b/tests/unit/controllers/proxy.controller.test.ts @@ -0,0 +1,459 @@ +/** + * Unit tests for the proxy controller. + */ +import { jest } from '@jest/globals'; +import type { Request, Response, NextFunction } from 'express'; + +// --- Mocks --- + +const mockFind = jest.fn<() => Promise<{ + artworkUrl: string | null; + releaseUrl: string | null; + album: string | null; + artist: string | null; + source: string | null; + confidence: number; +}>>(); + +jest.mock('../../../apps/backend/services/artwork/finder', () => ({ + getArtworkFinder: () => ({ find: mockFind }), +})); + +const mockFetchAlbumMetadata = jest.fn<() => Promise<{ + discogsReleaseId?: number; + discogsUrl?: string; + releaseYear?: number; + artworkUrl?: string; +} | null>>(); +const mockFetchArtistMetadataById = jest.fn<() => Promise<{ + discogsArtistId?: number; + bio?: string; + wikipediaUrl?: string; +} | null>>(); + +jest.mock('../../../apps/backend/services/metadata/providers/discogs.provider', () => ({ + DiscogsProvider: jest.fn().mockImplementation(() => ({ + fetchAlbumMetadata: mockFetchAlbumMetadata, + fetchArtistMetadataById: mockFetchArtistMetadataById, + })), +})); + +const mockGetSpotifyUrl = jest.fn<() => Promise>(); + +jest.mock('../../../apps/backend/services/metadata/providers/spotify.provider', () => ({ + SpotifyProvider: jest.fn().mockImplementation(() => ({ + getSpotifyUrl: mockGetSpotifyUrl, + })), +})); + +const mockGetAppleMusicUrl = jest.fn<() => Promise>(); + +jest.mock('../../../apps/backend/services/metadata/providers/apple.provider', () => ({ + AppleMusicProvider: jest.fn().mockImplementation(() => ({ + getAppleMusicUrl: mockGetAppleMusicUrl, + })), +})); + +jest.mock('../../../apps/backend/services/metadata/providers/search-urls.provider', () => ({ + SearchUrlProvider: jest.fn().mockImplementation(() => ({ + getAllSearchUrls: (artist: string, album?: string, track?: string) => ({ + youtubeMusicUrl: `https://music.youtube.com/search?q=${encodeURIComponent(artist)}`, + bandcampUrl: `https://bandcamp.com/search?q=${encodeURIComponent(artist)}`, + soundcloudUrl: `https://soundcloud.com/search?q=${encodeURIComponent(artist)}`, + }), + })), +})); + +const mockGetArtist = jest.fn<() => Promise<{ id: number; name: string } | null>>(); +const mockGetRelease = jest.fn<() => Promise<{ title: string } | null>>(); +const mockGetMaster = jest.fn<() => Promise<{ title: string } | null>>(); + +jest.mock('../../../apps/backend/services/discogs/discogs.service', () => ({ + DiscogsService: { + getArtist: mockGetArtist, + getRelease: mockGetRelease, + getMaster: mockGetMaster, + }, +})); + +// Mock global fetch for Spotify track endpoint +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +import { + searchArtwork, + getAlbumMetadata, + getArtistMetadata, + resolveEntity, + getSpotifyTrack, +} from '../../../apps/backend/controllers/proxy.controller'; + +// --- Helpers --- + +const createMockRes = () => { + const res: Partial = {}; + res.status = jest.fn().mockReturnValue(res) as unknown as Response['status']; + res.json = jest.fn().mockReturnValue(res) as unknown as Response['json']; + res.set = jest.fn().mockReturnValue(res) as unknown as Response['set']; + return res; +}; + +describe('proxy.controller', () => { + let mockNext: NextFunction; + + beforeEach(() => { + jest.clearAllMocks(); + mockNext = jest.fn() as unknown as NextFunction; + }); + + // --- searchArtwork --- + + describe('searchArtwork', () => { + it('returns 400 when artistName is missing', async () => { + const req = { query: {} } as unknown as Request; + const res = createMockRes(); + + await searchArtwork(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: 'artistName query parameter is required' }); + }); + + it('returns artwork result with cache header', async () => { + mockFind.mockResolvedValue({ + artworkUrl: 'https://i.discogs.com/img.jpg', + releaseUrl: 'https://discogs.com/release/123', + album: 'OK Computer', + artist: 'Radiohead', + source: 'discogs', + confidence: 0.95, + }); + + const req = { query: { artistName: 'Radiohead', releaseTitle: 'OK Computer' } } as unknown as Request; + const res = createMockRes(); + + await searchArtwork(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + artworkUrl: 'https://i.discogs.com/img.jpg', + source: 'discogs', + confidence: 0.95, + }); + expect(res.set).toHaveBeenCalledWith('Cache-Control', 'private, max-age=600'); + }); + + it('calls next on error', async () => { + const error = new Error('Service failure'); + mockFind.mockRejectedValue(error); + + const req = { query: { artistName: 'Test' } } as unknown as Request; + const res = createMockRes(); + + await searchArtwork(req, res as Response, mockNext); + + expect(mockNext).toHaveBeenCalledWith(error); + }); + }); + + // --- getAlbumMetadata --- + + describe('getAlbumMetadata', () => { + it('returns 400 when artistName is missing', async () => { + const req = { query: {} } as unknown as Request; + const res = createMockRes(); + + await getAlbumMetadata(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('returns merged metadata from all providers', async () => { + mockFetchAlbumMetadata.mockResolvedValue({ + discogsReleaseId: 12345, + discogsUrl: 'https://www.discogs.com/release/12345', + releaseYear: 1997, + artworkUrl: 'https://i.discogs.com/art.jpg', + }); + mockGetSpotifyUrl.mockResolvedValue('https://open.spotify.com/track/abc'); + mockGetAppleMusicUrl.mockResolvedValue('https://music.apple.com/album/xyz'); + + const req = { + query: { artistName: 'Radiohead', releaseTitle: 'OK Computer', trackTitle: 'Paranoid Android' }, + } as unknown as Request; + const res = createMockRes(); + + await getAlbumMetadata(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(200); + const result = (res.json as jest.Mock).mock.calls[0][0]; + expect(result.discogsReleaseId).toBe(12345); + expect(result.spotifyUrl).toBe('https://open.spotify.com/track/abc'); + expect(result.appleMusicUrl).toBe('https://music.apple.com/album/xyz'); + expect(result.youtubeMusicUrl).toContain('music.youtube.com'); + expect(result.bandcampUrl).toContain('bandcamp.com'); + expect(result.soundcloudUrl).toContain('soundcloud.com'); + expect(res.set).toHaveBeenCalledWith('Cache-Control', 'private, max-age=600'); + }); + + it('returns partial metadata when some providers fail', async () => { + mockFetchAlbumMetadata.mockRejectedValue(new Error('Discogs down')); + mockGetSpotifyUrl.mockResolvedValue('https://open.spotify.com/track/abc'); + mockGetAppleMusicUrl.mockResolvedValue(null); + + const req = { query: { artistName: 'Test Artist' } } as unknown as Request; + const res = createMockRes(); + + await getAlbumMetadata(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(200); + const result = (res.json as jest.Mock).mock.calls[0][0]; + expect(result.spotifyUrl).toBe('https://open.spotify.com/track/abc'); + expect(result.discogsReleaseId).toBeUndefined(); + }); + }); + + // --- getArtistMetadata --- + + describe('getArtistMetadata', () => { + it('returns 400 when artistId is missing', async () => { + const req = { query: {} } as unknown as Request; + const res = createMockRes(); + + await getArtistMetadata(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: 'artistId query parameter is required' }); + }); + + it('returns 400 when artistId is not a number', async () => { + const req = { query: { artistId: 'abc' } } as unknown as Request; + const res = createMockRes(); + + await getArtistMetadata(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: 'artistId must be an integer' }); + }); + + it('returns 404 when artist not found', async () => { + mockFetchArtistMetadataById.mockResolvedValue(null); + + const req = { query: { artistId: '99999' } } as unknown as Request; + const res = createMockRes(); + + await getArtistMetadata(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(404); + }); + + it('returns artist metadata with cache header', async () => { + mockFetchArtistMetadataById.mockResolvedValue({ + discogsArtistId: 456, + bio: 'A great band', + wikipediaUrl: 'https://en.wikipedia.org/wiki/Test', + }); + + const req = { query: { artistId: '456' } } as unknown as Request; + const res = createMockRes(); + + await getArtistMetadata(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + discogsArtistId: 456, + bio: 'A great band', + wikipediaUrl: 'https://en.wikipedia.org/wiki/Test', + }); + expect(res.set).toHaveBeenCalledWith('Cache-Control', 'private, max-age=3600'); + }); + }); + + // --- resolveEntity --- + + describe('resolveEntity', () => { + it('returns 400 when type or id is missing', async () => { + const req = { query: { type: 'artist' } } as unknown as Request; + const res = createMockRes(); + + await resolveEntity(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('returns 400 for invalid type', async () => { + const req = { query: { type: 'label', id: '1' } } as unknown as Request; + const res = createMockRes(); + + await resolveEntity(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('artist, release, master') }) + ); + }); + + it('returns 400 when id is not a number', async () => { + const req = { query: { type: 'artist', id: 'abc' } } as unknown as Request; + const res = createMockRes(); + + await resolveEntity(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: 'id must be an integer' }); + }); + + it('resolves an artist by ID', async () => { + mockGetArtist.mockResolvedValue({ id: 3840, name: 'Radiohead' }); + + const req = { query: { type: 'artist', id: '3840' } } as unknown as Request; + const res = createMockRes(); + + await resolveEntity(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ name: 'Radiohead', type: 'artist', id: 3840 }); + expect(res.set).toHaveBeenCalledWith('Cache-Control', 'private, max-age=86400'); + }); + + it('resolves a release by ID', async () => { + mockGetRelease.mockResolvedValue({ title: 'OK Computer' }); + + const req = { query: { type: 'release', id: '55555' } } as unknown as Request; + const res = createMockRes(); + + await resolveEntity(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ name: 'OK Computer', type: 'release', id: 55555 }); + }); + + it('resolves a master by ID', async () => { + mockGetMaster.mockResolvedValue({ title: 'Kid A' }); + + const req = { query: { type: 'master', id: '44444' } } as unknown as Request; + const res = createMockRes(); + + await resolveEntity(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ name: 'Kid A', type: 'master', id: 44444 }); + }); + + it('returns 404 when entity not found', async () => { + mockGetArtist.mockResolvedValue(null); + + const req = { query: { type: 'artist', id: '99999' } } as unknown as Request; + const res = createMockRes(); + + await resolveEntity(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(404); + }); + }); + + // --- getSpotifyTrack --- + + describe('getSpotifyTrack', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + process.env.SPOTIFY_CLIENT_ID = 'test-client-id'; + process.env.SPOTIFY_CLIENT_SECRET = 'test-client-secret'; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('returns 400 when track ID is missing', async () => { + const req = { params: {} } as unknown as Request; + const res = createMockRes(); + + await getSpotifyTrack(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('returns 503 when Spotify credentials are not configured', async () => { + delete process.env.SPOTIFY_CLIENT_ID; + delete process.env.SPOTIFY_CLIENT_SECRET; + + const req = { params: { id: 'abc123' } } as unknown as Request; + const res = createMockRes(); + + await getSpotifyTrack(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(503); + }); + + it('returns track metadata on success', async () => { + // Mock token response + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ access_token: 'mock-token', expires_in: 3600 }), + } as globalThis.Response); + + // Mock track response + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + name: 'Everything In Its Right Place', + artists: [{ name: 'Radiohead' }], + album: { + name: 'Kid A', + images: [{ url: 'https://i.scdn.co/image/abc' }], + }, + }), + } as globalThis.Response); + + const req = { params: { id: '6LgJvl0Xdtc73RJ1mN1a7A' } } as unknown as Request; + const res = createMockRes(); + + await getSpotifyTrack(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + title: 'Everything In Its Right Place', + artist: 'Radiohead', + album: 'Kid A', + artworkUrl: 'https://i.scdn.co/image/abc', + }); + expect(res.set).toHaveBeenCalledWith('Cache-Control', 'private, max-age=600'); + }); + + it('returns 404 when Spotify track not found', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ access_token: 'mock-token', expires_in: 3600 }), + } as globalThis.Response); + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + } as globalThis.Response); + + const req = { params: { id: 'nonexistent' } } as unknown as Request; + const res = createMockRes(); + + await getSpotifyTrack(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(404); + }); + + it('returns 502 when Spotify auth fails', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + } as globalThis.Response); + + const req = { params: { id: 'abc123' } } as unknown as Request; + const res = createMockRes(); + + await getSpotifyTrack(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(502); + }); + }); +});