diff --git a/apps/backend/controllers/library.controller.ts b/apps/backend/controllers/library.controller.ts index 2210e03..197d44a 100644 --- a/apps/backend/controllers/library.controller.ts +++ b/apps/backend/controllers/library.controller.ts @@ -83,6 +83,7 @@ type AlbumQueryParams = { code_letters?: string; code_artist_number?: string; code_number?: number; + genre_name?: string; n?: number; page?: number; }; @@ -103,9 +104,19 @@ export const searchForAlbum: RequestHandler = async ( 'Missing query parameter. Query must include: artist_name, album_title, or code_letters, code_artist_number, and code_number' ); } else if (query.code_letters !== undefined && query.code_artist_number !== undefined) { - //quickly look up albums by that artist - res.status(501); - res.send('TODO: Library Code Lookup'); + try { + const response = await libraryService.lookupByLibraryCode( + query.code_letters, + parseInt(query.code_artist_number), + query.code_number !== undefined ? Number(query.code_number) : undefined, + query.genre_name + ); + res.status(200).json(response); + } catch (e) { + console.error('Error: Library code lookup failed'); + console.error(e); + next(e); + } } else { try { const response = await libraryService.fuzzySearchLibrary(query.artist_name, query.album_title, query.n); @@ -283,3 +294,29 @@ export const getAlbum: RequestHandler, res, next) => { + const { body } = req; + if (body.album_id === undefined) { + res.status(400).send('Bad Request, Missing Parameter: album_id'); + } else { + try { + const fields: { label?: string; album_title?: string } = {}; + if (body.label !== undefined) fields.label = body.label; + if (body.album_title !== undefined) fields.album_title = body.album_title; + + const updated = await libraryService.updateAlbumFields(body.album_id, fields); + res.status(200).json(updated); + } catch (e) { + console.error('Error updating album'); + console.error(e); + next(e); + } + } +}; diff --git a/apps/backend/controllers/review.controller.ts b/apps/backend/controllers/review.controller.ts new file mode 100644 index 0000000..6033cd6 --- /dev/null +++ b/apps/backend/controllers/review.controller.ts @@ -0,0 +1,44 @@ +import { Request, RequestHandler } from 'express'; +import * as reviewService from '../services/review.service.js'; + +export const getReview: RequestHandler = async ( + req: Request, + res, + next +) => { + const { query } = req; + if (query.album_id === undefined) { + res.status(400).send('Bad Request, Missing Parameter: album_id'); + } else { + try { + const review = await reviewService.getReviewByAlbumId(parseInt(query.album_id)); + res.status(200).json(review ?? null); + } catch (e) { + console.error('Error retrieving review'); + console.error(e); + next(e); + } + } +}; + +type UpsertReviewBody = { + album_id?: number; + review?: string; + author?: string; +}; + +export const upsertReview: RequestHandler = async (req: Request, res, next) => { + const { body } = req; + if (body.album_id === undefined || body.review === undefined) { + res.status(400).send('Bad Request, Missing Parameters: album_id and review are required'); + } else { + try { + const result = await reviewService.upsertReview(body.album_id, body.review, body.author); + res.status(200).json(result); + } catch (e) { + console.error('Error upserting review'); + console.error(e); + next(e); + } + } +}; diff --git a/apps/backend/routes/library.route.ts b/apps/backend/routes/library.route.ts index 0d65147..6bc9f9f 100644 --- a/apps/backend/routes/library.route.ts +++ b/apps/backend/routes/library.route.ts @@ -1,6 +1,7 @@ import { requirePermissions } from '@wxyc/authentication'; import { Router } from 'express'; import * as libraryController from '../controllers/library.controller.js'; +import * as reviewController from '../controllers/review.controller.js'; import * as requestLineController from '../controllers/requestLine.controller.js'; import { requireAnonymousAuth } from '../middleware/anonymousAuth.js'; @@ -31,3 +32,9 @@ library_route.get('/genres', requirePermissions({ catalog: ['read'] }), libraryC library_route.post('/genres', requirePermissions({ catalog: ['write'] }), libraryController.addGenre); library_route.get('/info', requirePermissions({ catalog: ['read'] }), libraryController.getAlbum); + +library_route.get('/reviews', requirePermissions({ catalog: ['read'] }), reviewController.getReview); + +library_route.put('/reviews', requirePermissions({ catalog: ['write'] }), reviewController.upsertReview); + +library_route.patch('/', requirePermissions({ catalog: ['write'] }), libraryController.updateAlbum); diff --git a/apps/backend/services/library.service.ts b/apps/backend/services/library.service.ts index 8f98ff7..3beec18 100644 --- a/apps/backend/services/library.service.ts +++ b/apps/backend/services/library.service.ts @@ -1,4 +1,4 @@ -import { desc, eq, sql } from 'drizzle-orm'; +import { and, desc, eq, sql } from 'drizzle-orm'; import { RotationAddRequest } from '../controllers/library.controller.js'; import { db } from '@wxyc/database'; import { @@ -224,6 +224,45 @@ export const isISODate = (date: string): boolean => { return date.match(regex) !== null; }; +export const lookupByLibraryCode = async ( + code_letters: string, + code_artist_number: number, + code_number?: number, + genre_name?: string +): Promise => { + const conditions = [ + eq(library_artist_view.code_letters, code_letters), + eq(library_artist_view.code_artist_number, code_artist_number), + ]; + + if (code_number !== undefined) { + conditions.push(eq(library_artist_view.code_number, code_number)); + } + + if (genre_name !== undefined) { + conditions.push(eq(library_artist_view.genre_name, genre_name)); + } + + const result = await db + .select() + .from(library_artist_view) + .where(and(...conditions)); + + return result as LibraryArtistViewEntry[]; +}; + +export const updateAlbumFields = async (albumId: number, fields: { label?: string; album_title?: string }) => { + const result = await db + .update(library) + .set({ + ...fields, + last_modified: sql`now()`, + }) + .where(eq(library.id, albumId)) + .returning(); + return result[0]; +}; + // ============================================================================= // Request Line Enhanced Search Functions // ============================================================================= diff --git a/apps/backend/services/review.service.ts b/apps/backend/services/review.service.ts new file mode 100644 index 0000000..8eb3a8a --- /dev/null +++ b/apps/backend/services/review.service.ts @@ -0,0 +1,28 @@ +import { eq, sql } from 'drizzle-orm'; +import { db } from '@wxyc/database'; +import { reviews } from '@wxyc/database'; + +export const getReviewByAlbumId = async (albumId: number) => { + const result = await db.select().from(reviews).where(eq(reviews.album_id, albumId)).limit(1); + return result[0]; +}; + +export const upsertReview = async (albumId: number, review: string, author?: string) => { + const result = await db + .insert(reviews) + .values({ + album_id: albumId, + review, + author: author ?? null, + }) + .onConflictDoUpdate({ + target: reviews.album_id, + set: { + review, + author: author ?? null, + last_modified: sql`now()`, + }, + }) + .returning(); + return result[0]; +}; diff --git a/tests/integration/library.spec.js b/tests/integration/library.spec.js index f52a34d..c054cd3 100644 --- a/tests/integration/library.spec.js +++ b/tests/integration/library.spec.js @@ -69,10 +69,38 @@ describe('Library Catalog', () => { expect(res.body.length).toBe(0); }); - test('code lookup returns 501 (not implemented)', async () => { - const res = await auth.get('/library').query({ code_letters: 'BUI', code_artist_number: '1' }).expect(501); + test('code lookup returns albums matching library code', async () => { + const res = await auth.get('/library').query({ code_letters: 'BU', code_artist_number: '60' }).expect(200); - expectErrorContains(res, 'TODO'); + expectArray(res); + expect(res.body.length).toBeGreaterThan(0); + res.body.forEach((album) => { + expectFields(album, 'id', 'code_letters', 'code_artist_number', 'code_number', 'artist_name', 'album_title'); + expect(album.code_letters).toBe('BU'); + expect(album.code_artist_number).toBe(60); + }); + }); + + test('code lookup with code_number returns specific album', async () => { + const res = await auth + .get('/library') + .query({ code_letters: 'BU', code_artist_number: '60', code_number: 1 }) + .expect(200); + + expectArray(res); + expect(res.body.length).toBeLessThanOrEqual(1); + if (res.body.length > 0) { + expect(res.body[0].code_letters).toBe('BU'); + expect(res.body[0].code_artist_number).toBe(60); + expect(res.body[0].code_number).toBe(1); + } + }); + + test('code lookup returns empty array for non-existent code', async () => { + const res = await auth.get('/library').query({ code_letters: 'ZZZ', code_artist_number: '999' }).expect(200); + + expectArray(res); + expect(res.body.length).toBe(0); }); }); diff --git a/tests/mocks/database.mock.ts b/tests/mocks/database.mock.ts index 64a7e21..9f2b03e 100644 --- a/tests/mocks/database.mock.ts +++ b/tests/mocks/database.mock.ts @@ -71,6 +71,9 @@ export const genres = {}; export const format = {}; export const rotation = {}; export const library_artist_view = {}; +export const reviews = { + album_id: 'album_id', +}; export const flowsheet = { id: 'id', show_id: 'show_id', diff --git a/tests/unit/services/library.service.code-lookup.test.ts b/tests/unit/services/library.service.code-lookup.test.ts new file mode 100644 index 0000000..314c03e --- /dev/null +++ b/tests/unit/services/library.service.code-lookup.test.ts @@ -0,0 +1,201 @@ +// Mock dependencies before importing the service +jest.mock('@wxyc/database', () => ({ + db: { + select: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + update: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + execute: jest.fn().mockReturnThis(), + }, + library: {}, + artists: {}, + genres: {}, + format: {}, + rotation: {}, + library_artist_view: { + code_letters: 'code_letters', + code_artist_number: 'code_artist_number', + code_number: 'code_number', + genre_name: 'genre_name', + }, +})); + +jest.mock('drizzle-orm', () => ({ + and: jest.fn((...conditions) => ({ and: conditions })), + eq: jest.fn((a, b) => ({ eq: [a, b] })), + sql: Object.assign( + jest.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ sql: strings, values })), + { raw: jest.fn((s: string) => ({ raw: s })) } + ), + desc: jest.fn((col) => ({ desc: col })), +})); + +import { lookupByLibraryCode, updateAlbumFields } from '../../../apps/backend/services/library.service'; +import { db } from '@wxyc/database'; +import { and, eq } from 'drizzle-orm'; + +type MockDb = { + select: jest.Mock; + insert: jest.Mock; + update: jest.Mock; + from: jest.Mock; + where: jest.Mock; + limit: jest.Mock; + values: jest.Mock; + returning: jest.Mock; + set: jest.Mock; + _whereResolveValue: unknown[]; +}; + +function setUpChain() { + const mockDb = db as unknown as MockDb; + mockDb._whereResolveValue = []; + mockDb.from = jest.fn().mockReturnValue(mockDb); + mockDb.limit = jest.fn().mockResolvedValue([]); + mockDb.values = jest.fn().mockReturnValue(mockDb); + mockDb.set = jest.fn().mockReturnValue(mockDb); + mockDb.returning = jest.fn().mockResolvedValue([]); + + // where() returns a thenable that also has .returning() for chaining + // This supports both: + // await db.select().from().where() -- lookupByLibraryCode + // await db.update().set().where().returning() -- updateAlbumFields + mockDb.where = jest.fn().mockImplementation(() => ({ + then: (resolve: (val: unknown) => void, reject?: (err: unknown) => void) => + Promise.resolve(mockDb._whereResolveValue).then(resolve, reject), + returning: mockDb.returning, + })); + + mockDb.select.mockReturnValue(mockDb); + mockDb.insert.mockReturnValue(mockDb); + mockDb.update.mockReturnValue(mockDb); + + return mockDb; +} + +describe('library.service - code lookup and album update', () => { + let mockDb: MockDb; + + beforeEach(() => { + jest.clearAllMocks(); + mockDb = setUpChain(); + }); + + describe('lookupByLibraryCode', () => { + it('queries with code_letters and code_artist_number', async () => { + const mockResults = [ + { + id: 1, + code_letters: 'AB', + code_artist_number: 5, + code_number: 1, + artist_name: 'Test Artist', + album_title: 'Test Album', + genre_name: 'Rock', + format_name: 'CD', + label: 'Test Label', + }, + ]; + + mockDb._whereResolveValue = mockResults; + + const result = await lookupByLibraryCode('AB', 5); + + expect(mockDb.select).toHaveBeenCalled(); + expect(mockDb.from).toHaveBeenCalled(); + expect(mockDb.where).toHaveBeenCalled(); + expect(eq).toHaveBeenCalledWith('code_letters', 'AB'); + expect(eq).toHaveBeenCalledWith('code_artist_number', 5); + expect(and).toHaveBeenCalled(); + expect(result).toEqual(mockResults); + }); + + it('filters by code_number when provided', async () => { + mockDb._whereResolveValue = []; + + await lookupByLibraryCode('AB', 5, 3); + + expect(eq).toHaveBeenCalledWith('code_number', 3); + }); + + it('filters by genre_name when provided', async () => { + mockDb._whereResolveValue = []; + + await lookupByLibraryCode('AB', 5, undefined, 'Rock'); + + expect(eq).toHaveBeenCalledWith('genre_name', 'Rock'); + }); + + it('returns empty array when no matches found', async () => { + mockDb._whereResolveValue = []; + + const result = await lookupByLibraryCode('ZZ', 99); + + expect(result).toEqual([]); + }); + }); + + describe('updateAlbumFields', () => { + it('updates only provided fields', async () => { + const mockUpdated = { + id: 42, + album_title: 'Original Title', + label: 'New Label', + last_modified: new Date(), + }; + + mockDb.returning.mockResolvedValue([mockUpdated]); + + const result = await updateAlbumFields(42, { label: 'New Label' }); + + expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ + label: 'New Label', + }) + ); + expect(result).toEqual(mockUpdated); + }); + + it('updates album_title when provided', async () => { + const mockUpdated = { + id: 42, + album_title: 'New Title', + label: 'Some Label', + last_modified: new Date(), + }; + + mockDb.returning.mockResolvedValue([mockUpdated]); + + const result = await updateAlbumFields(42, { album_title: 'New Title' }); + + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ + album_title: 'New Title', + }) + ); + expect(result).toEqual(mockUpdated); + }); + + it('updates both label and album_title when both provided', async () => { + const mockUpdated = { + id: 42, + album_title: 'New Title', + label: 'New Label', + last_modified: new Date(), + }; + + mockDb.returning.mockResolvedValue([mockUpdated]); + + const result = await updateAlbumFields(42, { label: 'New Label', album_title: 'New Title' }); + + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ + label: 'New Label', + album_title: 'New Title', + }) + ); + expect(result).toEqual(mockUpdated); + }); + }); +}); diff --git a/tests/unit/services/review.service.test.ts b/tests/unit/services/review.service.test.ts new file mode 100644 index 0000000..127d9b1 --- /dev/null +++ b/tests/unit/services/review.service.test.ts @@ -0,0 +1,146 @@ +// Mock dependencies before importing the service +jest.mock('@wxyc/database', () => ({ + db: { + select: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + update: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + execute: jest.fn().mockReturnThis(), + }, + reviews: { album_id: 'album_id' }, +})); + +jest.mock('drizzle-orm', () => ({ + eq: jest.fn((a, b) => ({ eq: [a, b] })), + sql: Object.assign( + jest.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ sql: strings, values })), + { raw: jest.fn((s: string) => ({ raw: s })) } + ), + desc: jest.fn((col) => ({ desc: col })), +})); + +import { getReviewByAlbumId, upsertReview } from '../../../apps/backend/services/review.service'; +import { db } from '@wxyc/database'; + +// Build a chainable mock from the db mock +type MockDb = { + select: jest.Mock; + insert: jest.Mock; + update: jest.Mock; + from: jest.Mock; + where: jest.Mock; + limit: jest.Mock; + values: jest.Mock; + returning: jest.Mock; + set: jest.Mock; +}; + +function setUpChain() { + const mockDb = db as unknown as MockDb; + // Each chainable method returns the same object + mockDb.from = jest.fn().mockReturnValue(mockDb); + mockDb.where = jest.fn().mockReturnValue(mockDb); + mockDb.limit = jest.fn().mockResolvedValue([]); + mockDb.values = jest.fn().mockReturnValue(mockDb); + mockDb.set = jest.fn().mockReturnValue(mockDb); + mockDb.returning = jest.fn().mockResolvedValue([]); + + // select/insert/update return the chainable object + mockDb.select.mockReturnValue(mockDb); + mockDb.insert.mockReturnValue(mockDb); + mockDb.update.mockReturnValue(mockDb); + + return mockDb; +} + +describe('review.service', () => { + let mockDb: MockDb; + + beforeEach(() => { + jest.clearAllMocks(); + mockDb = setUpChain(); + }); + + describe('getReviewByAlbumId', () => { + it('returns a review when found', async () => { + const mockReview = { + id: 1, + album_id: 42, + review: 'Great album!', + author: 'DJ Test', + add_date: '2024-01-15', + last_modified: new Date('2024-01-15'), + }; + + mockDb.limit.mockResolvedValue([mockReview]); + + const result = await getReviewByAlbumId(42); + + expect(mockDb.select).toHaveBeenCalled(); + expect(mockDb.from).toHaveBeenCalled(); + expect(mockDb.where).toHaveBeenCalled(); + expect(mockDb.limit).toHaveBeenCalledWith(1); + expect(result).toEqual(mockReview); + }); + + it('returns undefined when no review is found', async () => { + mockDb.limit.mockResolvedValue([]); + + const result = await getReviewByAlbumId(999); + + expect(result).toBeUndefined(); + }); + }); + + describe('upsertReview', () => { + it('calls insert with onConflictDoUpdate', async () => { + const mockResult = { + id: 1, + album_id: 42, + review: 'Updated review', + author: 'DJ Updated', + add_date: '2024-01-15', + last_modified: new Date(), + }; + + const onConflictDoUpdate = jest.fn().mockReturnValue(mockDb); + mockDb.values.mockReturnValue({ onConflictDoUpdate }); + onConflictDoUpdate.mockReturnValue(mockDb); + mockDb.returning.mockResolvedValue([mockResult]); + + const result = await upsertReview(42, 'Updated review', 'DJ Updated'); + + expect(mockDb.insert).toHaveBeenCalled(); + expect(mockDb.values).toHaveBeenCalled(); + expect(onConflictDoUpdate).toHaveBeenCalled(); + expect(result).toEqual(mockResult); + }); + + it('passes null for author when not provided', async () => { + const mockResult = { + id: 1, + album_id: 42, + review: 'No author review', + author: null, + add_date: '2024-01-15', + last_modified: new Date(), + }; + + const onConflictDoUpdate = jest.fn().mockReturnValue(mockDb); + mockDb.values.mockReturnValue({ onConflictDoUpdate }); + onConflictDoUpdate.mockReturnValue(mockDb); + mockDb.returning.mockResolvedValue([mockResult]); + + const result = await upsertReview(42, 'No author review'); + + expect(mockDb.values).toHaveBeenCalledWith( + expect.objectContaining({ + album_id: 42, + review: 'No author review', + author: null, + }) + ); + expect(result).toEqual(mockResult); + }); + }); +});