Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 49 additions & 38 deletions apps/backend/services/djs.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
specialty_shows,
user,
} from '@wxyc/database';
import { and, eq, isNull } from 'drizzle-orm';
import { and, eq, inArray, isNull } from 'drizzle-orm';

export const addToBin = async (bin_entry: NewBinEntry): Promise<BinEntry> => {
const added_bin_entry = await db.insert(bins).values(bin_entry).returning();
Expand Down Expand Up @@ -63,47 +63,58 @@ type ShowPeek = {

// ERRORS IN SERVICES ARE 500 ERRORS
export const getPlaylistsForDJ = async (dj_id: string) => {
// gets a 'preview set' of 4 artists/albums and the show id for each show the dj has been in
const this_djs_shows = await db.select().from(show_djs).where(eq(show_djs.dj_id, dj_id));

const show_previews = [];
for (let i = 0; i < this_djs_shows.length; i++) {
const show = await db.select().from(shows).where(eq(shows.id, this_djs_shows[i].show_id));

const djs_involved = await db
.select({ dj_id: show_djs.dj_id, dj_name: user.djName })
.from(show_djs)
.innerJoin(user, and(eq(show_djs.show_id, show[0].id), eq(show_djs.dj_id, user.id)));

const peek_object: ShowPeek = {
show: show[0].id,
show_name: show[0].show_name ?? '',
date: show[0].start_time,
djs: djs_involved,
specialty_show: '',
preview: [],
};

if (show[0].specialty_id != null) {
const specialty_show = await db
.select()
.from(specialty_shows)
.where(eq(specialty_shows.id, show[0].specialty_id));
peek_object.specialty_show = specialty_show[0].specialty_name;
}

//get 4 track entries to display in preview
const entries: FSEntry[] = await db
.select()
.from(flowsheet)
.limit(4)
.where(and(eq(flowsheet.show_id, show[0].id), isNull(flowsheet.message)));

peek_object.preview = entries;
show_previews.push(peek_object);
if (this_djs_shows.length === 0) return [];

const showIds = this_djs_shows.map((s) => s.show_id);

const allShows = await db.select().from(shows).where(inArray(shows.id, showIds));

const allDjs = await db
.select({ dj_id: show_djs.dj_id, dj_name: user.djName, show_id: show_djs.show_id })
.from(show_djs)
.innerJoin(user, eq(show_djs.dj_id, user.id))
.where(inArray(show_djs.show_id, showIds));

const specialtyIds = allShows.filter((s) => s.specialty_id != null).map((s) => s.specialty_id!);

const allSpecialties =
specialtyIds.length > 0
? await db.select().from(specialty_shows).where(inArray(specialty_shows.id, specialtyIds))
: [];

const allEntries: FSEntry[] = await db
.select()
.from(flowsheet)
.where(and(inArray(flowsheet.show_id, showIds), isNull(flowsheet.message)));

const specialtyMap = new Map(allSpecialties.map((s) => [s.id, s.specialty_name]));
const djsByShow = new Map<number, { dj_id: string; dj_name: string | null }[]>();
for (const dj of allDjs) {
const list = djsByShow.get(dj.show_id) ?? [];
list.push({ dj_id: dj.dj_id, dj_name: dj.dj_name });
djsByShow.set(dj.show_id, list);
}
const entriesByShow = new Map<number, FSEntry[]>();
for (const entry of allEntries) {
if (entry.show_id == null) continue;
const list = entriesByShow.get(entry.show_id) ?? [];
list.push(entry);
entriesByShow.set(entry.show_id, list);
}

return show_previews;
return allShows.map((show) => {
const preview = (entriesByShow.get(show.id) ?? []).slice(0, 4);
return {
show: show.id,
show_name: show.show_name ?? '',
date: show.start_time,
djs: djsByShow.get(show.id) ?? [],
specialty_show: specialtyMap.get(show.specialty_id!) ?? '',
preview,
} satisfies ShowPeek;
});
};

export const getPlaylist = async (show_id: number) => {
Expand Down
166 changes: 166 additions & 0 deletions tests/unit/services/djs.getPlaylistsForDJ.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
let selectCallCount = 0;
const selectSpy = jest.fn();
const queryResults: unknown[][] = [];

function createChain(resolveIndex: number) {
const resolver = () => queryResults[resolveIndex] ?? [];
const chain: Record<string, unknown> = {};
for (const m of ['select', 'from', 'where', 'innerJoin', 'leftJoin', 'limit', 'orderBy']) {
chain[m] = jest.fn(() => chain);
}
chain.then = (onFulfill: (v: unknown) => unknown, onReject?: (e: unknown) => unknown) =>
Promise.resolve(resolver()).then(onFulfill, onReject);
return chain;
}

jest.mock('@wxyc/database', () => ({
db: {
select: (...args: unknown[]) => {
selectSpy(...args);
return createChain(selectCallCount++);
},
},
show_djs: { dj_id: 'dj_id', show_id: 'show_id' },
shows: { id: 'id', specialty_id: 'specialty_id' },
specialty_shows: { id: 'id', specialty_name: 'specialty_name' },
flowsheet: { show_id: 'show_id', message: 'message' },
user: { id: 'id', djName: 'djName' },
bins: {},
library: {},
artists: {},
format: {},
genres: {},
}));

jest.mock('drizzle-orm', () => ({
eq: jest.fn((a, b) => ({ eq: [a, b] })),
and: jest.fn((...args: unknown[]) => ({ and: args })),
isNull: jest.fn((col) => ({ isNull: col })),
inArray: jest.fn((col, vals) => ({ inArray: [col, vals] })),
}));

import { getPlaylistsForDJ } from '../../../apps/backend/services/djs.service';

const DJ_ID = 'dj-1';

function makeShowDjRows(count: number) {
return Array.from({ length: count }, (_, i) => ({
show_id: i + 1,
dj_id: DJ_ID,
active: true,
}));
}

function makeShowRows(count: number) {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
primary_dj_id: DJ_ID,
specialty_id: i === 0 ? 10 : null,
show_name: `Show ${i + 1}`,
start_time: new Date(`2024-01-${String(i + 1).padStart(2, '0')}T20:00:00Z`),
end_time: null,
}));
}

function makeDjsForShows(count: number) {
return Array.from({ length: count }, (_, i) => ({
dj_id: DJ_ID,
dj_name: 'DJ One',
show_id: i + 1,
}));
}

function makeFlowsheetRows(showCount: number) {
const entries = [];
for (let s = 0; s < showCount; s++) {
for (let e = 0; e < 4; e++) {
entries.push({
id: s * 4 + e + 1,
show_id: s + 1,
album_id: null,
rotation_id: null,
entry_type: 'track',
track_title: `Track ${e + 1}`,
album_title: `Album ${e + 1}`,
artist_name: `Artist ${e + 1}`,
record_label: null,
play_order: e + 1,
request_flag: false,
message: null,
add_time: new Date(),
});
}
}
return entries;
}

describe('djs.service - getPlaylistsForDJ', () => {
beforeEach(() => {
selectCallCount = 0;
selectSpy.mockClear();
queryResults.length = 0;
});

const NUM_SHOWS = 5;
const MAX_ALLOWED_SELECTS = 6;

it(`should make at most ${MAX_ALLOWED_SELECTS} select() calls, not O(N) for ${NUM_SHOWS} shows`, async () => {
const showDjRows = makeShowDjRows(NUM_SHOWS);
const showRows = makeShowRows(NUM_SHOWS);
const djRows = makeDjsForShows(NUM_SHOWS);
const specialtyRows = [{ id: 10, specialty_name: 'Jazz After Hours' }];
const flowsheetRows = makeFlowsheetRows(NUM_SHOWS);

// Provide enough results for both the batched (5 queries) and N+1 (17+ queries) paths.
// Batched order: [showDjs, shows, allDjs, specialties, flowsheet]
// N+1 order: [showDjs, show1, djs1, specialty1, fs1, show2, djs2, fs2, ...]
// We fill enough slots so either code path can run without crashing.
queryResults.push(showDjRows); // 0: show_djs for DJ
queryResults.push(showRows); // 1: batched shows / N+1 show[0]
queryResults.push(djRows); // 2: batched djs / N+1 djs[0]
queryResults.push(specialtyRows); // 3: batched specialty / N+1 specialty[0]
queryResults.push(flowsheetRows); // 4: batched flowsheet / N+1 flowsheet[0]
// Extra slots for N+1 loop iterations (shows 2-5)
for (let i = 1; i < NUM_SHOWS; i++) {
queryResults.push([showRows[i]]); // show
queryResults.push([{ dj_id: DJ_ID, dj_name: 'DJ One' }]); // djs
if (showRows[i].specialty_id != null) {
queryResults.push(specialtyRows);
}
queryResults.push(flowsheetRows.filter((e) => e.show_id === i + 1)); // flowsheet
}

await getPlaylistsForDJ(DJ_ID);

const totalSelectCalls = selectSpy.mock.calls.length;
expect(totalSelectCalls).toBeLessThanOrEqual(MAX_ALLOWED_SELECTS);
});

it('returns correct ShowPeek structures', async () => {
const showDjRows = makeShowDjRows(2);
const showRows = makeShowRows(2);
const djRows = makeDjsForShows(2);
const specialtyRows = [{ id: 10, specialty_name: 'Jazz After Hours' }];
const flowsheetRows = makeFlowsheetRows(2);

queryResults.push(showDjRows);
queryResults.push(showRows);
queryResults.push(djRows);
queryResults.push(specialtyRows);
queryResults.push(flowsheetRows);
// N+1 fallback slots
queryResults.push([showRows[1]]);
queryResults.push([{ dj_id: DJ_ID, dj_name: 'DJ One' }]);
queryResults.push(flowsheetRows.filter((e) => e.show_id === 2));

const result = await getPlaylistsForDJ(DJ_ID);

expect(result).toHaveLength(2);
expect(result[0]).toHaveProperty('show');
expect(result[0]).toHaveProperty('show_name');
expect(result[0]).toHaveProperty('date');
expect(result[0]).toHaveProperty('djs');
expect(result[0]).toHaveProperty('specialty_show');
expect(result[0]).toHaveProperty('preview');
});
});
Loading