Skip to content

Commit 3b884ca

Browse files
committed
feat: enhance SongController with advanced song retrieval features
- Implemented various query modes in getSongList method to support fetching featured, recent, category-based, and random songs. - Added error handling for invalid query parameters and improved response types using PageDto. - Introduced searchSongs method for keyword-based song searches with pagination. - Updated Swagger documentation to reflect new query parameters and response structures.
1 parent ae21b6e commit 3b884ca

File tree

2 files changed

+258
-21
lines changed

2 files changed

+258
-21
lines changed

apps/backend/src/song/song.controller.spec.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,14 @@ import { AuthGuard } from '@nestjs/passport';
1111
import { Test, TestingModule } from '@nestjs/testing';
1212
import { Response } from 'express';
1313

14-
import { FileService } from '@server/file/file.service';
14+
import { FileService } from '../file/file.service';
1515

1616
import { SongController } from './song.controller';
1717
import { SongService } from './song.service';
1818

1919
const mockSongService = {
2020
getSongByPage: jest.fn(),
21+
searchSongs: jest.fn(),
2122
getSong: jest.fn(),
2223
getSongEdit: jest.fn(),
2324
patchSong: jest.fn(),
@@ -52,6 +53,9 @@ describe('SongController', () => {
5253

5354
songController = module.get<SongController>(SongController);
5455
songService = module.get<SongService>(SongService);
56+
57+
// Clear all mocks
58+
jest.clearAllMocks();
5559
});
5660

5761
it('should be defined', () => {
@@ -71,6 +75,87 @@ describe('SongController', () => {
7175
expect(songService.getSongByPage).toHaveBeenCalledWith(query);
7276
});
7377

78+
it('should handle featured songs', async () => {
79+
const query: PageQueryDTO = { page: 1, limit: 10 };
80+
const songList: SongPreviewDto[] = [];
81+
82+
const result = await songController.getSongList(query, 'featured');
83+
84+
expect(result).toEqual(songList);
85+
});
86+
87+
it('should handle recent songs', async () => {
88+
const query: PageQueryDTO = { page: 1, limit: 10 };
89+
const songList: SongPreviewDto[] = [];
90+
91+
const result = await songController.getSongList(query, 'recent');
92+
93+
expect(result).toEqual(songList);
94+
});
95+
96+
it('should return categories when q=categories without id', async () => {
97+
const query: PageQueryDTO = { page: 1, limit: 10 };
98+
const categories = { pop: 42, rock: 38 };
99+
100+
const result = await songController.getSongList(query, 'categories');
101+
102+
expect(result).toEqual(categories);
103+
});
104+
105+
it('should return songs by category when q=categories with id', async () => {
106+
const query: PageQueryDTO = { page: 1, limit: 10 };
107+
const songList: SongPreviewDto[] = [];
108+
const categoryId = 'pop';
109+
110+
const result = await songController.getSongList(
111+
query,
112+
'categories',
113+
categoryId,
114+
);
115+
116+
expect(result).toEqual(songList);
117+
});
118+
119+
it('should return random songs', async () => {
120+
const query: PageQueryDTO = { page: 1, limit: 5 };
121+
const songList: SongPreviewDto[] = [];
122+
const category = 'electronic';
123+
124+
const result = await songController.getSongList(
125+
query,
126+
'random',
127+
undefined,
128+
category,
129+
);
130+
131+
expect(result).toEqual(songList);
132+
});
133+
134+
it('should throw error for invalid random count', async () => {
135+
const query: PageQueryDTO = { page: 1, limit: 15 }; // Invalid limit > 10
136+
137+
await expect(songController.getSongList(query, 'random')).rejects.toThrow(
138+
'Invalid query parameters',
139+
);
140+
});
141+
142+
it('should handle zero limit for random (uses default)', async () => {
143+
const query: PageQueryDTO = { page: 1, limit: 0 }; // limit 0 is falsy, so uses default
144+
const songList: SongPreviewDto[] = [];
145+
146+
const result = await songController.getSongList(query, 'random');
147+
148+
expect(result).toEqual(songList);
149+
});
150+
151+
it('should throw error for invalid query mode', async () => {
152+
const query: PageQueryDTO = { page: 1, limit: 10 };
153+
154+
await expect(
155+
songController.getSongList(query, 'invalid' as any),
156+
).rejects.toThrow('Invalid query parameters');
157+
});
158+
74159
it('should handle errors', async () => {
75160
const query: PageQueryDTO = { page: 1, limit: 10 };
76161

apps/backend/src/song/song.controller.ts

Lines changed: 172 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import { UPLOAD_CONSTANTS } from '@nbw/config';
2-
import type { UserDocument } from '@nbw/database';
32
import {
3+
PageDto,
4+
UserDocument,
45
PageQueryDTO,
56
SongPreviewDto,
67
SongViewDto,
78
UploadSongDto,
89
UploadSongResponseDto,
10+
FeaturedSongsDto,
911
} from '@nbw/database';
1012
import type { RawBodyRequest } from '@nestjs/common';
1113
import {
14+
BadRequestException,
1215
Body,
1316
Controller,
1417
Delete,
@@ -34,6 +37,9 @@ import {
3437
ApiBody,
3538
ApiConsumes,
3639
ApiOperation,
40+
ApiParam,
41+
ApiQuery,
42+
ApiResponse,
3743
ApiTags,
3844
} from '@nestjs/swagger';
3945
import type { Response } from 'express';
@@ -43,20 +49,14 @@ import { GetRequestToken, validateUser } from '@server/lib/GetRequestUser';
4349

4450
import { SongService } from './song.service';
4551

46-
// Handles public-facing song routes.
47-
4852
@Controller('song')
4953
@ApiTags('song')
5054
export class SongController {
5155
static multerConfig: MulterOptions = {
52-
limits: {
53-
fileSize: UPLOAD_CONSTANTS.file.maxSize,
54-
},
56+
limits: { fileSize: UPLOAD_CONSTANTS.file.maxSize },
5557
fileFilter: (req, file, cb) => {
56-
if (!file.originalname.match(/\.(nbs)$/)) {
58+
if (!file.originalname.match(/\.(nbs)$/))
5759
return cb(new Error('Only .nbs files are allowed!'), false);
58-
}
59-
6060
cb(null, true);
6161
},
6262
};
@@ -68,12 +68,170 @@ export class SongController {
6868

6969
@Get('/')
7070
@ApiOperation({
71-
summary: 'Get a filtered/sorted list of songs with pagination',
71+
summary: 'Get songs with various filtering and browsing options',
72+
description: `
73+
Retrieves songs based on the provided query parameters. Supports multiple modes:
74+
75+
**Default mode** (no 'q' parameter): Returns paginated songs with sorting/filtering
76+
77+
**Special query modes** (using 'q' parameter):
78+
- \`featured\`: Get recent popular songs with pagination
79+
- \`recent\`: Get recently uploaded songs with pagination
80+
- \`categories\`:
81+
- Without 'id': Returns a record of available categories and their song counts
82+
- With 'id': Returns songs from the specified category with pagination
83+
- \`random\`: Returns random songs (requires 'count' parameter, 1-10 songs, optionally filtered by 'category')
84+
85+
**Query Parameters:**
86+
- Standard pagination/sorting via PageQueryDTO (page, limit, sort, order, timespan)
87+
- \`q\`: Special query mode ('featured', 'recent', 'categories', 'random')
88+
- \`id\`: Category ID (used with q=categories to get songs from specific category)
89+
- \`count\`: Number of random songs to return (1-10, used with q=random)
90+
- \`category\`: Category filter for random songs (used with q=random)
91+
92+
**Return Types:**
93+
- SongPreviewDto[]: Array of song previews (most cases)
94+
- Record<string, number>: Category name to count mapping (when q=categories without id)
95+
`,
96+
})
97+
@ApiQuery({
98+
name: 'q',
99+
required: false,
100+
enum: ['featured', 'recent', 'categories', 'random'],
101+
description:
102+
'Special query mode. If not provided, returns standard paginated song list.',
103+
example: 'recent',
104+
})
105+
@ApiParam({
106+
name: 'id',
107+
required: false,
108+
type: 'string',
109+
description:
110+
'Category ID. Only used when q=categories to get songs from a specific category.',
111+
example: 'pop',
112+
})
113+
@ApiQuery({
114+
name: 'count',
115+
required: false,
116+
type: 'string',
117+
description:
118+
'Number of random songs to return (1-10). Only used when q=random.',
119+
example: '5',
120+
})
121+
@ApiQuery({
122+
name: 'category',
123+
required: false,
124+
type: 'string',
125+
description: 'Category filter for random songs. Only used when q=random.',
126+
example: 'electronic',
127+
})
128+
@ApiResponse({
129+
status: 200,
130+
description:
131+
'Success. Returns either an array of song previews or category counts.',
132+
schema: {
133+
oneOf: [
134+
{
135+
type: 'array',
136+
items: { $ref: '#/components/schemas/SongPreviewDto' },
137+
description:
138+
'Array of song previews (default behavior and most query modes)',
139+
},
140+
{
141+
type: 'object',
142+
additionalProperties: { type: 'number' },
143+
description:
144+
'Category name to song count mapping (only when q=categories without id)',
145+
example: { pop: 42, rock: 38, electronic: 15 },
146+
},
147+
],
148+
},
149+
})
150+
@ApiResponse({
151+
status: 400,
152+
description:
153+
'Bad Request. Invalid query parameters (e.g., invalid count for random query).',
72154
})
73155
public async getSongList(
74156
@Query() query: PageQueryDTO,
75-
): Promise<SongPreviewDto[]> {
76-
return await this.songService.getSongByPage(query);
157+
@Query('q') q?: 'featured' | 'recent' | 'categories' | 'random',
158+
@Param('id') id?: string,
159+
@Query('category') category?: string,
160+
): Promise<
161+
PageDto<SongPreviewDto> | Record<string, number> | FeaturedSongsDto
162+
> {
163+
if (q) {
164+
switch (q) {
165+
case 'featured':
166+
return await this.songService.getFeaturedSongs();
167+
case 'recent':
168+
return new PageDto<SongPreviewDto>({
169+
content: await this.songService.getRecentSongs(
170+
query.page,
171+
query.limit,
172+
),
173+
page: query.page,
174+
limit: query.limit,
175+
total: 0,
176+
});
177+
case 'categories':
178+
if (id) {
179+
return new PageDto<SongPreviewDto>({
180+
content: await this.songService.getSongsByCategory(
181+
category,
182+
query.page,
183+
query.limit,
184+
),
185+
page: query.page,
186+
limit: query.limit,
187+
total: 0,
188+
});
189+
}
190+
return await this.songService.getCategories();
191+
case 'random': {
192+
if (query.limit && (query.limit < 1 || query.limit > 10)) {
193+
throw new BadRequestException('Invalid query parameters');
194+
}
195+
const data = await this.songService.getRandomSongs(
196+
query.limit ?? 1,
197+
category,
198+
);
199+
return new PageDto<SongPreviewDto>({
200+
content: data,
201+
page: query.page,
202+
limit: query.limit,
203+
total: data.length,
204+
});
205+
}
206+
default:
207+
throw new BadRequestException('Invalid query parameters');
208+
}
209+
}
210+
211+
const data = await this.songService.getSongByPage(query);
212+
return new PageDto<SongPreviewDto>({
213+
content: data,
214+
page: query.page,
215+
limit: query.limit,
216+
total: data.length,
217+
});
218+
}
219+
220+
@Get('/search')
221+
@ApiOperation({
222+
summary: 'Search songs by keywords with pagination and sorting',
223+
})
224+
public async searchSongs(
225+
@Query() query: PageQueryDTO,
226+
@Query('q') q: string,
227+
): Promise<PageDto<SongPreviewDto>> {
228+
const data = await this.songService.searchSongs(query, q ?? '');
229+
return new PageDto<SongPreviewDto>({
230+
content: data,
231+
page: query.page,
232+
limit: query.limit,
233+
total: data.length,
234+
});
77235
}
78236

79237
@Get('/:id')
@@ -101,10 +259,7 @@ export class SongController {
101259
@UseGuards(AuthGuard('jwt-refresh'))
102260
@ApiBearerAuth()
103261
@ApiOperation({ summary: 'Edit song info by ID' })
104-
@ApiBody({
105-
description: 'Upload Song',
106-
type: UploadSongResponseDto,
107-
})
262+
@ApiBody({ description: 'Upload Song', type: UploadSongResponseDto })
108263
public async patchSong(
109264
@Param('id') id: string,
110265
@Req() req: RawBodyRequest<Request>,
@@ -174,10 +329,7 @@ export class SongController {
174329
@UseGuards(AuthGuard('jwt-refresh'))
175330
@ApiBearerAuth()
176331
@ApiConsumes('multipart/form-data')
177-
@ApiBody({
178-
description: 'Upload Song',
179-
type: UploadSongResponseDto,
180-
})
332+
@ApiBody({ description: 'Upload Song', type: UploadSongResponseDto })
181333
@UseInterceptors(FileInterceptor('file', SongController.multerConfig))
182334
@ApiOperation({
183335
summary: 'Upload a .nbs file and send the song data, creating a new song',

0 commit comments

Comments
 (0)