diff --git a/apps/backend/controllers/djs.controller.ts b/apps/backend/controllers/djs.controller.ts index 1e160cfd..ee846b20 100644 --- a/apps/backend/controllers/djs.controller.ts +++ b/apps/backend/controllers/djs.controller.ts @@ -1,6 +1,7 @@ import { RequestHandler } from 'express'; import * as DJService from '../services/djs.service'; import { NewBinEntry } from '@wxyc/database'; +import WxycError from '../utils/error.js'; export type binBody = { dj_id: string; @@ -11,22 +12,21 @@ export type binBody = { export const addToBin: RequestHandler = async (req, res, next) => { if (req.body.album_id === undefined || req.body.dj_id === undefined) { - console.error('Bad Request, Missing Album Identifier: album_id'); - res.status(400).send('Bad Request, Missing DJ or album identifier: album_id'); - } else { - const bin_entry: NewBinEntry = { - dj_id: req.body.dj_id, - album_id: req.body.album_id, - track_title: req.body.track_title === undefined ? null : req.body.track_title, - }; - try { - const added_bin_item = await DJService.addToBin(bin_entry); - res.status(200).json(added_bin_item); - } catch (e) { - console.error('Server error: Failed to insert into bin'); - console.error(e); - next(e); - } + throw new WxycError('Bad Request, Missing DJ or album identifier: album_id', 400); + } + + const bin_entry: NewBinEntry = { + dj_id: req.body.dj_id, + album_id: req.body.album_id, + track_title: req.body.track_title === undefined ? null : req.body.track_title, + }; + try { + const added_bin_item = await DJService.addToBin(bin_entry); + res.status(200).json(added_bin_item); + } catch (e) { + console.error('Server error: Failed to insert into bin'); + console.error(e); + next(e); } }; @@ -39,64 +39,60 @@ export type binQuery = { export const deleteFromBin: RequestHandler = async (req, res, next) => { if (req.query.album_id === undefined || req.query.dj_id === undefined) { - console.error('Bad Request, Missing Bin Entry Identifier: album_id or dj_id'); - res.status(400).send('Bad Request, Missing Bin Entry Identifier: album_id or dj_id'); - } else { - try { - //check that the dj_id === dj_id of bin entry - const removed_bin_item = await DJService.removeFromBin(parseInt(req.query.album_id), req.query.dj_id); - res.status(200).json(removed_bin_item); - } catch (e) { - console.error(e); - next(e); - } + throw new WxycError('Bad Request, Missing Bin Entry Identifier: album_id or dj_id', 400); + } + + try { + //check that the dj_id === dj_id of bin entry + const removed_bin_item = await DJService.removeFromBin(parseInt(req.query.album_id), req.query.dj_id); + res.status(200).json(removed_bin_item); + } catch (e) { + console.error(e); + next(e); } }; export const getBin: RequestHandler = async (req, res, next) => { if (req.query.dj_id === undefined) { - console.error('Bad Request, Missing DJ Identifier: dj_id'); - res.status(400).send('Bad Request, Missing DJ Identifier: dj_id'); - } else { - try { - const dj_bin = await DJService.getBinFromDB(req.query.dj_id); - res.status(200).json(dj_bin); - } catch (e) { - console.error("Error: Failed to retrieve dj's bin"); - console.error(e); - next(e); - } + throw new WxycError('Bad Request, Missing DJ Identifier: dj_id', 400); + } + + try { + const dj_bin = await DJService.getBinFromDB(req.query.dj_id); + res.status(200).json(dj_bin); + } catch (e) { + console.error("Error: Failed to retrieve dj's bin"); + console.error(e); + next(e); } }; export const getPlaylistsForDJ: RequestHandler = async (req, res, next) => { if (req.query.dj_id === undefined) { - console.error('Bad Request, Missing DJ Identifier: dj_id'); - res.status(400).send('Bad Request, Missing DJ Identifier: dj_id'); - } else { - try { - const playlists = await DJService.getPlaylistsForDJ(req.query.dj_id); - res.status(200).json(playlists); - } catch (e) { - console.error('Error: Failed to retrieve playlists'); - console.error(e); - next(e); - } + throw new WxycError('Bad Request, Missing DJ Identifier: dj_id', 400); + } + + try { + const playlists = await DJService.getPlaylistsForDJ(req.query.dj_id); + res.status(200).json(playlists); + } catch (e) { + console.error('Error: Failed to retrieve playlists'); + console.error(e); + next(e); } }; export const getPlaylist: RequestHandler = async (req, res, next) => { if (req.query.playlist_id === undefined) { - console.error('Bad Request, Missing Playlist Identifier: playlist_id'); - res.status(400).send('Bad Request, Missing Playlist Identifier: playlist_id'); - } else { - try { - const playlist = await DJService.getPlaylist(parseInt(req.query.playlist_id)); - res.status(200).json(playlist); - } catch (e) { - console.error('Error: Failed to retrieve playlist'); - console.error(e); - next(e); - } + throw new WxycError('Bad Request, Missing Playlist Identifier: playlist_id', 400); + } + + try { + const playlist = await DJService.getPlaylist(parseInt(req.query.playlist_id)); + res.status(200).json(playlist); + } catch (e) { + console.error('Error: Failed to retrieve playlist'); + console.error(e); + next(e); } }; diff --git a/apps/backend/controllers/flowsheet.controller.ts b/apps/backend/controllers/flowsheet.controller.ts index 9ac09f27..92b3f3b8 100644 --- a/apps/backend/controllers/flowsheet.controller.ts +++ b/apps/backend/controllers/flowsheet.controller.ts @@ -3,6 +3,7 @@ import { Mutex } from 'async-mutex'; import { NewFSEntry, FSEntry, Show, ShowDJ, library } from '@wxyc/database'; import * as flowsheet_service from '../services/flowsheet.service.js'; import { fetchAndCacheMetadata } from '../services/metadata/index.js'; +import WxycError from '../utils/error.js'; export type QueryParams = { page?: string; @@ -165,118 +166,111 @@ export const addEntry: RequestHandler = async (req: Request console.error('[Flowsheet] Metadata fetch failed:', err)); - } - - res.status(200).json(completedEntry); - } else if ( - body.album_title === undefined || - body.artist_name === undefined || - body.track_title === undefined - ) { - console.error('Bad Request, Missing Flowsheet Parameters: album_title, artist_name, track_title'); - res.status(400).send('Bad Request, Missing Flowsheet Parameters: album_title, artist_name, track_title'); - } else { - const fsEntry: NewFSEntry = { - ...body, - show_id: latestShow.id, - }; - - const completedEntry: FSEntry = await flowsheet_service.addTrack(fsEntry); - - // Fire-and-forget: fetch metadata for this entry - if (completedEntry.artist_name) { - fetchAndCacheMetadata({ - albumId: completedEntry.album_id ?? undefined, - artistId: undefined, - rotationId: completedEntry.rotation_id ?? undefined, - artistName: completedEntry.artist_name, - albumTitle: completedEntry.album_title ?? undefined, - trackTitle: completedEntry.track_title ?? undefined, - }).catch((err) => console.error('[Flowsheet] Metadata fetch failed:', err)); - } - - res.status(200).json(completedEntry); - } - } catch (e) { - console.error('Error: Failed to add track to flowsheet'); - console.error(e); - next(e); - } + throw new WxycError('Bad Request, There are no active shows', 400); + } + + if (body.message !== undefined) { + //we're just throwing the message in there (whatever it may be): dj join event, psa event, talk set event, break-point + const fsEntry: NewFSEntry = { + artist_name: '', + album_title: '', + track_title: '', + message: body.message, + show_id: latestShow.id, + }; + try { + const completedEntry: FSEntry = await flowsheet_service.addTrack(fsEntry); + res.status(200).json(completedEntry); + } catch (e) { + console.error('Error: Failed to add message to flowsheet'); + console.error(e); + next(e); + } + return; + } + + // no message passed, so we assume we're adding a track to the flowsheet + if (body.track_title === undefined) { + throw new WxycError('Bad Request, Missing query parameter: track_title', 400); + } + + try { + if (body.album_id !== undefined) { + //backfill album info from library before adding to flowsheet + const albumInfo = await flowsheet_service.getAlbumFromDB(body.album_id); + + if (body.record_label !== undefined) { + albumInfo.record_label = body.record_label; + } + + const fsEntry: NewFSEntry = { + album_id: body.album_id, + ...albumInfo, + track_title: body.track_title, + rotation_id: body.rotation_id, + request_flag: body.request_flag, + show_id: latestShow.id, + }; + + const completedEntry: FSEntry = await flowsheet_service.addTrack(fsEntry); + + // Fire-and-forget: fetch metadata for this entry + if (completedEntry.artist_name) { + fetchAndCacheMetadata({ + albumId: completedEntry.album_id ?? undefined, + artistId: albumInfo.artist_id ?? undefined, + rotationId: completedEntry.rotation_id ?? undefined, + artistName: completedEntry.artist_name, + albumTitle: completedEntry.album_title ?? undefined, + trackTitle: completedEntry.track_title ?? undefined, + }).catch((err) => console.error('[Flowsheet] Metadata fetch failed:', err)); } + + res.status(200).json(completedEntry); + } else if (body.album_title === undefined || body.artist_name === undefined || body.track_title === undefined) { + throw new WxycError('Bad Request, Missing Flowsheet Parameters: album_title, artist_name, track_title', 400); } else { - //we're just throwing the message in there (whatever it may be): dj join event, psa event, talk set event, break-point const fsEntry: NewFSEntry = { - artist_name: '', - album_title: '', - track_title: '', - message: body.message, + ...body, show_id: latestShow.id, }; - try { - const completedEntry: FSEntry = await flowsheet_service.addTrack(fsEntry); - res.status(200).json(completedEntry); - } catch (e) { - console.error('Error: Failed to add message to flowsheet'); - console.error(e); - next(e); + + const completedEntry: FSEntry = await flowsheet_service.addTrack(fsEntry); + + // Fire-and-forget: fetch metadata for this entry + if (completedEntry.artist_name) { + fetchAndCacheMetadata({ + albumId: completedEntry.album_id ?? undefined, + artistId: undefined, + rotationId: completedEntry.rotation_id ?? undefined, + artistName: completedEntry.artist_name, + albumTitle: completedEntry.album_title ?? undefined, + trackTitle: completedEntry.track_title ?? undefined, + }).catch((err) => console.error('[Flowsheet] Metadata fetch failed:', err)); } + + res.status(200).json(completedEntry); } + } catch (e) { + console.error('Error: Failed to add track to flowsheet'); + console.error(e); + next(e); } }; export const deleteEntry: RequestHandler = async (req, res, next) => { const { entry_id } = req.body; if (entry_id === undefined) { - console.error('Bad Request, Missing entry identifier: entry_id'); - res.status(400).send('Bad Request, Missing entry identifier: entry_id'); - } else { - try { - const removedEntry: FSEntry = await flowsheet_service.removeTrack(entry_id); - res.status(200).json(removedEntry); - } catch (e) { - console.error('Error: Failed to remove entry'); - console.error(e); - next(e); - } + throw new WxycError('Bad Request, Missing entry identifier: entry_id', 400); + } + + try { + const removedEntry: FSEntry = await flowsheet_service.removeTrack(entry_id); + res.status(200).json(removedEntry); + } catch (e) { + console.error('Error: Failed to remove entry'); + console.error(e); + next(e); } }; @@ -296,17 +290,16 @@ export const updateEntry: RequestHandler { const { entry_id, data } = req.body; if (entry_id === undefined) { - console.error('Bad Request, Missing entry identifier: entry_id'); - res.status(400).send('Bad Request, Missing entry identifier: entry_id'); - } else { - try { - const updatedEntry: FSEntry = await flowsheet_service.updateEntry(entry_id, data); - res.status(200).json(updatedEntry); - } catch (e) { - console.error('Error: Failed to update entry'); - console.error(e); - next(e); - } + throw new WxycError('Bad Request, Missing entry identifier: entry_id', 400); + } + + try { + const updatedEntry: FSEntry = await flowsheet_service.updateEntry(entry_id, data); + res.status(200).json(updatedEntry); + } catch (e) { + console.error('Error: Failed to update entry'); + console.error(e); + next(e); } }; @@ -320,8 +313,10 @@ export type JoinRequestBody = { export const joinShow: RequestHandler = async (req: Request, res, next) => { const current_show = await flowsheet_service.getLatestShow(); if (req.body.dj_id === undefined) { - res.status(400).send('Bad Request, Must include a dj_id to join show'); - } else if (current_show?.end_time !== null) { + throw new WxycError('Bad Request, Must include a dj_id to join show', 400); + } + + if (current_show?.end_time !== null) { try { const show_session: Show = await flowsheet_service.startShow( req.body.dj_id, diff --git a/apps/backend/controllers/library.controller.ts b/apps/backend/controllers/library.controller.ts index 5af246c8..5faa4a92 100644 --- a/apps/backend/controllers/library.controller.ts +++ b/apps/backend/controllers/library.controller.ts @@ -10,6 +10,7 @@ import { RotationRelease, } from '@wxyc/database'; import * as libraryService from '../services/library.service.js'; +import WxycError from '../utils/error.js'; type NewAlbumRequest = { album_title: string; @@ -33,48 +34,45 @@ export const addAlbum: RequestHandler = async (req: Request { const { query } = req; if (!query.code_letters || !query.genre_id) { - res.status(400); - res.send('Missing query parameters: code_letters and genre_id'); - return; + throw new WxycError('Missing query parameters: code_letters and genre_id', 400); } const genreId = Number(query.genre_id); if (!Number.isFinite(genreId)) { - res.status(400); - res.send('Invalid genre_id'); - return; + throw new WxycError('Invalid genre_id', 400); } try { @@ -217,15 +210,15 @@ export const getRotation: RequestHandler = async (req, res, next) => { export type RotationAddRequest = Omit; export const addRotation: RequestHandler = async (req, res, next) => { if (req.body.album_id === undefined || req.body.rotation_bin === undefined) { - res.status(400).send('Missing Parameters: album_id or rotation_bin'); - } else { - try { - const rotationRelease: RotationRelease = await libraryService.addToRotation(req.body); - res.status(200).json(rotationRelease); - } catch (e) { - console.error(e); - next(e); - } + throw new WxycError('Missing Parameters: album_id or rotation_bin', 400); + } + + try { + const rotationRelease: RotationRelease = await libraryService.addToRotation(req.body); + res.status(200).json(rotationRelease); + } catch (e) { + console.error(e); + next(e); } }; @@ -238,22 +231,23 @@ export const killRotation: RequestHandler const { body } = req; if (body.rotation_id === undefined) { - res.status(400).send('Bad Request, Missing Parameter: rotation_id'); - } else if (body.kill_date !== undefined && !libraryService.isISODate(body.kill_date)) { - res.status(400).send('Bad Request, Incorrect Date Format: kill_date should be of form YYYY-MM-DD'); - } else { - try { - const updatedRotation: RotationRelease = await libraryService.killRotationInDB(body.rotation_id, body.kill_date); - if (updatedRotation !== undefined) { - res.status(200).json(updatedRotation); - } else { - res.status(400).json({ status: 400, message: 'Rotation entry not found' }); - } - } catch (e) { - console.error('Failed to update rotation kill_date'); - console.error(e); - next(e); + throw new WxycError('Bad Request, Missing Parameter: rotation_id', 400); + } + if (body.kill_date !== undefined && !libraryService.isISODate(body.kill_date)) { + throw new WxycError('Bad Request, Incorrect Date Format: kill_date should be of form YYYY-MM-DD', 400); + } + + try { + const updatedRotation: RotationRelease = await libraryService.killRotationInDB(body.rotation_id, body.kill_date); + if (updatedRotation !== undefined) { + res.status(200).json(updatedRotation); + } else { + throw new WxycError('Rotation entry not found', 400); } + } catch (e) { + console.error('Failed to update rotation kill_date'); + console.error(e); + next(e); } }; @@ -271,20 +265,20 @@ export const getFormats: RequestHandler = async (req, res, next) => { export const addFormat: RequestHandler = async (req, res, next) => { const { body } = req; if (body.name === undefined) { - res.status(400).send('Bad Request, Missing Parameter: name'); - } else { - try { - const newFormat: NewAlbumFormat = { - format_name: body.name, - }; + throw new WxycError('Bad Request, Missing Parameter: name', 400); + } - const insertion = await libraryService.insertFormat(newFormat); - res.status(200).json(insertion); - } catch (e) { - console.error('Failed to add new format'); - console.error(e); - next(e); - } + try { + const newFormat: NewAlbumFormat = { + format_name: body.name, + }; + + const insertion = await libraryService.insertFormat(newFormat); + res.status(200).json(insertion); + } catch (e) { + console.error('Failed to add new format'); + console.error(e); + next(e); } }; @@ -296,41 +290,40 @@ export const getGenres: RequestHandler = async (req, res) => { export const addGenre: RequestHandler = async (req, res, next) => { const { body } = req; if (body.name === undefined || body.description === undefined) { - res.status(400).send('Bad Request, Parameters name and description are required.'); - } else { - try { - const newGenre: NewGenre = { - genre_name: body.name, - description: body.description, - plays: 0, - add_date: new Date().toISOString(), - last_modified: new Date(), - }; + throw new WxycError('Bad Request, Parameters name and description are required.', 400); + } - const insertion = await libraryService.insertGenre(newGenre); + try { + const newGenre: NewGenre = { + genre_name: body.name, + description: body.description, + plays: 0, + add_date: new Date().toISOString(), + last_modified: new Date(), + }; - res.status(200).json(insertion); - } catch (e) { - console.error('Failed to add new genre'); - console.error(e); - next(e); - } + const insertion = await libraryService.insertGenre(newGenre); + + res.status(200).json(insertion); + } catch (e) { + console.error('Failed to add new genre'); + console.error(e); + next(e); } }; export const getAlbum: RequestHandler = async (req, res, next) => { const { query } = req; if (query.album_id === undefined) { - console.error('Bad Request, missing album identifier: album_id'); - res.status(400).send('Bad Request, missing album identifier: album_id'); - } else { - try { - const album = await libraryService.getAlbumFromDB(parseInt(query.album_id)); - res.status(200).json(album); - } catch (e) { - console.error('Failed to retrieve album'); - console.error(e); - next(e); - } + throw new WxycError('Bad Request, missing album identifier: album_id', 400); + } + + try { + const album = await libraryService.getAlbumFromDB(parseInt(query.album_id)); + res.status(200).json(album); + } catch (e) { + console.error('Failed to retrieve album'); + console.error(e); + next(e); } }; diff --git a/apps/backend/middleware/errorHandler.ts b/apps/backend/middleware/errorHandler.ts index e10636b0..6b266b6d 100644 --- a/apps/backend/middleware/errorHandler.ts +++ b/apps/backend/middleware/errorHandler.ts @@ -10,7 +10,8 @@ function errorHandler(err: any, req: Request, res: Response, next: NextFunction) if (error instanceof WxycError) { res.status(error.statusCode).json({ message: error.message }); } else { - res.status(500).json({ message: error.message }); + console.error('Unhandled error:', error); + res.status(500).json({ message: 'Internal server error' }); } } diff --git a/tests/unit/middleware/errorHandler.test.ts b/tests/unit/middleware/errorHandler.test.ts new file mode 100644 index 00000000..01b566b0 --- /dev/null +++ b/tests/unit/middleware/errorHandler.test.ts @@ -0,0 +1,58 @@ +import errorHandler from '../../../apps/backend/middleware/errorHandler'; +import WxycError from '../../../apps/backend/utils/error'; +import { Request, Response, NextFunction } from 'express'; + +function mockResponse() { + const statusMock = jest.fn().mockReturnThis(); + const jsonMock = jest.fn().mockReturnThis(); + const res = { + status: statusMock, + json: jsonMock, + } as unknown as Response; + return { res, statusMock, jsonMock }; +} + +const mockReq = {} as Request; +const mockNext = jest.fn() as NextFunction; + +describe('errorHandler middleware', () => { + it('returns { message } with correct status for WxycError', () => { + const { res, statusMock, jsonMock } = mockResponse(); + const error = new WxycError('Album not found', 404); + + errorHandler(error, mockReq, res, mockNext); + + expect(statusMock).toHaveBeenCalledWith(404); + expect(jsonMock).toHaveBeenCalledWith({ message: 'Album not found' }); + }); + + it('returns generic message for non-WxycError (does not leak internals)', () => { + const { res, statusMock, jsonMock } = mockResponse(); + const error = new Error('SELECT * FROM users failed: connection refused'); + + errorHandler(error, mockReq, res, mockNext); + + expect(statusMock).toHaveBeenCalledWith(500); + expect(jsonMock).toHaveBeenCalledWith({ message: 'Internal server error' }); + }); + + it('handles non-Error values thrown', () => { + const { res, statusMock, jsonMock } = mockResponse(); + + errorHandler('something broke', mockReq, res, mockNext); + + expect(statusMock).toHaveBeenCalledWith(500); + expect(jsonMock).toHaveBeenCalledWith({ message: 'Internal server error' }); + }); + + it('logs non-WxycError errors to console', () => { + const { res } = mockResponse(); + const error = new Error('db connection lost'); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + errorHandler(error, mockReq, res, mockNext); + + expect(consoleSpy).toHaveBeenCalledWith('Unhandled error:', error); + consoleSpy.mockRestore(); + }); +});