Skip to content
Merged
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
56 changes: 56 additions & 0 deletions src/__tests__/SongController.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Request, Response } from 'express';
import { SongController } from '../../src/controllers/SongController';
import { SongService } from '../../src/services/SongService';
import * as utils from '../../src/utils/helpers';

// Mock the SongService class
jest.mock('../../src/services/SongService');

describe('SongController - getLyrics', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let mockGetLyrics: jest.Mock;

beforeEach(() => {
mockGetLyrics = jest.fn();
(SongService.prototype.getLyrics as jest.Mock) = mockGetLyrics;

req = {
params: { id: 'song-123' },
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
});

afterEach(() => {
jest.clearAllMocks();
});

it('should return lyrics for a valid song', async () => {
mockGetLyrics.mockResolvedValue({ lyrics: 'La la la', language: 'en' });

await SongController.getLyrics(req as Request, res as Response);

expect(mockGetLyrics).toHaveBeenCalledWith('song-123');
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
success: true,
data: { lyrics: 'La la la', language: 'en' },
});
});

it('should call handleError on failure', async () => {
const error = new Error('Test error');
mockGetLyrics.mockRejectedValue(error);

// We mock handleError using spyOn if possible, but actually handleError is in utils.
// Instead of asserting handleError, we just test that it catches the error and doesn't throw.
jest.spyOn(utils, 'handleError').mockImplementation(() => {});

await SongController.getLyrics(req as Request, res as Response);

expect(utils.handleError).toHaveBeenCalledWith(req, res, error);
});
});
11 changes: 11 additions & 0 deletions src/controllers/SongController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,4 +387,15 @@ export class SongController {
handleError(req, res, error);
}
};

/** GET /api/songs/:id/lyrics — get lyrics for a song (Issue #75). */
static getLyrics = async (req: Request, res: Response) => {
try {
const songId = req.params.id as string;
const lyricsData = await songService.getLyrics(songId);
return res.status(200).json({ success: true, data: lyricsData });
} catch (error) {
handleError(req, res, error);
}
};
}
6 changes: 6 additions & 0 deletions src/entities/Song.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ export class Song {
@Column('simple-json', { nullable: true })
royaltySplits?: TemplateSplit[];

@Column({ type: 'text', nullable: true })
lyrics?: string;

@Column({ nullable: true })
language?: string;

@CreateDateColumn()
createdAt!: Date;

Expand Down
15 changes: 15 additions & 0 deletions src/migrations/1753200000004-AddSongLyrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddSongLyrics1753200000004 implements MigrationInterface {
name = 'AddSongLyrics1753200000004';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "songs" ADD "lyrics" text`);
await queryRunner.query(`ALTER TABLE "songs" ADD "language" varchar`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "songs" DROP COLUMN "language"`);
await queryRunner.query(`ALTER TABLE "songs" DROP COLUMN "lyrics"`);
}
}
3 changes: 3 additions & 0 deletions src/routes/SongRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@ router.post(
// Stream Songs
router.get('/stream/:id', SongController.streamSong);

// Lyrics (Issue #75)
router.get('/:id/lyrics', requireAuth, SongController.getLyrics);

// Public, read-heavy catalog endpoints get ETag + Cache-Control so clients can
// revalidate cheaply and skip re-downloading unchanged lists (Issue #133).
router.get(
Expand Down
19 changes: 19 additions & 0 deletions src/services/SongService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,4 +585,23 @@ export class SongService {

return song;
}

/**
* Get lyrics for a song.
*/
async getLyrics(songId: string): Promise<{ lyrics: string; language?: string }> {
const song = await this.songRepo.findOneBy({ id: songId });
if (!song) {
throw AppError.notFound('Song not found', undefined, 'SONG_NOT_FOUND');
}
if (!song.lyrics) {
throw AppError.notFound('Lyrics not found for this song', undefined, 'LYRICS_NOT_FOUND');
}

const result: { lyrics: string; language?: string } = { lyrics: song.lyrics };
if (song.language) {
result.language = song.language;
}
return result;
}
}
119 changes: 119 additions & 0 deletions tests/integration/songLyrics.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
process.env.NODE_ENV = 'test';
process.env.DB_TYPE = 'sqlite';
process.env.JWT_SECRET = 'test-secret';
process.env.APP_URL = 'http://localhost';

import request from 'supertest';
import jwt from 'jsonwebtoken';
import AppDataSource from '../../src/config/db';
import app from '../../src/app';
import { User } from '../../src/entities/User';
import { Song } from '../../src/entities/Song';

jest.mock('../../src/config/redis', () => ({
__esModule: true,
default: {
get: jest.fn(async () => null),
set: jest.fn(async () => 'OK'),
},
}));

describe('Song Lyrics API Integration', () => {
let userToken: string;
let testUser: User;
let testSongWithLyrics: Song;
let testSongWithoutLyrics: Song;

beforeAll(async () => {
if (!AppDataSource.isInitialized) {
await AppDataSource.initialize();
}

const userRepo = AppDataSource.getRepository(User);
const songRepo = AppDataSource.getRepository(Song);

// Create test user
testUser = userRepo.create({
email: 'lyrics-test@example.com',
username: 'lyricstest',
passwordHash: 'hashedpassword',
});
await userRepo.save(testUser);

userToken = jwt.sign(
{ id: testUser.id, role: 'artist' },
process.env.JWT_SECRET || 'test-secret',
{ expiresIn: '1h' },
);

// Create a song with lyrics
testSongWithLyrics = songRepo.create({
title: 'Song With Lyrics',
artistId: testUser.id,
coverArtPath: 'test.jpg',
status: 'ready',
lyrics: 'Hello from the other side',
language: 'en',
});
await songRepo.save(testSongWithLyrics);

// Create a song without lyrics
testSongWithoutLyrics = songRepo.create({
title: 'Song Without Lyrics',
artistId: testUser.id,
coverArtPath: 'test.jpg',
status: 'ready',
});
await songRepo.save(testSongWithoutLyrics);
});

afterAll(async () => {
const userRepo = AppDataSource.getRepository(User);
const songRepo = AppDataSource.getRepository(Song);
await songRepo.delete({ artistId: testUser.id });
await userRepo.delete({ email: 'lyrics-test@example.com' });
if (AppDataSource.isInitialized) {
await AppDataSource.destroy();
}
});

describe('GET /api/songs/:id/lyrics', () => {
it('returns lyrics for a valid song ID', async () => {
const response = await request(app)
.get(`/api/songs/${testSongWithLyrics.id}/lyrics`)
.set('Authorization', `Bearer ${userToken}`);

expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.lyrics).toBe('Hello from the other side');
expect(response.body.data.language).toBe('en');
});

it('returns 404 if song has no lyrics', async () => {
const response = await request(app)
.get(`/api/songs/${testSongWithoutLyrics.id}/lyrics`)
.set('Authorization', `Bearer ${userToken}`);

expect(response.status).toBe(404);
expect(response.body.success).toBe(false);
expect(response.body.message).toBe('Lyrics not found for this song');
});

it('returns 404 if song does not exist', async () => {
const fakeId = '00000000-0000-0000-0000-000000000000';
const response = await request(app)
.get(`/api/songs/${fakeId}/lyrics`)
.set('Authorization', `Bearer ${userToken}`);

expect(response.status).toBe(404);
expect(response.body.success).toBe(false);
expect(response.body.message).toBe('Song not found');
});

it('requires authentication', async () => {
const response = await request(app).get(`/api/songs/${testSongWithLyrics.id}/lyrics`);

expect(response.status).toBe(401);
});
});
});
Loading