diff --git a/apps/backend/app.yaml b/apps/backend/app.yaml index c727223a..a98225ea 100644 --- a/apps/backend/app.yaml +++ b/apps/backend/app.yaml @@ -49,7 +49,7 @@ components: type: integer show_id: type: integer - rotation_play_freq: + rotation_bin: type: string message: type: string @@ -183,7 +183,7 @@ components: type: string genre_name: type: string - play_freq: + rotation_bin: type: string nullable: true add_date: @@ -219,7 +219,7 @@ components: add_date: type: string format: date - play_freq: + rotation_bin: type: string enum: ['S', 'L', 'M', 'H'] kill_date: @@ -231,11 +231,11 @@ components: NewRotationRequest: type: object - required: ['album_id', 'play_freq'] + required: ['album_id', 'rotation_bin'] properties: album_id: type: integer - play_freq: + rotation_bin: type: string enum: ['S', 'L', 'M', 'H'] kill_date: diff --git a/apps/backend/controllers/flowsheet.controller.ts b/apps/backend/controllers/flowsheet.controller.ts index 6a5adc66..fd309cd3 100644 --- a/apps/backend/controllers/flowsheet.controller.ts +++ b/apps/backend/controllers/flowsheet.controller.ts @@ -28,7 +28,7 @@ export interface IFSEntryMetadata { } export interface IFSEntry extends FSEntry { - rotation_play_freq: string | null; + rotation_bin: string | null; metadata: IFSEntryMetadata; } @@ -406,9 +406,12 @@ export const changeOrder: RequestHandler = async (req, res, next) => { + const page = parseInt(req.query.page ?? '0'); + const limit = parseInt(req.query.limit ?? '30'); + + if (isNaN(limit) || limit < 1) { + res.status(400).json({ message: 'limit must be a positive number' }); + return; + } + + if (limit > MAX_ITEMS) { + res.status(400).json({ message: 'Requested too many entries' }); + return; + } + + if (isNaN(page) || page < 0) { + res.status(400).json({ message: 'page must be a non-negative number' }); + return; + } + + try { + const offset = page * limit; + const [entries, total] = await Promise.all([ + flowsheet_service.getEntriesByPage(offset, limit), + flowsheet_service.getEntryCount(), + ]); + + const totalPages = Math.ceil(total / limit); + + res.status(200).json({ + entries: entries.map(flowsheet_service.transformToV2), + total, + page, + limit, + totalPages, + }); + } catch (e) { + console.error('Error: Failed to retrieve V2 flowsheet entries'); + console.error(e); + next(e); + } +}; + +/** + * GET /v2/flowsheet/latest + * Single latest flowsheet entry in V2 discriminated union format. + */ +export const getLatest: RequestHandler = async (req, res, next) => { + try { + const entries = await flowsheet_service.getEntriesByPage(0, 1); + if (entries.length) { + res.status(200).json(flowsheet_service.transformToV2(entries[0])); + } else { + res.status(404).json({ message: 'No entries found' }); + } + } catch (e) { + console.error('Error: Failed to retrieve latest V2 flowsheet entry'); + console.error(e); + next(e); + } +}; + /** * GET /v2/flowsheet/playlist - * Get show info with entries in discriminated union format + * Show info with entries in V2 discriminated union format. */ export const getShowInfo: RequestHandler = async (req, res, next) => { const showId = parseInt(req.query.show_id); @@ -14,16 +86,14 @@ export const getShowInfo: RequestHandler - flowsheet_service.transformToV2(entry as Parameters[0]) - ); + const [showMetadata, ifsEntries] = await Promise.all([ + flowsheet_service.getShowMetadata(showId), + flowsheet_service.getEntriesByShow(showId), + ]); res.status(200).json({ - ...showInfo, - entries: v2Entries, + ...showMetadata, + entries: ifsEntries.map(flowsheet_service.transformToV2), }); } catch (e) { console.error('Error: Failed to retrieve playlist'); diff --git a/apps/backend/controllers/library.controller.ts b/apps/backend/controllers/library.controller.ts index 587628b5..2210e03a 100644 --- a/apps/backend/controllers/library.controller.ts +++ b/apps/backend/controllers/library.controller.ts @@ -165,8 +165,8 @@ 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.play_freq === undefined) { - res.status(400).send('Missing Parameters: album_id or play_freq'); + 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); diff --git a/apps/backend/routes/flowsheet.v2.route.ts b/apps/backend/routes/flowsheet.v2.route.ts index 7bab772e..0a58aa6c 100644 --- a/apps/backend/routes/flowsheet.v2.route.ts +++ b/apps/backend/routes/flowsheet.v2.route.ts @@ -3,5 +3,11 @@ import * as flowsheetV2Controller from '../controllers/flowsheet.v2.controller.j export const flowsheet_v2_route = Router(); +// V2 paginated entries in discriminated union format +flowsheet_v2_route.get('/', flowsheetV2Controller.getEntries); + +// V2 latest entry in discriminated union format +flowsheet_v2_route.get('/latest', flowsheetV2Controller.getLatest); + // V2 playlist returns entries in discriminated union format flowsheet_v2_route.get('/playlist', flowsheetV2Controller.getShowInfo); diff --git a/apps/backend/services/flowsheet.service.ts b/apps/backend/services/flowsheet.service.ts index 6bae271c..41f3f33d 100644 --- a/apps/backend/services/flowsheet.service.ts +++ b/apps/backend/services/flowsheet.service.ts @@ -18,7 +18,7 @@ import { album_metadata, artist_metadata, } from '@wxyc/database'; -import { IFSEntry, ShowInfo, UpdateRequestBody } from '../controllers/flowsheet.controller.js'; +import { IFSEntry, ShowInfo, ShowMetadata, UpdateRequestBody } from '../controllers/flowsheet.controller.js'; import { PgSelectQueryBuilder, QueryBuilder } from 'drizzle-orm/pg-core'; // Track when the flowsheet was last modified for conditional responses (304 Not Modified) @@ -51,7 +51,7 @@ const FSEntryFieldsRaw = { track_title: flowsheet.track_title, record_label: flowsheet.record_label, rotation_id: flowsheet.rotation_id, - rotation_play_freq: rotation.play_freq, + rotation_bin: rotation.rotation_bin, request_flag: flowsheet.request_flag, message: flowsheet.message, play_order: flowsheet.play_order, @@ -81,7 +81,7 @@ type FSEntryRaw = { track_title: string | null; record_label: string | null; rotation_id: number | null; - rotation_play_freq: string | null; + rotation_bin: string | null; request_flag: boolean | null; message: string | null; play_order: number | null; @@ -109,7 +109,7 @@ const transformToIFSEntry = (raw: FSEntryRaw): IFSEntry => ({ track_title: raw.track_title, record_label: raw.record_label, rotation_id: raw.rotation_id, - rotation_play_freq: raw.rotation_play_freq, + rotation_bin: raw.rotation_bin, request_flag: raw.request_flag ?? false, message: raw.message, play_order: raw.play_order ?? 0, @@ -128,6 +128,14 @@ const transformToIFSEntry = (raw: FSEntryRaw): IFSEntry => ({ }, }); +/** Count total flowsheet entries (for pagination) */ +export const getEntryCount = async (): Promise => { + const result = await db + .select({ count: sql`count(*)::int` }) + .from(flowsheet); + return result[0].count; +}; + /** Gets flowsheet entries by page with metadata joins */ export const getEntriesByPage = async (offset: number, limit: number): Promise => { const raw = await db @@ -557,13 +565,12 @@ export const changeOrder = async (entry_id: number, position_new: number): Promi return response[0]; }; -export const getPlaylist = async (show_id: number): Promise => { +/** Gets show metadata (DJs, specialty show name) without fetching entries */ +export const getShowMetadata = async (show_id: number): Promise => { const show = await db.select().from(shows).where(eq(shows.id, show_id)); const showDJs = (await getDJsInShow(show_id, false)).map((dj) => ({ id: dj.id, dj_name: dj.djName || dj.name })); - const entries = await db.select().from(flowsheet).where(eq(flowsheet.show_id, show_id)); - let specialty_show_name = ''; if (show[0].specialty_id != null) { const specialty_show = await db.select().from(specialty_shows).where(eq(specialty_shows.id, show[0].specialty_id)); @@ -574,7 +581,18 @@ export const getPlaylist = async (show_id: number): Promise => { ...show[0], specialty_show_name: specialty_show_name, show_djs: showDJs, - entries: entries, + }; +}; + +export const getPlaylist = async (show_id: number): Promise => { + const [metadata, entries] = await Promise.all([ + getShowMetadata(show_id), + db.select().from(flowsheet).where(eq(flowsheet.show_id, show_id)), + ]); + + return { + ...metadata, + entries, }; }; @@ -602,7 +620,7 @@ export const transformToV2 = (entry: IFSEntry): Record => { track_title: entry.track_title, record_label: entry.record_label, request_flag: entry.request_flag, - rotation_play_freq: entry.rotation_play_freq, + rotation_bin: entry.rotation_bin, artwork_url: entry.metadata?.artwork_url ?? null, discogs_url: entry.metadata?.discogs_url ?? null, release_year: entry.metadata?.release_year ?? null, diff --git a/apps/backend/services/library.service.ts b/apps/backend/services/library.service.ts index b6c82fb9..8f98ff7e 100644 --- a/apps/backend/services/library.service.ts +++ b/apps/backend/services/library.service.ts @@ -44,7 +44,7 @@ export interface Rotation { rotation_id: number; add_date: Date; rotation_add_date: string; - play_freq: 'S' | 'L' | 'M' | 'H'; + rotation_bin: 'S' | 'L' | 'M' | 'H'; rotation_kill_date: string | null; plays: number; } @@ -64,7 +64,7 @@ export const getRotationFromDB = async (): Promise => { rotation_id: rotation.id, add_date: library.add_date, rotation_add_date: rotation.add_date, - play_freq: rotation.play_freq, + rotation_bin: rotation.rotation_bin, rotation_kill_date: rotation.kill_date, plays: library.plays, }) diff --git a/dev_env/seed_db.sql b/dev_env/seed_db.sql index ab475239..75aa521f 100644 --- a/dev_env/seed_db.sql +++ b/dev_env/seed_db.sql @@ -156,7 +156,7 @@ INSERT INTO wxyc_schema.library( artist_id, genre_id, format_id, album_title, code_number) VALUES (6, 1, 2, 'Homogenic', 1); -INSERT INTO wxyc_schema.rotation(album_id, play_freq) VALUES (1, 'L'); +INSERT INTO wxyc_schema.rotation(album_id, rotation_bin) VALUES (1, 'L'); -- Add album 4 to rotation for metadata.spec.js rotation tests -INSERT INTO wxyc_schema.rotation(album_id, play_freq) VALUES (4, 'M'); \ No newline at end of file +INSERT INTO wxyc_schema.rotation(album_id, rotation_bin) VALUES (4, 'M'); \ No newline at end of file diff --git a/shared/database/src/migrations/0029_rename_play_freq_to_rotation_bin.sql b/shared/database/src/migrations/0029_rename_play_freq_to_rotation_bin.sql new file mode 100644 index 00000000..f7323625 --- /dev/null +++ b/shared/database/src/migrations/0029_rename_play_freq_to_rotation_bin.sql @@ -0,0 +1,47 @@ +-- Rename play_freq column to rotation_bin on the rotation table. +-- The enum type (freq_enum) stays the same; only the column name changes. + +-- Drop views that reference the column +DROP VIEW IF EXISTS "wxyc_schema"."library_artist_view"; +--> statement-breakpoint +DROP VIEW IF EXISTS "wxyc_schema"."rotation_library_view"; +--> statement-breakpoint + +-- Rename the column +ALTER TABLE "wxyc_schema"."rotation" RENAME COLUMN "play_freq" TO "rotation_bin"; +--> statement-breakpoint + +-- Recreate library_artist_view with the new column name +CREATE VIEW "wxyc_schema"."library_artist_view" AS +SELECT "wxyc_schema"."library"."id", + "wxyc_schema"."artists"."code_letters", + "wxyc_schema"."artists"."code_artist_number", + "wxyc_schema"."library"."code_number", + "wxyc_schema"."artists"."artist_name", + "wxyc_schema"."library"."album_title", + "wxyc_schema"."format"."format_name", + "wxyc_schema"."genres"."genre_name", + "wxyc_schema"."rotation"."rotation_bin", + "wxyc_schema"."library"."add_date", + "wxyc_schema"."library"."label" +FROM "wxyc_schema"."library" + INNER JOIN "wxyc_schema"."artists" ON "wxyc_schema"."artists"."id" = "wxyc_schema"."library"."artist_id" + INNER JOIN "wxyc_schema"."format" ON "wxyc_schema"."format"."id" = "wxyc_schema"."library"."format_id" + INNER JOIN "wxyc_schema"."genres" ON "wxyc_schema"."genres"."id" = "wxyc_schema"."library"."genre_id" + LEFT JOIN "wxyc_schema"."rotation" + ON "wxyc_schema"."rotation"."album_id" = "wxyc_schema"."library"."id" + AND ("wxyc_schema"."rotation"."kill_date" < CURRENT_DATE OR "wxyc_schema"."rotation"."kill_date" IS NULL); +--> statement-breakpoint + +-- Recreate rotation_library_view with the new column name +CREATE VIEW "wxyc_schema"."rotation_library_view" AS +SELECT "wxyc_schema"."library"."id", + "wxyc_schema"."rotation"."id" AS "rotation_id", + "wxyc_schema"."library"."label", + "wxyc_schema"."rotation"."rotation_bin", + "wxyc_schema"."library"."album_title", + "wxyc_schema"."artists"."artist_name", + "wxyc_schema"."rotation"."kill_date" +FROM "wxyc_schema"."library" + INNER JOIN "wxyc_schema"."rotation" ON "wxyc_schema"."library"."id" = "wxyc_schema"."rotation"."album_id" + INNER JOIN "wxyc_schema"."artists" ON "wxyc_schema"."artists"."id" = "wxyc_schema"."library"."artist_id"; diff --git a/shared/database/src/schema.ts b/shared/database/src/schema.ts index f17f2b36..7a9bfc5e 100644 --- a/shared/database/src/schema.ts +++ b/shared/database/src/schema.ts @@ -281,7 +281,7 @@ export const rotation = wxyc_schema.table( album_id: integer('album_id') .references(() => library.id) .notNull(), - play_freq: freqEnum('play_freq').notNull(), + rotation_bin: freqEnum('rotation_bin').notNull(), add_date: date('add_date').defaultNow().notNull(), kill_date: date('kill_date'), }, @@ -419,7 +419,7 @@ export type LibraryArtistViewEntry = { album_title: string; format_name: string; genre_name: string; - play_freq: string | null; + rotation_bin: string | null; add_date: Date; label: string | null; }; @@ -434,7 +434,7 @@ export const library_artist_view = wxyc_schema.view('library_artist_view').as((q album_title: library.album_title, format_name: format.format_name, genre_name: genres.genre_name, - play_freq: rotation.play_freq, + rotation_bin: rotation.rotation_bin, add_date: library.add_date, label: library.label, }) @@ -454,7 +454,7 @@ export const rotation_library_view = wxyc_schema.view('rotation_library_view').a library_id: library.id, rotation_id: rotation.id, label: library.label, - play_freq: rotation.play_freq, + rotation_bin: rotation.rotation_bin, album_title: library.album_title, artist_name: artists.artist_name, kill_date: rotation.kill_date, diff --git a/shared/database/src/types/flowsheet.types.ts b/shared/database/src/types/flowsheet.types.ts index 7a8cf263..90f36f1e 100644 --- a/shared/database/src/types/flowsheet.types.ts +++ b/shared/database/src/types/flowsheet.types.ts @@ -40,7 +40,7 @@ export interface TrackEntryV2 extends BaseEntry { track_title: string | null; record_label: string | null; request_flag: boolean; - rotation_play_freq: string | null; + rotation_rotation_bin: string | null; // Album metadata from cache artwork_url: string | null; discogs_url: string | null; diff --git a/tests/integration/library.spec.js b/tests/integration/library.spec.js index d8eb002c..f52a34df 100644 --- a/tests/integration/library.spec.js +++ b/tests/integration/library.spec.js @@ -183,7 +183,7 @@ describe('Library Rotation', () => { const res = await auth.get('/library/rotation').expect(200); if (res.body.length > 0) { - expectFields(res.body[0], 'id', 'artist_name', 'album_title', 'play_freq', 'rotation_id'); + expectFields(res.body[0], 'id', 'artist_name', 'album_title', 'rotation_bin', 'rotation_id'); } }); }); @@ -194,13 +194,13 @@ describe('Library Rotation', () => { .post('/library/rotation') .send({ album_id: 2, - play_freq: 'M', + rotation_bin: 'M', }) .expect(200); - expectFields(res.body, 'id', 'album_id', 'play_freq'); + expectFields(res.body, 'id', 'album_id', 'rotation_bin'); expect(res.body.album_id).toBe(2); - expect(res.body.play_freq).toBe('M'); + expect(res.body.rotation_bin).toBe('M'); // Clean up if (res.body.id) { @@ -212,14 +212,14 @@ describe('Library Rotation', () => { const res = await auth .post('/library/rotation') .send({ - play_freq: 'M', + rotation_bin: 'M', }) .expect(400); expectErrorContains(res, 'Missing Parameters'); }); - test('returns 400 when play_freq is missing', async () => { + test('returns 400 when rotation_bin is missing', async () => { const res = await auth .post('/library/rotation') .send({ @@ -237,7 +237,7 @@ describe('Library Rotation', () => { beforeEach(async () => { const res = await auth.post('/library/rotation').send({ album_id: 3, - play_freq: 'L', + rotation_bin: 'L', }); if (res.body && res.body.id) { @@ -260,7 +260,7 @@ describe('Library Rotation', () => { test('kills rotation with specific date', async () => { const createRes = await auth.post('/library/rotation').send({ album_id: 3, - play_freq: 'H', + rotation_bin: 'H', }); if (createRes.body && createRes.body.id) { diff --git a/tests/integration/metadata.spec.js b/tests/integration/metadata.spec.js index 7a861df3..dae93993 100644 --- a/tests/integration/metadata.spec.js +++ b/tests/integration/metadata.spec.js @@ -267,7 +267,7 @@ describe('Metadata with Rotation Entries', () => { await fls_util.leave_show(getTestDjId(), global.access_token); }); - test('Rotation entries include rotation_play_freq', async () => { + test('Rotation entries include rotation_rotation_bin', async () => { // Add a track with rotation_id (album_id 4 is in rotation per seed with rotation_id 2) const addRes = await request .post('/flowsheet') @@ -285,7 +285,7 @@ describe('Metadata with Rotation Entries', () => { expect(rotationEntry).toBeDefined(); expect(rotationEntry.rotation_id).toEqual(2); - expect(rotationEntry.rotation_play_freq).toEqual('M'); // From seed data + expect(rotationEntry.rotation_rotation_bin).toEqual('M'); // From seed data }); }); diff --git a/tests/unit/controllers/flowsheet.v2.controller.test.ts b/tests/unit/controllers/flowsheet.v2.controller.test.ts new file mode 100644 index 00000000..f5db768d --- /dev/null +++ b/tests/unit/controllers/flowsheet.v2.controller.test.ts @@ -0,0 +1,324 @@ +import { jest } from '@jest/globals'; +import type { Request, Response, NextFunction } from 'express'; + +// Mock the service module +const mockGetEntriesByPage = jest.fn<() => Promise>(); +const mockGetEntryCount = jest.fn<() => Promise>(); +const mockGetEntriesByShow = jest.fn<() => Promise>(); +const mockGetShowMetadata = jest.fn<() => Promise>>(); +const mockTransformToV2 = jest.fn((entry: unknown) => ({ ...entry as Record, v2: true })); + +jest.mock('../../../apps/backend/services/flowsheet.service', () => ({ + getEntriesByPage: mockGetEntriesByPage, + getEntryCount: mockGetEntryCount, + getEntriesByShow: mockGetEntriesByShow, + getShowMetadata: mockGetShowMetadata, + transformToV2: mockTransformToV2, +})); + +import { getEntries, getLatest, getShowInfo } from '../../../apps/backend/controllers/flowsheet.v2.controller'; + +// Helper to create mock Express req/res/next +const createMockReq = (query: Record = {}): Partial => ({ + query, +}); + +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']; + return res; +}; + +const createMockEntry = (id: number) => ({ + id, + show_id: 1, + entry_type: 'track', + play_order: id, + add_time: new Date(), +}); + +describe('flowsheet.v2.controller', () => { + let mockNext: NextFunction; + + beforeEach(() => { + jest.clearAllMocks(); + mockNext = jest.fn() as unknown as NextFunction; + }); + + describe('getEntries', () => { + it('returns paginated entries with defaults (page=0, limit=30)', async () => { + const entries = [createMockEntry(1), createMockEntry(2)]; + mockGetEntriesByPage.mockResolvedValue(entries); + mockGetEntryCount.mockResolvedValue(2); + + const req = createMockReq(); + const res = createMockRes(); + + await getEntries(req as Request, res as Response, mockNext); + + expect(mockGetEntriesByPage).toHaveBeenCalledWith(0, 30); + expect(mockGetEntryCount).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + entries: entries.map(e => ({ ...e, v2: true })), + total: 2, + page: 0, + limit: 30, + totalPages: 1, + }); + }); + + it('calculates offset from page and limit', async () => { + mockGetEntriesByPage.mockResolvedValue([]); + mockGetEntryCount.mockResolvedValue(100); + + const req = createMockReq({ page: '3', limit: '10' }); + const res = createMockRes(); + + await getEntries(req as Request, res as Response, mockNext); + + expect(mockGetEntriesByPage).toHaveBeenCalledWith(30, 10); + }); + + it('calculates totalPages correctly', async () => { + mockGetEntriesByPage.mockResolvedValue([]); + mockGetEntryCount.mockResolvedValue(25); + + const req = createMockReq({ limit: '10' }); + const res = createMockRes(); + + await getEntries(req as Request, res as Response, mockNext); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ totalPages: 3 }), + ); + }); + + it('returns totalPages=0 when no entries exist', async () => { + mockGetEntriesByPage.mockResolvedValue([]); + mockGetEntryCount.mockResolvedValue(0); + + const req = createMockReq(); + const res = createMockRes(); + + await getEntries(req as Request, res as Response, mockNext); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ entries: [], total: 0, totalPages: 0 }), + ); + }); + + it('transforms each entry through transformToV2', async () => { + const entries = [createMockEntry(1), createMockEntry(2), createMockEntry(3)]; + mockGetEntriesByPage.mockResolvedValue(entries); + mockGetEntryCount.mockResolvedValue(3); + + const req = createMockReq(); + const res = createMockRes(); + + await getEntries(req as Request, res as Response, mockNext); + + expect(mockTransformToV2).toHaveBeenCalledTimes(3); + expect(mockTransformToV2).toHaveBeenCalledWith(entries[0], 0, entries); + expect(mockTransformToV2).toHaveBeenCalledWith(entries[1], 1, entries); + expect(mockTransformToV2).toHaveBeenCalledWith(entries[2], 2, entries); + }); + + describe('validation', () => { + it.each([ + ['abc', 'limit must be a positive number'], + ['0', 'limit must be a positive number'], + ['-1', 'limit must be a positive number'], + ])('rejects limit=%s with 400', async (limit, expectedMessage) => { + const req = createMockReq({ limit }); + const res = createMockRes(); + + await getEntries(req as Request, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: expectedMessage }); + expect(mockGetEntriesByPage).not.toHaveBeenCalled(); + }); + + it('rejects limit exceeding MAX_ITEMS (200)', async () => { + const req = createMockReq({ limit: '201' }); + const res = createMockRes(); + + await getEntries(req as Request, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: 'Requested too many entries' }); + }); + + it('accepts limit=200 (exactly MAX_ITEMS)', async () => { + mockGetEntriesByPage.mockResolvedValue([]); + mockGetEntryCount.mockResolvedValue(0); + + const req = createMockReq({ limit: '200' }); + const res = createMockRes(); + + await getEntries(req as Request, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(200); + expect(mockGetEntriesByPage).toHaveBeenCalledWith(0, 200); + }); + + it.each([ + ['abc', 'page must be a non-negative number'], + ['-1', 'page must be a non-negative number'], + ])('rejects page=%s with 400', async (page, expectedMessage) => { + const req = createMockReq({ page }); + const res = createMockRes(); + + await getEntries(req as Request, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: expectedMessage }); + expect(mockGetEntriesByPage).not.toHaveBeenCalled(); + }); + + it('accepts page=0', async () => { + mockGetEntriesByPage.mockResolvedValue([]); + mockGetEntryCount.mockResolvedValue(0); + + const req = createMockReq({ page: '0' }); + const res = createMockRes(); + + await getEntries(req as Request, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(200); + }); + }); + + it('calls next with error on service failure', async () => { + const error = new Error('DB connection failed'); + mockGetEntriesByPage.mockRejectedValue(error); + + const req = createMockReq(); + const res = createMockRes(); + + await getEntries(req as Request, res as Response, mockNext); + + expect(mockNext).toHaveBeenCalledWith(error); + }); + }); + + describe('getLatest', () => { + it('returns the latest entry transformed to V2', async () => { + const entry = createMockEntry(42); + mockGetEntriesByPage.mockResolvedValue([entry]); + + const req = createMockReq(); + const res = createMockRes(); + + await getLatest(req as Request, res as Response, mockNext); + + expect(mockGetEntriesByPage).toHaveBeenCalledWith(0, 1); + expect(mockTransformToV2).toHaveBeenCalledWith(entry); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ ...entry, v2: true }); + }); + + it('returns 404 when no entries exist', async () => { + mockGetEntriesByPage.mockResolvedValue([]); + + const req = createMockReq(); + const res = createMockRes(); + + await getLatest(req as Request, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ message: 'No entries found' }); + expect(mockTransformToV2).not.toHaveBeenCalled(); + }); + + it('calls next with error on service failure', async () => { + const error = new Error('DB timeout'); + mockGetEntriesByPage.mockRejectedValue(error); + + const req = createMockReq(); + const res = createMockRes(); + + await getLatest(req as Request, res as Response, mockNext); + + expect(mockNext).toHaveBeenCalledWith(error); + }); + }); + + describe('getShowInfo', () => { + const mockShowMetadata = { + id: 1, + specialty_show_name: '', + show_djs: [{ id: '1', dj_name: 'DJ Test' }], + }; + + it('returns show metadata with V2-transformed entries', async () => { + const entries = [createMockEntry(1), createMockEntry(2)]; + mockGetShowMetadata.mockResolvedValue(mockShowMetadata); + mockGetEntriesByShow.mockResolvedValue(entries); + + const req = createMockReq({ show_id: '1' }); + const res = createMockRes(); + + await getShowInfo(req as Request, res as Response, mockNext); + + expect(mockGetShowMetadata).toHaveBeenCalledWith(1); + expect(mockGetEntriesByShow).toHaveBeenCalledWith(1); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + ...mockShowMetadata, + entries: entries.map(e => ({ ...e, v2: true })), + }); + }); + + it('fetches show metadata and entries in parallel', async () => { + const callOrder: string[] = []; + mockGetShowMetadata.mockImplementation(async () => { + callOrder.push('metadata'); + return mockShowMetadata; + }); + mockGetEntriesByShow.mockImplementation(async () => { + callOrder.push('entries'); + return []; + }); + + const req = createMockReq({ show_id: '1' }); + const res = createMockRes(); + + await getShowInfo(req as Request, res as Response, mockNext); + + // Both should be called (Promise.all) + expect(mockGetShowMetadata).toHaveBeenCalledWith(1); + expect(mockGetEntriesByShow).toHaveBeenCalledWith(1); + }); + + it.each([ + ['abc'], + [undefined], + ])('returns 400 for invalid show_id=%s', async (show_id) => { + const query = show_id !== undefined ? { show_id } : {}; + const req = createMockReq(query as Record); + const res = createMockRes(); + + await getShowInfo(req as Request, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: 'Missing or invalid show_id parameter' }); + expect(mockGetShowMetadata).not.toHaveBeenCalled(); + expect(mockGetEntriesByShow).not.toHaveBeenCalled(); + }); + + it('calls next with error on service failure', async () => { + const error = new Error('Show not found'); + mockGetShowMetadata.mockRejectedValue(error); + + const req = createMockReq({ show_id: '1' }); + const res = createMockRes(); + + await getShowInfo(req as Request, res as Response, mockNext); + + expect(mockNext).toHaveBeenCalledWith(error); + }); + }); +}); diff --git a/tests/unit/services/flowsheet.service.test.ts b/tests/unit/services/flowsheet.service.test.ts index 7812ea6b..3d081267 100644 --- a/tests/unit/services/flowsheet.service.test.ts +++ b/tests/unit/services/flowsheet.service.test.ts @@ -1,9 +1,7 @@ import { transformToV2 } from '../../../apps/backend/services/flowsheet.service'; -import { IFSEntry } from '../../../apps/backend/controllers/flowsheet.controller'; +import { IFSEntry, IFSEntryMetadata } from '../../../apps/backend/controllers/flowsheet.controller'; -import { IFSEntryMetadata } from '../../../apps/backend/controllers/flowsheet.controller'; - -const defaultMetadata: IFSEntryMetadata = { +const nullMetadata: IFSEntryMetadata = { artwork_url: null, discogs_url: null, release_year: null, @@ -17,36 +15,8 @@ const defaultMetadata: IFSEntryMetadata = { }; // Helper to create a base entry with common fields -const createBaseEntry = (overrides: Partial = {}): IFSEntry => { - const { - artwork_url, - discogs_url, - release_year, - spotify_url, - apple_music_url, - youtube_music_url, - bandcamp_url, - soundcloud_url, - artist_bio, - artist_wikipedia_url, - metadata: metadataOverride, - ...rest - } = overrides; - - const metadata: IFSEntryMetadata = metadataOverride ?? { - ...defaultMetadata, - ...(artwork_url !== undefined && { artwork_url }), - ...(discogs_url !== undefined && { discogs_url }), - ...(release_year !== undefined && { release_year }), - ...(spotify_url !== undefined && { spotify_url }), - ...(apple_music_url !== undefined && { apple_music_url }), - ...(youtube_music_url !== undefined && { youtube_music_url }), - ...(bandcamp_url !== undefined && { bandcamp_url }), - ...(soundcloud_url !== undefined && { soundcloud_url }), - ...(artist_bio !== undefined && { artist_bio }), - ...(artist_wikipedia_url !== undefined && { artist_wikipedia_url }), - }; - +const createBaseEntry = (overrides: Partial }> = {}): IFSEntry => { + const { metadata: metadataOverrides, ...rest } = overrides; return { id: 1, show_id: 100, @@ -61,8 +31,11 @@ const createBaseEntry = (overrides: Partial = {}): request_flag: false, message: null, add_time: new Date('2024-01-15T12:00:00Z'), - rotation_play_freq: null, - metadata, + rotation_bin: null, + metadata: { + ...nullMetadata, + ...metadataOverrides, + }, ...rest, }; }; @@ -80,9 +53,11 @@ describe('flowsheet.service', () => { album_id: 1, rotation_id: 5, request_flag: true, - rotation_play_freq: 'H', - artwork_url: 'https://example.com/art.jpg', - spotify_url: 'https://open.spotify.com/track/123', + rotation_bin: 'H', + metadata: { + artwork_url: 'https://example.com/art.jpg', + spotify_url: 'https://open.spotify.com/track/123', + }, }); const result = transformToV2(entry); @@ -95,11 +70,33 @@ describe('flowsheet.service', () => { expect(result.album_id).toBe(1); expect(result.rotation_id).toBe(5); expect(result.request_flag).toBe(true); - expect(result.rotation_play_freq).toBe('H'); + expect(result.rotation_bin).toBe('H'); expect(result.artwork_url).toBe('https://example.com/art.jpg'); expect(result.spotify_url).toBe('https://open.spotify.com/track/123'); }); + it('includes rotation_bin field', () => { + const entry = createBaseEntry({ + entry_type: 'track', + rotation_bin: 'H', + }); + + const result = transformToV2(entry); + + expect(result.rotation_bin).toBe('H'); + }); + + it('includes rotation_bin as null when rotation_bin is null', () => { + const entry = createBaseEntry({ + entry_type: 'track', + rotation_bin: null, + }); + + const result = transformToV2(entry); + + expect(result.rotation_bin).toBeNull(); + }); + it('excludes message field from track entries', () => { const entry = createBaseEntry({ entry_type: 'track', @@ -115,16 +112,18 @@ describe('flowsheet.service', () => { it('includes all metadata fields for tracks', () => { const entry = createBaseEntry({ entry_type: 'track', - artwork_url: 'art.jpg', - discogs_url: 'discogs.com', - release_year: 1999, - spotify_url: 'spotify.com', - apple_music_url: 'apple.com', - youtube_music_url: 'youtube.com', - bandcamp_url: 'bandcamp.com', - soundcloud_url: 'soundcloud.com', - artist_bio: 'A great band', - artist_wikipedia_url: 'wiki.com', + metadata: { + artwork_url: 'art.jpg', + discogs_url: 'discogs.com', + release_year: 1999, + spotify_url: 'spotify.com', + apple_music_url: 'apple.com', + youtube_music_url: 'youtube.com', + bandcamp_url: 'bandcamp.com', + soundcloud_url: 'soundcloud.com', + artist_bio: 'A great band', + artist_wikipedia_url: 'wiki.com', + }, }); const result = transformToV2(entry); @@ -169,7 +168,7 @@ describe('flowsheet.service', () => { expect(result.artist_name).toBeUndefined(); expect(result.album_title).toBeUndefined(); expect(result.track_title).toBeUndefined(); - expect(result.rotation_play_freq).toBeUndefined(); + expect(result.rotation_bin).toBeUndefined(); }); it('handles malformed show_start message gracefully', () => { @@ -346,12 +345,12 @@ describe('flowsheet.service', () => { const entry = createBaseEntry({ entry_type: 'message', message: 'Test message', - rotation_play_freq: 'H', + rotation_bin: 'H', }); const result = transformToV2(entry); - expect(result.rotation_play_freq).toBeUndefined(); + expect(result.rotation_bin).toBeUndefined(); }); }); diff --git a/tests/utils/library_util.js b/tests/utils/library_util.js index 123e2d6d..b093af56 100644 --- a/tests/utils/library_util.js +++ b/tests/utils/library_util.js @@ -102,7 +102,7 @@ exports.createGenre = async (genreData, access_token) => { * * @param {object} rotationData - Rotation data * @param {number} rotationData.album_id - Album ID - * @param {string} rotationData.play_freq - Play frequency (S, L, M, H) + * @param {string} rotationData.rotation_bin - Play frequency (S, L, M, H) * @param {string} access_token - Authorization token * @returns {Promise} Created rotation entry */