From 2983142255e8af8feee2fd31aec839e8a2def754 Mon Sep 17 00:00:00 2001 From: Beshoy205 Date: Sun, 3 May 2026 00:34:42 +0300 Subject: [PATCH] refactor(admin): implement immediate session invalidation and logout for suspended users with Run prettier - Added real-time suspension checks to both REST and Socket.IO authentication middleware. - Configured automatic session cookie clearing when suspended users attempt to access the platform. - Centralized refresh cookie security options and ensured immediate WebSocket disconnection upon admin suspension. --- server.js | 13 +- src/config/jwt.js | 10 +- src/controllers/auth.controller.js | 34 +-- src/middleware/auth.js | 6 +- tests/auth.controller.unit.test.js | 92 +++---- tests/auth.service.unit.test.js | 67 +++-- tests/feed.controller.unit.test.js | 213 ++++++++-------- tests/feed.model.unit.test.js | 182 ++++++++------ tests/feed.service.unit.test.js | 238 +++++++++++------- tests/genre.controller.unit.test.js | 12 +- tests/genre.model.unit.test.js | 75 +++--- tests/genre.service.unit.test.js | 44 ++-- tests/playback/geo-restrictions.test.js | 10 +- tests/playback/playback.controller.test.js | 21 +- tests/resolve.controller.unit.test.js | 2 +- tests/resolve.model.unit.test.js | 2 +- tests/resolve.service.unit.test.js | 2 +- tests/search.controller.unit.test.js | 6 +- tests/search.model.unit.test.js | 237 +++++++++++------ tests/search.service.unit.test.js | 31 ++- tests/storage.service.test.js | 14 +- tests/subscription/subscription.model.test.js | 23 +- tests/user.model.unit.test.js | 90 +++---- tests/utils/geo-restrictions.test.js | 21 +- 24 files changed, 829 insertions(+), 616 deletions(-) diff --git a/server.js b/server.js index 92576c89..e33bee0a 100644 --- a/server.js +++ b/server.js @@ -6,6 +6,7 @@ const { Server } = require('socket.io'); const app = require('./app'); const env = require('./src/config/env'); const { verifyToken } = require('./src/config/jwt'); +const userModel = require('./src/models/user.model'); const { registerMessageHandlers } = require('./src/sockets/messages.socket'); const { registerNotificationHandlers, @@ -27,14 +28,22 @@ const io = new Server(server, { initNotificationSocket(io); // FIX 3 — single middleware block, duplicate removed -io.use((socket, next) => { +io.use(async (socket, next) => { const authHeader = socket.handshake.auth?.token; if (!authHeader || !authHeader.startsWith('Bearer ')) { return next(new Error('Access token required')); } const token = authHeader.split(' ')[1]; try { - socket.user = verifyToken(token); + const decoded = verifyToken(token); + socket.user = decoded; + + // Check if user is suspended in the database + const user = await userModel.findById(decoded.sub); + if (!user || user.is_suspended) { + return next(new Error('Your account is suspended or no longer exists.')); + } + next(); } catch { return next(new Error('Invalid or expired token')); diff --git a/src/config/jwt.js b/src/config/jwt.js index e8349e5b..279106ef 100644 --- a/src/config/jwt.js +++ b/src/config/jwt.js @@ -13,4 +13,12 @@ const signRefreshToken = (payload) => const verifyToken = (token) => jwt.verify(token, env.JWT_SECRET); -module.exports = { signAccessToken, signRefreshToken, verifyToken }; +const REFRESH_COOKIE_OPTIONS = { + httpOnly: true, + secure: true, + sameSite: 'none', + path: '/', + maxAge: 7 * 24 * 60 * 60 * 1000, +}; + +module.exports = { signAccessToken, signRefreshToken, verifyToken, REFRESH_COOKIE_OPTIONS }; diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js index e8d994ea..6b594646 100644 --- a/src/controllers/auth.controller.js +++ b/src/controllers/auth.controller.js @@ -15,23 +15,17 @@ const { isValidEmail, isValidPassword } = require('../utils/validators'); // secure must be true only in production — in development (HTTP localhost) // secure: true causes the browser to silently drop the cookie entirely. -const refreshCookieOptions = { - httpOnly: true, - secure: true, - sameSite: 'none', - path: '/', - maxAge: 7 * 24 * 60 * 60 * 1000, -}; +const { REFRESH_COOKIE_OPTIONS } = require('../config/jwt'); const setRefreshTokenCookie = (res, token) => { - res.cookie('refreshToken', token, refreshCookieOptions); + res.cookie('refreshToken', token, REFRESH_COOKIE_OPTIONS); // Backward compatibility while frontend migrates from snake_case key. - res.cookie('refresh_token', token, refreshCookieOptions); + res.cookie('refresh_token', token, REFRESH_COOKIE_OPTIONS); }; const clearRefreshTokenCookie = (res) => { - res.clearCookie('refreshToken', refreshCookieOptions); - res.clearCookie('refresh_token', refreshCookieOptions); + res.clearCookie('refreshToken', REFRESH_COOKIE_OPTIONS); + res.clearCookie('refresh_token', REFRESH_COOKIE_OPTIONS); }; exports.register = async (req, res) => { @@ -111,17 +105,23 @@ exports.login = async (req, res) => { }; exports.refresh = async (req, res) => { - // ✅ FIX: Removed console.log('Cookies:', req.cookies) — was leaking - // refresh tokens to server logs in plain text. const refresh_token = req.cookies?.refreshToken || req.cookies?.refresh_token || req.body?.refresh_token; - const data = await authService.refresh({ refresh_token }); - const { refresh_token: newRefreshToken, ...responseData } = data; + try { + const data = await authService.refresh({ refresh_token }); + const { refresh_token: newRefreshToken, ...responseData } = data; - setRefreshTokenCookie(res, newRefreshToken); + setRefreshTokenCookie(res, newRefreshToken); - return success(res, responseData, 'Token refreshed successfully.'); + return success(res, responseData, 'Token refreshed successfully.'); + } catch (err) { + // If the account is suspended during refresh, clear the cookies to force logout + if (err.errorCode === 'AUTH_ACCOUNT_SUSPENDED') { + clearRefreshTokenCookie(res); + } + throw err; + } }; exports.logout = async (req, res) => { diff --git a/src/middleware/auth.js b/src/middleware/auth.js index bf8dcb36..0156f2d9 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -2,7 +2,7 @@ // middleware/auth.js — JWT verification & token extraction // Attaches decoded user payload to req.user // ============================================================ -const { verifyToken } = require('../config/jwt'); +const { verifyToken, REFRESH_COOKIE_OPTIONS } = require('../config/jwt'); const { error } = require('../utils/api-response'); const userModel = require('../models/user.model'); @@ -24,6 +24,10 @@ const authenticate = async (req, res, next) => { } if (user.is_suspended) { + // Immediate passive logout: clear session cookies + res.clearCookie('refreshToken', REFRESH_COOKIE_OPTIONS); + res.clearCookie('refresh_token', REFRESH_COOKIE_OPTIONS); + return error( res, 'AUTH_ACCOUNT_SUSPENDED', diff --git a/tests/auth.controller.unit.test.js b/tests/auth.controller.unit.test.js index 8ee910e8..975bac97 100644 --- a/tests/auth.controller.unit.test.js +++ b/tests/auth.controller.unit.test.js @@ -92,13 +92,9 @@ describe('verifyEmail', () => { await controller.verifyEmail(req, res); - expect(api.error).toHaveBeenCalledWith( - res, - 'VALIDATION_FAILED', - 'Validation failed', - 400, - [{ field: 'token', issue: 'Verification token is required' }] - ); + expect(api.error).toHaveBeenCalledWith(res, 'VALIDATION_FAILED', 'Validation failed', 400, [ + { field: 'token', issue: 'Verification token is required' }, + ]); expect(authService.verifyEmail).not.toHaveBeenCalled(); }); @@ -133,13 +129,9 @@ describe('login', () => { await controller.login(req, res); - expect(api.error).toHaveBeenCalledWith( - res, - 'VALIDATION_FAILED', - 'Validation failed', - 400, - [{ field: 'identifier', issue: 'Email or username is required' }] - ); + expect(api.error).toHaveBeenCalledWith(res, 'VALIDATION_FAILED', 'Validation failed', 400, [ + { field: 'identifier', issue: 'Email or username is required' }, + ]); expect(authService.login).not.toHaveBeenCalled(); }); @@ -149,13 +141,9 @@ describe('login', () => { await controller.login(req, res); - expect(api.error).toHaveBeenCalledWith( - res, - 'VALIDATION_FAILED', - 'Validation failed', - 400, - [{ field: 'password', issue: 'Password is required' }] - ); + expect(api.error).toHaveBeenCalledWith(res, 'VALIDATION_FAILED', 'Validation failed', 400, [ + { field: 'password', issue: 'Password is required' }, + ]); expect(authService.login).not.toHaveBeenCalled(); }); @@ -190,8 +178,12 @@ describe('login', () => { const req = mkReq({ body: { identifier: ' user@example.com ', password: 'P1' } }); const res = mkRes(); authService.login.mockResolvedValue({ - access_token: 'acc', refresh_token: 'ref', - token_type: 'Bearer', expires_in: 900, user: {}, is_new_user: false, + access_token: 'acc', + refresh_token: 'ref', + token_type: 'Bearer', + expires_in: 900, + user: {}, + is_new_user: false, }); await controller.login(req, res); @@ -296,13 +288,9 @@ describe('requestPasswordReset', () => { await controller.requestPasswordReset(req, res); - expect(api.error).toHaveBeenCalledWith( - res, - 'VALIDATION_FAILED', - 'Validation failed', - 400, - [{ field: 'email', issue: 'Must be a valid email address' }] - ); + expect(api.error).toHaveBeenCalledWith(res, 'VALIDATION_FAILED', 'Validation failed', 400, [ + { field: 'email', issue: 'Must be a valid email address' }, + ]); expect(authService.requestPasswordReset).not.toHaveBeenCalled(); }); @@ -404,13 +392,9 @@ describe('resendVerification', () => { await controller.resendVerification(req, res); - expect(api.error).toHaveBeenCalledWith( - res, - 'VALIDATION_FAILED', - 'Validation failed', - 400, - [{ field: 'email', issue: 'Email is required' }] - ); + expect(api.error).toHaveBeenCalledWith(res, 'VALIDATION_FAILED', 'Validation failed', 400, [ + { field: 'email', issue: 'Email is required' }, + ]); }); it('calls service with normalized email', async () => { @@ -437,13 +421,9 @@ describe('changeEmail', () => { await controller.changeEmail(req, res); - expect(api.error).toHaveBeenCalledWith( - res, - 'VALIDATION_FAILED', - 'Validation failed', - 400, - [{ field: 'new_email', issue: 'Must be a valid email address' }] - ); + expect(api.error).toHaveBeenCalledWith(res, 'VALIDATION_FAILED', 'Validation failed', 400, [ + { field: 'new_email', issue: 'Must be a valid email address' }, + ]); }); it('calls service with userId and normalized email', async () => { @@ -471,18 +451,18 @@ describe('deleteAccount', () => { await controller.deleteAccount(req, res); - expect(api.error).toHaveBeenCalledWith( - res, - 'VALIDATION_FAILED', - 'Validation failed', - 400, - [{ field: 'password', issue: 'Password confirmation is required' }] - ); + expect(api.error).toHaveBeenCalledWith(res, 'VALIDATION_FAILED', 'Validation failed', 400, [ + { field: 'password', issue: 'Password confirmation is required' }, + ]); expect(authService.deleteAccount).not.toHaveBeenCalled(); }); it('calls service, clears cookies, and returns success', async () => { - const req = mkReq({ body: { password: 'Password1' }, user: { sub: 'u1' }, cookies: { refreshToken: 'tok' } }); + const req = mkReq({ + body: { password: 'Password1' }, + user: { sub: 'u1' }, + cookies: { refreshToken: 'tok' }, + }); const res = mkRes(); authService.deleteAccount.mockResolvedValue(); @@ -490,6 +470,10 @@ describe('deleteAccount', () => { expect(authService.deleteAccount).toHaveBeenCalledWith({ userId: 'u1', password: 'Password1' }); expect(res.clearCookie).toHaveBeenCalledWith('refreshToken', expect.any(Object)); - expect(api.success).toHaveBeenCalledWith(res, { success: true }, 'Account deleted successfully.'); + expect(api.success).toHaveBeenCalledWith( + res, + { success: true }, + 'Account deleted successfully.' + ); }); -}); \ No newline at end of file +}); diff --git a/tests/auth.service.unit.test.js b/tests/auth.service.unit.test.js index 057bf3cc..3bf10fa8 100644 --- a/tests/auth.service.unit.test.js +++ b/tests/auth.service.unit.test.js @@ -8,7 +8,11 @@ const refreshTokenModel = require('../src/models/refresh-token.model'); const oauthConnectionModel = require('../src/models/oauth-connection.model'); const bcrypt = require('bcryptjs'); const { signAccessToken, signRefreshToken, verifyToken } = require('../src/config/jwt'); -const { sendVerificationEmail, sendPasswordResetEmail, sendEmailChangeEmail } = require('../src/utils/mailer'); +const { + sendVerificationEmail, + sendPasswordResetEmail, + sendEmailChangeEmail, +} = require('../src/utils/mailer'); jest.mock('../src/models/user.model'); jest.mock('../src/models/verification-token.model'); @@ -111,7 +115,10 @@ describe('login', () => { }); it('returns tokens and user on valid credentials', async () => { - const result = await authService.login({ identifier: 'user@example.com', password: 'Password1' }); + const result = await authService.login({ + identifier: 'user@example.com', + password: 'Password1', + }); expect(userModel.findByEmailOrUsername).toHaveBeenCalledWith('user@example.com'); expect(bcrypt.compare).toHaveBeenCalledWith('Password1', fakeUser.password_hashed); @@ -174,36 +181,48 @@ describe('refresh', () => { }); it('throws 401 when no token provided', async () => { - await expect(authService.refresh({ refresh_token: null })) - .rejects.toMatchObject({ statusCode: 401, code: 'AUTH_REFRESH_TOKEN_INVALID' }); + await expect(authService.refresh({ refresh_token: null })).rejects.toMatchObject({ + statusCode: 401, + code: 'AUTH_REFRESH_TOKEN_INVALID', + }); }); it('throws 401 when token fails JWT verification', async () => { - verifyToken.mockImplementation(() => { throw new Error('bad token'); }); + verifyToken.mockImplementation(() => { + throw new Error('bad token'); + }); - await expect(authService.refresh({ refresh_token: 'bad_tok' })) - .rejects.toMatchObject({ statusCode: 401, code: 'AUTH_REFRESH_TOKEN_INVALID' }); + await expect(authService.refresh({ refresh_token: 'bad_tok' })).rejects.toMatchObject({ + statusCode: 401, + code: 'AUTH_REFRESH_TOKEN_INVALID', + }); }); it('throws 401 when token not found in DB', async () => { refreshTokenModel.findValid.mockResolvedValue(null); - await expect(authService.refresh({ refresh_token: 'orphan_tok' })) - .rejects.toMatchObject({ statusCode: 401, code: 'AUTH_REFRESH_TOKEN_INVALID' }); + await expect(authService.refresh({ refresh_token: 'orphan_tok' })).rejects.toMatchObject({ + statusCode: 401, + code: 'AUTH_REFRESH_TOKEN_INVALID', + }); }); it('throws 403 when account suspended', async () => { userModel.findById.mockResolvedValue({ ...fakeUser, is_suspended: true }); - await expect(authService.refresh({ refresh_token: 'tok' })) - .rejects.toMatchObject({ statusCode: 403, code: 'AUTH_ACCOUNT_SUSPENDED' }); + await expect(authService.refresh({ refresh_token: 'tok' })).rejects.toMatchObject({ + statusCode: 403, + code: 'AUTH_ACCOUNT_SUSPENDED', + }); }); it('throws 401 when rotateToken returns null (token already used)', async () => { refreshTokenModel.rotateToken.mockResolvedValue(null); - await expect(authService.refresh({ refresh_token: 'tok' })) - .rejects.toMatchObject({ statusCode: 401, code: 'AUTH_REFRESH_TOKEN_INVALID' }); + await expect(authService.refresh({ refresh_token: 'tok' })).rejects.toMatchObject({ + statusCode: 401, + code: 'AUTH_REFRESH_TOKEN_INVALID', + }); }); it('returns new access and refresh tokens on success', async () => { @@ -235,8 +254,10 @@ describe('verifyEmail', () => { it('throws 400 when token invalid or expired', async () => { verificationTokenModel.findValidToken.mockResolvedValue(null); - await expect(authService.verifyEmail({ token: 'bad_tok' })) - .rejects.toMatchObject({ statusCode: 400, code: 'AUTH_TOKEN_INVALID' }); + await expect(authService.verifyEmail({ token: 'bad_tok' })).rejects.toMatchObject({ + statusCode: 400, + code: 'AUTH_TOKEN_INVALID', + }); }); it('marks token used, marks user verified, returns tokens', async () => { @@ -317,7 +338,11 @@ describe('resetPassword', () => { }); it('updates password and revokes all tokens when logout_all=true', async () => { - await authService.resetPassword({ token: 'valid', new_password: 'NewPassword1', logout_all: true }); + await authService.resetPassword({ + token: 'valid', + new_password: 'NewPassword1', + logout_all: true, + }); expect(userModel.updatePassword).toHaveBeenCalledWith('u1', 'new_hashed'); expect(verificationTokenModel.markUsed).toHaveBeenCalledWith('tok1'); @@ -325,7 +350,11 @@ describe('resetPassword', () => { }); it('does not revoke tokens when logout_all=false', async () => { - await authService.resetPassword({ token: 'valid', new_password: 'NewPassword1', logout_all: false }); + await authService.resetPassword({ + token: 'valid', + new_password: 'NewPassword1', + logout_all: false, + }); expect(refreshTokenModel.revokeAllForUser).not.toHaveBeenCalled(); }); @@ -336,7 +365,7 @@ describe('resetPassword', () => { // ══════════════════════════════════════════════════════════════ describe('changeEmail', () => { beforeEach(() => { - userModel.findByEmail.mockResolvedValue(null); // new email not taken + userModel.findByEmail.mockResolvedValue(null); // new email not taken userModel.findById.mockResolvedValue(fakeUser); verificationTokenModel.revokeAllForUser.mockResolvedValue(); userModel.setPendingEmail.mockResolvedValue(); @@ -406,4 +435,4 @@ describe('deleteAccount', () => { expect(bcrypt.compare).not.toHaveBeenCalled(); expect(userModel.softDeleteWithContent).toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/tests/feed.controller.unit.test.js b/tests/feed.controller.unit.test.js index 50c77d62..2b4de08c 100644 --- a/tests/feed.controller.unit.test.js +++ b/tests/feed.controller.unit.test.js @@ -109,7 +109,11 @@ describe('Feed - Controller', () => { feedService.getTrendingByGenre.mockResolvedValue({ tracks: [] }); await controller.getTrendingByGenre(req, res); - expect(feedService.getTrendingByGenre).toHaveBeenCalledWith(VALID_UUID, { limit: 20, offset: 0 }, null); + expect(feedService.getTrendingByGenre).toHaveBeenCalledWith( + VALID_UUID, + { limit: 20, offset: 0 }, + null + ); }); it('getMoreOfWhatYouLike requires auth', async () => { @@ -336,8 +340,15 @@ describe('Feed - Controller', () => { data: [], pagination: { limit: 50, offset: 0, total: 0 }, }); - await controller.getStationTracks(mkReq({ userId: null, params: { artist_id: VALID_UUID } }), res1); - expect(feedService.getStationTracks).toHaveBeenCalledWith(VALID_UUID, { limit: 50, offset: 0 }, null); + await controller.getStationTracks( + mkReq({ userId: null, params: { artist_id: VALID_UUID } }), + res1 + ); + expect(feedService.getStationTracks).toHaveBeenCalledWith( + VALID_UUID, + { limit: 50, offset: 0 }, + null + ); const res2 = mkRes(); feedService.getArtistsToWatch.mockResolvedValue({ @@ -489,139 +500,133 @@ describe('Feed - Controller', () => { }); it('parsePagination handles NaN values (defaults)', async () => { - const req = mkReq({ - params: { genre_id: VALID_UUID }, - query: { limit: 'abc', offset: 'xyz' }, - }); - const res = mkRes(); - feedService.getTrendingByGenre.mockResolvedValue({}); - - await controller.getTrendingByGenre(req, res); - - expect(feedService.getTrendingByGenre).toHaveBeenCalledWith( - VALID_UUID, - { limit: 20, offset: 0 }, - 'u-1' - ); -}); + const req = mkReq({ + params: { genre_id: VALID_UUID }, + query: { limit: 'abc', offset: 'xyz' }, + }); + const res = mkRes(); + feedService.getTrendingByGenre.mockResolvedValue({}); -it('getHotForYou returns full response body', async () => { - const req = mkReq(); - const res = mkRes(); + await controller.getTrendingByGenre(req, res); - feedService.getHotForYou.mockResolvedValue({ id: 't1' }); + expect(feedService.getTrendingByGenre).toHaveBeenCalledWith( + VALID_UUID, + { limit: 20, offset: 0 }, + 'u-1' + ); + }); - await controller.getHotForYou(req, res); + it('getHotForYou returns full response body', async () => { + const req = mkReq(); + const res = mkRes(); - expect(res.json).toHaveBeenCalledWith({ - data: { id: 't1' }, - message: 'Hot for you track fetched successfully.', - }); -}); + feedService.getHotForYou.mockResolvedValue({ id: 't1' }); -it('getDiscoveryFeedController handles custom limit and cursor', async () => { - const req = mkReq({ query: { limit: '5', cursor: 'abc' } }); - const res = mkRes(); + await controller.getHotForYou(req, res); - feedService.getDiscoveryFeedService.mockResolvedValue({ - data: [], - hasMore: false, - nextCursor: null, + expect(res.json).toHaveBeenCalledWith({ + data: { id: 't1' }, + message: 'Hot for you track fetched successfully.', + }); }); - await controller.getDiscoveryFeedController(req, res); + it('getDiscoveryFeedController handles custom limit and cursor', async () => { + const req = mkReq({ query: { limit: '5', cursor: 'abc' } }); + const res = mkRes(); - expect(feedService.getDiscoveryFeedService).toHaveBeenCalledWith('u-1', 5, 'abc'); -}); + feedService.getDiscoveryFeedService.mockResolvedValue({ + data: [], + hasMore: false, + nextCursor: null, + }); -it('getActivityFeedController handles null cursor explicitly', async () => { - const req = mkReq({ query: {} }); - const res = mkRes(); + await controller.getDiscoveryFeedController(req, res); - feedService.getActivityFeedService.mockResolvedValue({ - data: [], - hasMore: false, - nextCursor: null, + expect(feedService.getDiscoveryFeedService).toHaveBeenCalledWith('u-1', 5, 'abc'); }); - await controller.getActivityFeedController(req, res); + it('getActivityFeedController handles null cursor explicitly', async () => { + const req = mkReq({ query: {} }); + const res = mkRes(); - expect(feedService.getActivityFeedService).toHaveBeenCalledWith('u-1', 20, null); -}); + feedService.getActivityFeedService.mockResolvedValue({ + data: [], + hasMore: false, + nextCursor: null, + }); + + await controller.getActivityFeedController(req, res); -it('getRelatedTracks works with authenticated user', async () => { - const req = mkReq({ - params: { track_id: VALID_UUID }, - query: {}, + expect(feedService.getActivityFeedService).toHaveBeenCalledWith('u-1', 20, null); }); - const res = mkRes(); - feedService.getRelatedTracks.mockResolvedValue({ tracks: [] }); + it('getRelatedTracks works with authenticated user', async () => { + const req = mkReq({ + params: { track_id: VALID_UUID }, + query: {}, + }); + const res = mkRes(); - await controller.getRelatedTracks(req, res); + feedService.getRelatedTracks.mockResolvedValue({ tracks: [] }); - expect(feedService.getRelatedTracks).toHaveBeenCalledWith( - VALID_UUID, - 'u-1', - { limit: 20, offset: 0 } - ); -}); + await controller.getRelatedTracks(req, res); -it('getTrackRadioTracks uses default pagination when no query', async () => { - const req = mkReq({ - params: { playlist_id: VALID_UUID }, - query: {}, + expect(feedService.getRelatedTracks).toHaveBeenCalledWith(VALID_UUID, 'u-1', { + limit: 20, + offset: 0, + }); }); - const res = mkRes(); - - feedService.getTrackRadioTracks.mockResolvedValue({}); - await controller.getTrackRadioTracks(req, res); + it('getTrackRadioTracks uses default pagination when no query', async () => { + const req = mkReq({ + params: { playlist_id: VALID_UUID }, + query: {}, + }); + const res = mkRes(); - expect(feedService.getTrackRadioTracks).toHaveBeenCalledWith( - 'u-1', - VALID_UUID, - { limit: 20, offset: 0 } - ); -}); + feedService.getTrackRadioTracks.mockResolvedValue({}); -it('getArtistsToWatch clamps to max limit', async () => { - const req = mkReq({ query: { limit: '999' } }); - const res = mkRes(); + await controller.getTrackRadioTracks(req, res); - feedService.getArtistsToWatch.mockResolvedValue({ - data: [], - pagination: {}, + expect(feedService.getTrackRadioTracks).toHaveBeenCalledWith('u-1', VALID_UUID, { + limit: 20, + offset: 0, + }); }); - await controller.getArtistsToWatch(req, res); + it('getArtistsToWatch clamps to max limit', async () => { + const req = mkReq({ query: { limit: '999' } }); + const res = mkRes(); - expect(feedService.getArtistsToWatch).toHaveBeenCalledWith( - { limit: 20, offset: 0 }, - 'u-1' - ); -}); + feedService.getArtistsToWatch.mockResolvedValue({ + data: [], + pagination: {}, + }); -it('getStationTracks uses default pagination when no query', async () => { - const req = mkReq({ - params: { artist_id: VALID_UUID }, - query: {}, - }); - const res = mkRes(); + await controller.getArtistsToWatch(req, res); - feedService.getStationTracks.mockResolvedValue({ - station: {}, - data: [], - pagination: {}, + expect(feedService.getArtistsToWatch).toHaveBeenCalledWith({ limit: 20, offset: 0 }, 'u-1'); }); - await controller.getStationTracks(req, res); + it('getStationTracks uses default pagination when no query', async () => { + const req = mkReq({ + params: { artist_id: VALID_UUID }, + query: {}, + }); + const res = mkRes(); - expect(feedService.getStationTracks).toHaveBeenCalledWith( - VALID_UUID, - { limit: 50, offset: 0 }, - 'u-1' - ); -}); + feedService.getStationTracks.mockResolvedValue({ + station: {}, + data: [], + pagination: {}, + }); + await controller.getStationTracks(req, res); + + expect(feedService.getStationTracks).toHaveBeenCalledWith( + VALID_UUID, + { limit: 50, offset: 0 }, + 'u-1' + ); + }); }); diff --git a/tests/feed.model.unit.test.js b/tests/feed.model.unit.test.js index 3826476e..a91e15b1 100644 --- a/tests/feed.model.unit.test.js +++ b/tests/feed.model.unit.test.js @@ -45,9 +45,11 @@ describe('Feed - Model', () => { }); it('getHomeTrendingByGenre falls back to empty tracks when initial genre tracks are non-array', async () => { - db.query.mockResolvedValueOnce({ rows: [{ genre_id: GENRE_UUID, genre_name: 'Pop' }] }).mockResolvedValueOnce({ - rows: null, - }); + db.query + .mockResolvedValueOnce({ rows: [{ genre_id: GENRE_UUID, genre_name: 'Pop' }] }) + .mockResolvedValueOnce({ + rows: null, + }); const out = await model.getHomeTrendingByGenre(20, null); expect(out.initial_tab.tracks).toEqual([]); @@ -104,9 +106,12 @@ describe('Feed - Model', () => { followers_count: null, }; - db.query.mockResolvedValueOnce({ rows: [row] }).mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ - rows: [], - }); + db.query + .mockResolvedValueOnce({ rows: [row] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ + rows: [], + }); const a = await model.getArtistsToWatch(10, null); const ap = await model.getArtistsToWatchPaginated(10, 0, null); @@ -229,11 +234,17 @@ describe('Feed - Model', () => { }); it('album list functions return zero totals for empty rows', async () => { - db.query.mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ - rows: [], - }); + db.query + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ + rows: [], + }); - await expect(model.getAlbumsFromFollowedArtists('u1', 6, 0)).resolves.toEqual({ items: [], total: 0 }); + await expect(model.getAlbumsFromFollowedArtists('u1', 6, 0)).resolves.toEqual({ + items: [], + total: 0, + }); await expect(model.getTopAlbums(6, 0, null)).resolves.toEqual({ items: [], total: 0 }); await expect(model.getAllAlbums(6, 0, null)).resolves.toEqual({ items: [], total: 0 }); }); @@ -858,7 +869,15 @@ describe('Feed - Model', () => { ], }) .mockResolvedValueOnce({ - rows: [{ id: 'u2', username: 'u2', display_name: 'User 2', followers_count: 11, is_verified: true }], + rows: [ + { + id: 'u2', + username: 'u2', + display_name: 'User 2', + followers_count: 11, + is_verified: true, + }, + ], }) .mockResolvedValueOnce({ rows: [ @@ -983,7 +1002,15 @@ describe('Feed - Model', () => { ], }) .mockResolvedValueOnce({ - rows: [{ id: 'u2', username: 'u2', display_name: 'User 2', followers_count: 11, is_verified: true }], + rows: [ + { + id: 'u2', + username: 'u2', + display_name: 'User 2', + followers_count: 11, + is_verified: true, + }, + ], }) .mockResolvedValueOnce({ rows: [ @@ -1088,95 +1115,94 @@ describe('Feed - Model', () => { }); it('getDiscoveryFeed handles invalid cursor (NaN offset)', async () => { - db.query.mockResolvedValueOnce({ rows: [{ track_id: 't1' }] }); + db.query.mockResolvedValueOnce({ rows: [{ track_id: 't1' }] }); - const badCursor = Buffer.from('not-a-number').toString('base64'); - const out = await model.getDiscoveryFeed('u1', 2, badCursor); + const badCursor = Buffer.from('not-a-number').toString('base64'); + const out = await model.getDiscoveryFeed('u1', 2, badCursor); - expect(out.items).toHaveLength(1); - expect(out.hasMore).toBe(false); - expect(out.nextCursor).toBeNull(); -}); + expect(out.items).toHaveLength(1); + expect(out.hasMore).toBe(false); + expect(out.nextCursor).toBeNull(); + }); -it('getDiscoveryFeed returns empty when no rows', async () => { - db.query.mockResolvedValueOnce({ rows: [] }); + it('getDiscoveryFeed returns empty when no rows', async () => { + db.query.mockResolvedValueOnce({ rows: [] }); - const out = await model.getDiscoveryFeed('u1', 10, null); + const out = await model.getDiscoveryFeed('u1', 10, null); - expect(out).toEqual({ - items: [], - hasMore: false, - nextCursor: null, + expect(out).toEqual({ + items: [], + hasMore: false, + nextCursor: null, + }); }); -}); -it('getDailyTracks handles explicit null viewerUserId', async () => { - db.query.mockResolvedValueOnce({ rows: [{ id: 't1' }] }); + it('getDailyTracks handles explicit null viewerUserId', async () => { + db.query.mockResolvedValueOnce({ rows: [{ id: 't1' }] }); - const out = await model.getDailyTracks(5, null); + const out = await model.getDailyTracks(5, null); - expect(out).toEqual([{ id: 't1' }]); -}); - -it('getWeeklyTracks returns empty array when no rows', async () => { - db.query.mockResolvedValueOnce({ rows: [] }); + expect(out).toEqual([{ id: 't1' }]); + }); - const out = await model.getWeeklyTracks('u1', 10); + it('getWeeklyTracks returns empty array when no rows', async () => { + db.query.mockResolvedValueOnce({ rows: [] }); - expect(out).toEqual([]); -}); + const out = await model.getWeeklyTracks('u1', 10); -it('getTracksByArtistId handles missing total_count safely', async () => { - db.query.mockResolvedValueOnce({ - rows: [{ id: 't1' }], // no total_count + expect(out).toEqual([]); }); - const out = await model.getTracksByArtistId('a1', 10, 0, null); + it('getTracksByArtistId handles missing total_count safely', async () => { + db.query.mockResolvedValueOnce({ + rows: [{ id: 't1' }], // no total_count + }); - expect(out.total).toBeNaN(); // exposes branch - expect(out.items).toHaveLength(1); -}); + const out = await model.getTracksByArtistId('a1', 10, 0, null); -it('getMoreOfWhatYouLike returns empty result correctly', async () => { - db.query.mockResolvedValueOnce({ rows: [] }); + expect(out.total).toBeNaN(); // exposes branch + expect(out.items).toHaveLength(1); + }); - const out = await model.getMoreOfWhatYouLike('u1', 10, 0); + it('getMoreOfWhatYouLike returns empty result correctly', async () => { + db.query.mockResolvedValueOnce({ rows: [] }); - expect(out).toEqual({ - items: [], - total: 0, - source: 'trending_fallback', - }); -}); + const out = await model.getMoreOfWhatYouLike('u1', 10, 0); -it('getTopPreviewTracksByGenreIds returns empty for invalid input', async () => { - const out = await model.getTopPreviewTracksByGenreIds(null, null); - expect(out).toEqual([]); -}); + expect(out).toEqual({ + items: [], + total: 0, + source: 'trending_fallback', + }); + }); -it('getFirstPreviewTracksByAlbumIds returns empty for invalid input', async () => { - const out = await model.getFirstPreviewTracksByAlbumIds(null, null); - expect(out).toEqual([]); -}); + it('getTopPreviewTracksByGenreIds returns empty for invalid input', async () => { + const out = await model.getTopPreviewTracksByGenreIds(null, null); + expect(out).toEqual([]); + }); -it('getActivityFeed returns hasMore=false when rows <= limit', async () => { - db.query.mockResolvedValueOnce({ - rows: [ - { - type: 'track_post', - occurred_at: new Date(), - actor_id: null, - track_id: null, - playlist_id: null, - sort_id: 's1', - }, - ], + it('getFirstPreviewTracksByAlbumIds returns empty for invalid input', async () => { + const out = await model.getFirstPreviewTracksByAlbumIds(null, null); + expect(out).toEqual([]); }); - const out = await model.getActivityFeed('u1', 5, null); + it('getActivityFeed returns hasMore=false when rows <= limit', async () => { + db.query.mockResolvedValueOnce({ + rows: [ + { + type: 'track_post', + occurred_at: new Date(), + actor_id: null, + track_id: null, + playlist_id: null, + sort_id: 's1', + }, + ], + }); - expect(out.hasMore).toBe(false); - expect(out.nextCursor).toBeNull(); -}); + const out = await model.getActivityFeed('u1', 5, null); + expect(out.hasMore).toBe(false); + expect(out.nextCursor).toBeNull(); + }); }); diff --git a/tests/feed.service.unit.test.js b/tests/feed.service.unit.test.js index 501f66fb..86f2c292 100644 --- a/tests/feed.service.unit.test.js +++ b/tests/feed.service.unit.test.js @@ -130,20 +130,26 @@ describe('Feed - Service', () => { ]); expect(testables.decorateTracksWithLikedState(null, new Set())).toEqual([]); - expect(testables.decorateTracksWithLikedState([{ id: 't1' }], null)[0].is_liked_by_me).toBe(false); - expect(testables.decorateTracksWithLikedState([{ id: 't1' }], new Set(['t1']))[0].is_liked_by_me).toBe( - true + expect(testables.decorateTracksWithLikedState([{ id: 't1' }], null)[0].is_liked_by_me).toBe( + false ); + expect( + testables.decorateTracksWithLikedState([{ id: 't1' }], new Set(['t1']))[0].is_liked_by_me + ).toBe(true); expect(testables.decorateTrackWithLikedState(null, new Set())).toBeNull(); expect(testables.decorateTrackWithLikedState({ id: 't1' }, null).is_liked_by_me).toBe(false); - expect(testables.decorateTrackWithLikedState({ id: 't1' }, new Set(['t1'])).is_liked_by_me).toBe( - true - ); + expect( + testables.decorateTrackWithLikedState({ id: 't1' }, new Set(['t1'])).is_liked_by_me + ).toBe(true); }); it('station and mix helpers cover empty, fallback, and diversity branches', async () => { - expect(testables.buildStationImages([], null)).toEqual({ left: null, center: null, right: null }); + expect(testables.buildStationImages([], null)).toEqual({ + left: null, + center: null, + right: null, + }); expect(testables.buildStationPayload(null, [])).toBeNull(); expect(testables.buildStationPayload({ id: 's1' }, [])).toBeNull(); @@ -173,7 +179,9 @@ describe('Feed - Service', () => { expect(testables.generateMixTitle([{ genre_name: 'Hip-Hop & Rap' }])).toBe('Energy Boost'); expect(testables.generateMixTitle([{ genre_name: 'Lo-Fi' }])).toBe('Chill Vibes'); expect(testables.generateMixTitle([{ genre_name: 'Jazz' }])).toBe('Late Night Jazz'); - expect(testables.generateMixTitle([{ genre_name: 'Pop' }, { genre_name: 'Rock' }])).toBe('Pop & Rock'); + expect(testables.generateMixTitle([{ genre_name: 'Pop' }, { genre_name: 'Rock' }])).toBe( + 'Pop & Rock' + ); expect(testables.generateMixTitle([{ genre_name: 'Pop' }])).toBe('Pop Mix'); expect(testables.generateMixTitle([])).toBe('Curated Mix'); @@ -226,7 +234,10 @@ describe('Feed - Service', () => { created_at: '2026-01-01', }, ]); - const attached = await testables.attachAlbumPreviewTracks([{ id: 'a1', name: 'Album 1' }], 'u-1'); + const attached = await testables.attachAlbumPreviewTracks( + [{ id: 'a1', name: 'Album 1' }], + 'u-1' + ); expect(attached[0].preview_track.id).toBe('t1'); expect(attached[0].preview_track.album_id).toBeUndefined(); expect(consoleSpy).toHaveBeenCalled(); @@ -236,7 +247,10 @@ describe('Feed - Service', () => { } feedModel.getAlbumsFromFollowedArtists.mockResolvedValueOnce({ items: [], total: 0 }); - feedModel.getTopAlbums.mockResolvedValueOnce({ items: [{ id: 'a2', name: 'Album 2' }], total: 1 }); + feedModel.getTopAlbums.mockResolvedValueOnce({ + items: [{ id: 'a2', name: 'Album 2' }], + total: 1, + }); feedModel.getFirstPreviewTracksByAlbumIds.mockResolvedValueOnce([]); const homeAlbums = await testables.buildHomeAlbumsForYou('u-1'); expect(homeAlbums[0].id).toBe('a2'); @@ -608,9 +622,13 @@ describe('Feed - Service', () => { feedModel.getHomeTrendingByGenre.mockResolvedValue({ genres: [], initial_tab: null }); feedModel.getArtistsToWatch.mockResolvedValue([]); feedModel.getDiscoverWithStations.mockResolvedValue([]); - feedModel.getMoreOfWhatYouLike.mockResolvedValue({ items: [{ id: VALID_UUID, user_id: 'a1' }] }); + feedModel.getMoreOfWhatYouLike.mockResolvedValue({ + items: [{ id: VALID_UUID, user_id: 'a1' }], + }); feedModel.getDailyTracks.mockResolvedValue([{ id: VALID_UUID, user_id: 'a1' }]); - feedModel.getWeeklyTracks.mockResolvedValue([{ id: VALID_UUID, source_rank: 1, user_id: 'a1' }]); + feedModel.getWeeklyTracks.mockResolvedValue([ + { id: VALID_UUID, source_rank: 1, user_id: 'a1' }, + ]); feedModel.getAlbumsFromFollowedArtists.mockResolvedValue({ items: [], total: 0 }); feedModel.getTopAlbums.mockResolvedValue({ items: [], total: 0 }); feedModel.getAllAlbums.mockResolvedValue({ items: [], total: 0 }); @@ -627,12 +645,42 @@ describe('Feed - Service', () => { { genre_id: '11111111-1111-1111-1111-111111111117', genre_name: 'Genre 8' }, ]); feedModel.getTopPreviewTracksByGenreIds.mockResolvedValue([ - { genre_id: '11111111-1111-1111-1111-111111111110', id: 'g1', cover_image: 'c1', user_id: 'a1' }, - { genre_id: '11111111-1111-1111-1111-111111111111', id: 'g2', cover_image: 'c2', user_id: 'a1' }, - { genre_id: '11111111-1111-1111-1111-111111111112', id: 'g3', cover_image: 'c3', user_id: 'a1' }, - { genre_id: '11111111-1111-1111-1111-111111111113', id: 'g4', cover_image: 'c4', user_id: 'a1' }, - { genre_id: '11111111-1111-1111-1111-111111111114', id: 'g5', cover_image: 'c5', user_id: 'a1' }, - { genre_id: '11111111-1111-1111-1111-111111111115', id: 'g6', cover_image: 'c6', user_id: 'a1' }, + { + genre_id: '11111111-1111-1111-1111-111111111110', + id: 'g1', + cover_image: 'c1', + user_id: 'a1', + }, + { + genre_id: '11111111-1111-1111-1111-111111111111', + id: 'g2', + cover_image: 'c2', + user_id: 'a1', + }, + { + genre_id: '11111111-1111-1111-1111-111111111112', + id: 'g3', + cover_image: 'c3', + user_id: 'a1', + }, + { + genre_id: '11111111-1111-1111-1111-111111111113', + id: 'g4', + cover_image: 'c4', + user_id: 'a1', + }, + { + genre_id: '11111111-1111-1111-1111-111111111114', + id: 'g5', + cover_image: 'c5', + user_id: 'a1', + }, + { + genre_id: '11111111-1111-1111-1111-111111111115', + id: 'g6', + cover_image: 'c6', + user_id: 'a1', + }, ]); playlistModel.findOrCreateGenreMixPlaylist.mockImplementation(async (_userId, genreId) => ({ genre_id: genreId, @@ -954,8 +1002,16 @@ describe('Feed - Service', () => { for (let artistIndex = 2; artistIndex <= 15; artistIndex += 1) { tracks.push( - { id: `artist-${artistIndex}-track-1`, user_id: `artist-${artistIndex}`, cover_image: `c${artistIndex}a` }, - { id: `artist-${artistIndex}-track-2`, user_id: `artist-${artistIndex}`, cover_image: `c${artistIndex}b` } + { + id: `artist-${artistIndex}-track-1`, + user_id: `artist-${artistIndex}`, + cover_image: `c${artistIndex}a`, + }, + { + id: `artist-${artistIndex}-track-2`, + user_id: `artist-${artistIndex}`, + cover_image: `c${artistIndex}b`, + } ); } @@ -1244,99 +1300,99 @@ describe('Feed - Service', () => { expect(out.tracks).toHaveLength(1); }); it('parseGenreIdFromMixId returns null for uuid explicitly', () => { - expect(feedService.parseGenreIdFromMixId(VALID_UUID)).toBeNull(); -}); - -it('getMoreOfWhatYouLike handles empty items', async () => { - feedModel.getMoreOfWhatYouLike.mockResolvedValue({ - items: [], - total: 0, - source: 'fallback', + expect(feedService.parseGenreIdFromMixId(VALID_UUID)).toBeNull(); }); - const out = await feedService.getMoreOfWhatYouLike('u-1', { limit: 10, offset: 0 }); - - expect(out.data).toEqual([]); - expect(out.pagination.total).toBe(0); -}); + it('getMoreOfWhatYouLike handles empty items', async () => { + feedModel.getMoreOfWhatYouLike.mockResolvedValue({ + items: [], + total: 0, + source: 'fallback', + }); -it('getDailyMix throws when user not found', async () => { - userModel.findById.mockResolvedValueOnce(null); + const out = await feedService.getMoreOfWhatYouLike('u-1', { limit: 10, offset: 0 }); - await expect(feedService.getDailyMix('bad-user')).rejects.toMatchObject({ - code: 'RESOURCE_NOT_FOUND', + expect(out.data).toEqual([]); + expect(out.pagination.total).toBe(0); }); -}); -it('getWeeklyMix throws when user not found', async () => { - userModel.findById.mockResolvedValueOnce(null); + it('getDailyMix throws when user not found', async () => { + userModel.findById.mockResolvedValueOnce(null); - await expect(feedService.getWeeklyMix('bad-user')).rejects.toMatchObject({ - code: 'RESOURCE_NOT_FOUND', + await expect(feedService.getDailyMix('bad-user')).rejects.toMatchObject({ + code: 'RESOURCE_NOT_FOUND', + }); }); -}); -it('listStations returns empty when no stations', async () => { - feedModel.getStationsPaginated.mockResolvedValue({ items: [], total: 0 }); + it('getWeeklyMix throws when user not found', async () => { + userModel.findById.mockResolvedValueOnce(null); - const out = await feedService.listStations({ limit: 10, offset: 0 }, null); + await expect(feedService.getWeeklyMix('bad-user')).rejects.toMatchObject({ + code: 'RESOURCE_NOT_FOUND', + }); + }); - expect(out.data).toEqual([]); - expect(out.pagination.total).toBe(0); -}); + it('listStations returns empty when no stations', async () => { + feedModel.getStationsPaginated.mockResolvedValue({ items: [], total: 0 }); -it('getArtistsToWatch handles empty result', async () => { - feedModel.getArtistsToWatchPaginated.mockResolvedValue({ items: [], total: 0 }); + const out = await feedService.listStations({ limit: 10, offset: 0 }, null); - const out = await feedService.getArtistsToWatch({ limit: 10, offset: 0 }, null); + expect(out.data).toEqual([]); + expect(out.pagination.total).toBe(0); + }); - expect(out.data).toEqual([]); -}); + it('getArtistsToWatch handles empty result', async () => { + feedModel.getArtistsToWatchPaginated.mockResolvedValue({ items: [], total: 0 }); -it('decorateHomeItems returns payload unchanged when no userId', async () => { - const payload = { test: true }; + const out = await feedService.getArtistsToWatch({ limit: 10, offset: 0 }, null); - const result = await testables.decorateHomeItems(null, payload); + expect(out.data).toEqual([]); + }); - expect(result).toEqual(payload); -}); + it('decorateHomeItems returns payload unchanged when no userId', async () => { + const payload = { test: true }; -it('getHotForYou throws when no personalized and no fallback', async () => { - feedModel.getDailyTracks.mockResolvedValue([]); - feedModel.getMoreOfWhatYouLike.mockResolvedValue({ items: [] }); + const result = await testables.decorateHomeItems(null, payload); - await expect(feedService.getHotForYou('u-1')).rejects.toMatchObject({ - code: 'RESOURCE_NOT_FOUND', + expect(result).toEqual(payload); }); -}); -it('getDiscoveryFeedService covers new_release reason label', async () => { - feedModel.getDiscoveryFeed.mockResolvedValue({ - items: [ - { - track_id: 't1', - title: 'T1', - duration: 1, - play_count: 0, - like_count: 0, - cover_image: null, - preview_url: null, - stream_url: null, - audio_url: null, - artist_id: 'a1', - artist_username: 'artist', - artist_profile_picture: null, - reason_type: 'new_release', - source_name: 'Artist X', - source_id: 'a1', - }, - ], - hasMore: false, - nextCursor: null, + it('getHotForYou throws when no personalized and no fallback', async () => { + feedModel.getDailyTracks.mockResolvedValue([]); + feedModel.getMoreOfWhatYouLike.mockResolvedValue({ items: [] }); + + await expect(feedService.getHotForYou('u-1')).rejects.toMatchObject({ + code: 'RESOURCE_NOT_FOUND', + }); }); - const out = await feedService.getDiscoveryFeedService('u-1'); + it('getDiscoveryFeedService covers new_release reason label', async () => { + feedModel.getDiscoveryFeed.mockResolvedValue({ + items: [ + { + track_id: 't1', + title: 'T1', + duration: 1, + play_count: 0, + like_count: 0, + cover_image: null, + preview_url: null, + stream_url: null, + audio_url: null, + artist_id: 'a1', + artist_username: 'artist', + artist_profile_picture: null, + reason_type: 'new_release', + source_name: 'Artist X', + source_id: 'a1', + }, + ], + hasMore: false, + nextCursor: null, + }); - expect(out.data[0].reason.label).toBe('New release by Artist X'); -}); + const out = await feedService.getDiscoveryFeedService('u-1'); + + expect(out.data[0].reason.label).toBe('New release by Artist X'); + }); }); diff --git a/tests/genre.controller.unit.test.js b/tests/genre.controller.unit.test.js index f628c314..8e42937f 100644 --- a/tests/genre.controller.unit.test.js +++ b/tests/genre.controller.unit.test.js @@ -57,7 +57,9 @@ describe('getAllGenres', () => { await controller.getAllGenres(req, res); - expect(genresService.getAllGenres).toHaveBeenCalledWith(expect.objectContaining({ limit: 100 })); + expect(genresService.getAllGenres).toHaveBeenCalledWith( + expect.objectContaining({ limit: 100 }) + ); }); it('clamps limit to a minimum of 1', async () => { @@ -164,7 +166,11 @@ describe('getGenrePage', () => { await controller.getGenrePage(req, res); - expect(api.success).toHaveBeenCalledWith(res, pageData, 'Genre page data fetched successfully.'); + expect(api.success).toHaveBeenCalledWith( + res, + pageData, + 'Genre page data fetched successfully.' + ); }); it('bubbles service errors', async () => { @@ -429,4 +435,4 @@ describe('getGenreArtists', () => { await expect(controller.getGenreArtists(req, res)).rejects.toThrow('service error'); }); -}); \ No newline at end of file +}); diff --git a/tests/genre.model.unit.test.js b/tests/genre.model.unit.test.js index b004c112..1fde8adf 100644 --- a/tests/genre.model.unit.test.js +++ b/tests/genre.model.unit.test.js @@ -118,11 +118,14 @@ describe('genre.model — findGenreDetail', () => { describe('genrediscovery.model — findGenreTracks', () => { it('returns tracks and total count', async () => { db.query - .mockResolvedValueOnce({ rows: [fakeTrack] }) // tracks query - .mockResolvedValueOnce({ rows: [{ total: '42' }] }); // count query + .mockResolvedValueOnce({ rows: [fakeTrack] }) // tracks query + .mockResolvedValueOnce({ rows: [{ total: '42' }] }); // count query const result = await discoveryModel.findGenreTracks({ - genreId: 'g1', limit: 12, offset: 0, sort: 'newest', + genreId: 'g1', + limit: 12, + offset: 0, + sort: 'newest', }); expect(result.tracks).toEqual([fakeTrack]); @@ -131,12 +134,13 @@ describe('genrediscovery.model — findGenreTracks', () => { }); it('uses play_count order for sort=popular', async () => { - db.query - .mockResolvedValueOnce({ rows: [] }) - .mockResolvedValueOnce({ rows: [{ total: '0' }] }); + db.query.mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ rows: [{ total: '0' }] }); await discoveryModel.findGenreTracks({ - genreId: 'g1', limit: 12, offset: 0, sort: 'popular', + genreId: 'g1', + limit: 12, + offset: 0, + sort: 'popular', }); const firstCall = db.query.mock.calls[0][0]; @@ -144,12 +148,13 @@ describe('genrediscovery.model — findGenreTracks', () => { }); it('uses created_at order for sort=newest', async () => { - db.query - .mockResolvedValueOnce({ rows: [] }) - .mockResolvedValueOnce({ rows: [{ total: '0' }] }); + db.query.mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ rows: [{ total: '0' }] }); await discoveryModel.findGenreTracks({ - genreId: 'g1', limit: 12, offset: 0, sort: 'newest', + genreId: 'g1', + limit: 12, + offset: 0, + sort: 'newest', }); const firstCall = db.query.mock.calls[0][0]; @@ -157,12 +162,13 @@ describe('genrediscovery.model — findGenreTracks', () => { }); it('returns total 0 when count row is missing', async () => { - db.query - .mockResolvedValueOnce({ rows: [] }) - .mockResolvedValueOnce({ rows: [{}] }); + db.query.mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ rows: [{}] }); const result = await discoveryModel.findGenreTracks({ - genreId: 'g1', limit: 12, offset: 0, sort: 'newest', + genreId: 'g1', + limit: 12, + offset: 0, + sort: 'newest', }); expect(result.total).toBe(0); @@ -182,16 +188,11 @@ describe('genrediscovery.model — findGenreAlbums', () => { expect(result.albums).toEqual([fakeAlbum]); expect(result.total).toBe(3); - expect(db.query).toHaveBeenCalledWith( - expect.any(String), - ['g1', 12, 0] - ); + expect(db.query).toHaveBeenCalledWith(expect.any(String), ['g1', 12, 0]); }); it('returns empty albums and 0 total when none found', async () => { - db.query - .mockResolvedValueOnce({ rows: [] }) - .mockResolvedValueOnce({ rows: [{ total: '0' }] }); + db.query.mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ rows: [{ total: '0' }] }); const result = await discoveryModel.findGenreAlbums({ genreId: 'g1', limit: 12, offset: 0 }); @@ -216,9 +217,7 @@ describe('genrediscovery.model — findGenrePlaylists', () => { }); it('returns empty when no playlists match', async () => { - db.query - .mockResolvedValueOnce({ rows: [] }) - .mockResolvedValueOnce({ rows: [{ total: '0' }] }); + db.query.mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ rows: [{ total: '0' }] }); const result = await discoveryModel.findGenrePlaylists({ genreId: 'g1', limit: 4, offset: 0 }); @@ -237,30 +236,28 @@ describe('genrediscovery.model — findGenreArtists', () => { .mockResolvedValueOnce({ rows: [{ total: '1' }] }); const result = await discoveryModel.findGenreArtists({ - genreId: 'g1', limit: 12, offset: 0, currentUserId: null, + genreId: 'g1', + limit: 12, + offset: 0, + currentUserId: null, }); expect(result.artists).toEqual([fakeArtist]); expect(result.total).toBe(1); - expect(db.query).toHaveBeenCalledWith( - expect.any(String), - ['g1', 12, 0, null] - ); + expect(db.query).toHaveBeenCalledWith(expect.any(String), ['g1', 12, 0, null]); }); it('passes currentUserId as $4 for follow status', async () => { - db.query - .mockResolvedValueOnce({ rows: [] }) - .mockResolvedValueOnce({ rows: [{ total: '0' }] }); + db.query.mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ rows: [{ total: '0' }] }); await discoveryModel.findGenreArtists({ - genreId: 'g1', limit: 12, offset: 0, currentUserId: 'u1', + genreId: 'g1', + limit: 12, + offset: 0, + currentUserId: 'u1', }); - expect(db.query).toHaveBeenCalledWith( - expect.any(String), - ['g1', 12, 0, 'u1'] - ); + expect(db.query).toHaveBeenCalledWith(expect.any(String), ['g1', 12, 0, 'u1']); }); }); @@ -293,4 +290,4 @@ describe('genrediscovery.model — getPlaylistPreviewTracks', () => { expect(db.query).toHaveBeenCalledWith(expect.any(String), ['p1', 2]); }); -}); \ No newline at end of file +}); diff --git a/tests/genre.service.unit.test.js b/tests/genre.service.unit.test.js index 39572f02..2a15bed3 100644 --- a/tests/genre.service.unit.test.js +++ b/tests/genre.service.unit.test.js @@ -130,9 +130,10 @@ describe('getGenrePage', () => { it('throws 404 when genre not found', async () => { genreModel.findGenreDetail.mockResolvedValue(null); - await expect( - genreService.getGenrePage({ genreId: 'ghost' }) - ).rejects.toMatchObject({ statusCode: 404, code: 'RESOURCE_NOT_FOUND' }); + await expect(genreService.getGenrePage({ genreId: 'ghost' })).rejects.toMatchObject({ + statusCode: 404, + code: 'RESOURCE_NOT_FOUND', + }); }); it('returns genre page with formatted sections', async () => { @@ -191,9 +192,10 @@ describe('getGenreTracks', () => { it('throws 404 when genre not found', async () => { genreModel.findGenreDetail.mockResolvedValue(null); - await expect( - genreService.getGenreTracks({ genreId: 'ghost' }) - ).rejects.toMatchObject({ statusCode: 404, code: 'RESOURCE_NOT_FOUND' }); + await expect(genreService.getGenreTracks({ genreId: 'ghost' })).rejects.toMatchObject({ + statusCode: 404, + code: 'RESOURCE_NOT_FOUND', + }); }); it('returns formatted tracks and correct pagination', async () => { @@ -247,9 +249,10 @@ describe('getGenreAlbums', () => { it('throws 404 when genre not found', async () => { genreModel.findGenreDetail.mockResolvedValue(null); - await expect( - genreService.getGenreAlbums({ genreId: 'ghost' }) - ).rejects.toMatchObject({ statusCode: 404, code: 'RESOURCE_NOT_FOUND' }); + await expect(genreService.getGenreAlbums({ genreId: 'ghost' })).rejects.toMatchObject({ + statusCode: 404, + code: 'RESOURCE_NOT_FOUND', + }); }); it('returns formatted albums and pagination', async () => { @@ -280,9 +283,10 @@ describe('getGenrePlaylists', () => { it('throws 404 when genre not found', async () => { genreModel.findGenreDetail.mockResolvedValue(null); - await expect( - genreService.getGenrePlaylists({ genreId: 'ghost' }) - ).rejects.toMatchObject({ statusCode: 404, code: 'RESOURCE_NOT_FOUND' }); + await expect(genreService.getGenrePlaylists({ genreId: 'ghost' })).rejects.toMatchObject({ + statusCode: 404, + code: 'RESOURCE_NOT_FOUND', + }); }); it('returns formatted playlists and pagination', async () => { @@ -306,9 +310,10 @@ describe('getGenreArtists', () => { it('throws 404 when genre not found', async () => { genreModel.findGenreDetail.mockResolvedValue(null); - await expect( - genreService.getGenreArtists({ genreId: 'ghost' }) - ).rejects.toMatchObject({ statusCode: 404, code: 'RESOURCE_NOT_FOUND' }); + await expect(genreService.getGenreArtists({ genreId: 'ghost' })).rejects.toMatchObject({ + statusCode: 404, + code: 'RESOURCE_NOT_FOUND', + }); }); it('returns formatted artists and pagination', async () => { @@ -325,10 +330,15 @@ describe('getGenreArtists', () => { }); it('passes currentUserId to the model', async () => { - await genreService.getGenreArtists({ genreId: 'g1', limit: 10, offset: 0, currentUserId: 'u2' }); + await genreService.getGenreArtists({ + genreId: 'g1', + limit: 10, + offset: 0, + currentUserId: 'u2', + }); expect(discoveryModel.findGenreArtists).toHaveBeenCalledWith( expect.objectContaining({ currentUserId: 'u2' }) ); }); -}); \ No newline at end of file +}); diff --git a/tests/playback/geo-restrictions.test.js b/tests/playback/geo-restrictions.test.js index 8b4b4974..d167c907 100644 --- a/tests/playback/geo-restrictions.test.js +++ b/tests/playback/geo-restrictions.test.js @@ -36,20 +36,14 @@ describe('geo-restrictions utility', () => { isTrackGeoBlocked({ geo_restriction_type: 'exclusive_regions', geo_regions: ['eg'] }, 'EG') ).toBe(false); expect( - isTrackGeoBlocked( - { geo_restriction_type: 'exclusive_regions', geo_regions: '["US"]' }, - 'EG' - ) + isTrackGeoBlocked({ geo_restriction_type: 'exclusive_regions', geo_regions: '["US"]' }, 'EG') ).toBe(true); expect( isTrackGeoBlocked({ geo_restriction_type: 'exclusive_regions', geo_regions: ['US'] }, null) ).toBe(true); expect( - isTrackGeoBlocked( - { geo_restriction_type: 'blocked_regions', geo_regions: '["EG"]' }, - 'EG' - ) + isTrackGeoBlocked({ geo_restriction_type: 'blocked_regions', geo_regions: '["EG"]' }, 'EG') ).toBe(true); expect( isTrackGeoBlocked({ geo_restriction_type: 'blocked_regions', geo_regions: null }, 'EG') diff --git a/tests/playback/playback.controller.test.js b/tests/playback/playback.controller.test.js index 2b5ec4c1..af4c8ea0 100644 --- a/tests/playback/playback.controller.test.js +++ b/tests/playback/playback.controller.test.js @@ -793,20 +793,17 @@ describe('playback.controller', () => { }, { queue: [] }, ], - ])('%s includes normalized countryCode when X-Country-Code is valid', async ( - controllerMethod, - serviceMethod, - req, - expectedPayload, - serviceResult - ) => { - const res = mkRes(); - playbackService[serviceMethod].mockResolvedValue(serviceResult); + ])( + '%s includes normalized countryCode when X-Country-Code is valid', + async (controllerMethod, serviceMethod, req, expectedPayload, serviceResult) => { + const res = mkRes(); + playbackService[serviceMethod].mockResolvedValue(serviceResult); - await controller[controllerMethod](req, res); + await controller[controllerMethod](req, res); - expect(playbackService[serviceMethod]).toHaveBeenCalledWith(expectedPayload); - }); + expect(playbackService[serviceMethod]).toHaveBeenCalledWith(expectedPayload); + } + ); it('omits countryCode when X-Country-Code is invalid', async () => { const req = { diff --git a/tests/resolve.controller.unit.test.js b/tests/resolve.controller.unit.test.js index af2a2688..c33020f2 100644 --- a/tests/resolve.controller.unit.test.js +++ b/tests/resolve.controller.unit.test.js @@ -163,4 +163,4 @@ describe('resolve — server errors', () => { }) ); }); -}); \ No newline at end of file +}); diff --git a/tests/resolve.model.unit.test.js b/tests/resolve.model.unit.test.js index a09a2629..1c18a20a 100644 --- a/tests/resolve.model.unit.test.js +++ b/tests/resolve.model.unit.test.js @@ -135,4 +135,4 @@ describe('userExists', () => { const sql = db.query.mock.calls[0][0]; expect(sql).toMatch(/username/i); }); -}); \ No newline at end of file +}); diff --git a/tests/resolve.service.unit.test.js b/tests/resolve.service.unit.test.js index 8e857809..0f1aa76c 100644 --- a/tests/resolve.service.unit.test.js +++ b/tests/resolve.service.unit.test.js @@ -190,4 +190,4 @@ describe('resolve — ambiguous UUID paths', () => { expect(result).toEqual({ type: 'album', id: OTHER_UUID }); }); -}); \ No newline at end of file +}); diff --git a/tests/search.controller.unit.test.js b/tests/search.controller.unit.test.js index 970f2c50..212cbd00 100644 --- a/tests/search.controller.unit.test.js +++ b/tests/search.controller.unit.test.js @@ -216,9 +216,7 @@ describe('search — tag and location filters', () => { await search(req, res); expect(res.status).toHaveBeenCalledWith(200); - expect(searchService.search).toHaveBeenCalledWith( - expect.objectContaining({ tag: 'indie' }) - ); + expect(searchService.search).toHaveBeenCalledWith(expect.objectContaining({ tag: 'indie' })); }); }); @@ -472,4 +470,4 @@ describe('suggestions — success', () => { expect(res.status).toHaveBeenCalledWith(500); expect(res.json.mock.calls[0][0].error.code).toBe('INTERNAL_SERVER_ERROR'); }); -}); \ No newline at end of file +}); diff --git a/tests/search.model.unit.test.js b/tests/search.model.unit.test.js index 6f8ebafb..813945cc 100644 --- a/tests/search.model.unit.test.js +++ b/tests/search.model.unit.test.js @@ -10,25 +10,47 @@ beforeEach(() => jest.clearAllMocks()); // ── Shared stubs ───────────────────────────────────────────── const fakeTrackRow = { - id: 't1', title: 'Blue Note', cover_image: '/img/t1.jpg', - user_id: 'u1', artist_name: 'Miles Davis', artist_username: 'miles', - genre_name: 'Jazz', duration: 180, play_count: 100, like_count: 20, - repost_count: 5, stream_url: 'https://stream/t1', - created_at: new Date().toISOString(), score: 0.9, total_count: '1', + id: 't1', + title: 'Blue Note', + cover_image: '/img/t1.jpg', + user_id: 'u1', + artist_name: 'Miles Davis', + artist_username: 'miles', + genre_name: 'Jazz', + duration: 180, + play_count: 100, + like_count: 20, + repost_count: 5, + stream_url: 'https://stream/t1', + created_at: new Date().toISOString(), + score: 0.9, + total_count: '1', }; const fakeUserRow = { - id: 'u1', display_name: 'Miles Davis', username: 'miles', - profile_picture: null, followers_count: 500, - city: 'Cairo', country: 'EG', created_at: new Date().toISOString(), - score: 0.8, total_count: '1', + id: 'u1', + display_name: 'Miles Davis', + username: 'miles', + profile_picture: null, + followers_count: 500, + city: 'Cairo', + country: 'EG', + created_at: new Date().toISOString(), + score: 0.8, + total_count: '1', }; const fakePlaylistRow = { - id: 'p1', name: 'Jazz Vibes', cover_image: null, - owner_id: 'u1', owner_display_name: 'Miles Davis', owner_username: 'miles', - track_count: 10, created_at: new Date().toISOString(), - score: 0.7, total_count: '1', + id: 'p1', + name: 'Jazz Vibes', + cover_image: null, + owner_id: 'u1', + owner_display_name: 'Miles Davis', + owner_username: 'miles', + track_count: 10, + created_at: new Date().toISOString(), + score: 0.7, + total_count: '1', }; // ══════════════════════════════════════════════════════════════ @@ -39,7 +61,11 @@ describe('searchTracks', () => { db.query.mockResolvedValueOnce({ rows: [fakeTrackRow] }); const result = await model.searchTracks({ - q: 'jazz', sort: 'relevance', limit: 20, offset: 0, threshold: 0.2, + q: 'jazz', + sort: 'relevance', + limit: 20, + offset: 0, + threshold: 0.2, }); expect(result.rows).toEqual([fakeTrackRow]); @@ -54,7 +80,11 @@ describe('searchTracks', () => { db.query.mockResolvedValueOnce({ rows: [] }); const result = await model.searchTracks({ - q: 'nothing', sort: 'relevance', limit: 20, offset: 0, threshold: 0.2, + q: 'nothing', + sort: 'relevance', + limit: 20, + offset: 0, + threshold: 0.2, }); expect(result.rows).toEqual([]); @@ -65,7 +95,11 @@ describe('searchTracks', () => { db.query.mockResolvedValueOnce({ rows: [] }); await model.searchTracks({ - q: 'jazz', sort: 'plays', limit: 20, offset: 0, threshold: 0.2, + q: 'jazz', + sort: 'plays', + limit: 20, + offset: 0, + threshold: 0.2, }); expect(db.query).toHaveBeenCalledWith( @@ -78,7 +112,11 @@ describe('searchTracks', () => { db.query.mockResolvedValueOnce({ rows: [] }); await model.searchTracks({ - q: 'jazz', sort: 'newest', limit: 20, offset: 0, threshold: 0.2, + q: 'jazz', + sort: 'newest', + limit: 20, + offset: 0, + threshold: 0.2, }); expect(db.query).toHaveBeenCalledWith( @@ -91,7 +129,12 @@ describe('searchTracks', () => { db.query.mockResolvedValueOnce({ rows: [] }); await model.searchTracks({ - q: 'jazz', sort: 'relevance', limit: 20, offset: 0, threshold: 0.2, tag: 'blues', + q: 'jazz', + sort: 'relevance', + limit: 20, + offset: 0, + threshold: 0.2, + tag: 'blues', }); const params = db.query.mock.calls[0][1]; @@ -103,7 +146,11 @@ describe('searchTracks', () => { db.query.mockResolvedValueOnce({ rows: [] }); await model.searchTracks({ - q: 'jazz', sort: 'relevance', limit: 20, offset: 0, threshold: 0.2, + q: 'jazz', + sort: 'relevance', + limit: 20, + offset: 0, + threshold: 0.2, }); const params = db.query.mock.calls[0][1]; @@ -114,7 +161,11 @@ describe('searchTracks', () => { db.query.mockResolvedValueOnce({ rows: [] }); await model.searchTracks({ - q: 'jazz', sort: 'relevance', limit: 20, offset: 0, threshold: 0.2, + q: 'jazz', + sort: 'relevance', + limit: 20, + offset: 0, + threshold: 0.2, time_range: 'past_week', }); @@ -128,7 +179,11 @@ describe('searchTracks', () => { db.query.mockResolvedValueOnce({ rows: [] }); await model.searchTracks({ - q: 'jazz', sort: 'relevance', limit: 20, offset: 0, threshold: 0.2, + q: 'jazz', + sort: 'relevance', + limit: 20, + offset: 0, + threshold: 0.2, duration: 'short', }); @@ -169,8 +224,12 @@ describe('searchUsers', () => { db.query.mockResolvedValueOnce({ rows: [fakeUserRow] }); const result = await model.searchUsers({ - q: 'miles', sort: 'relevance', limit: 20, offset: 0, - threshold: 0.2, currentUserId: null, + q: 'miles', + sort: 'relevance', + limit: 20, + offset: 0, + threshold: 0.2, + currentUserId: null, }); expect(result.rows[0].is_following).toBe(false); @@ -179,12 +238,16 @@ describe('searchUsers', () => { it('enriches follow status when currentUserId is provided', async () => { db.query - .mockResolvedValueOnce({ rows: [fakeUserRow] }) // search - .mockResolvedValueOnce({ rows: [{ following_id: 'u1' }] }); // follows + .mockResolvedValueOnce({ rows: [fakeUserRow] }) // search + .mockResolvedValueOnce({ rows: [{ following_id: 'u1' }] }); // follows const result = await model.searchUsers({ - q: 'miles', sort: 'relevance', limit: 20, offset: 0, - threshold: 0.2, currentUserId: 'viewer1', + q: 'miles', + sort: 'relevance', + limit: 20, + offset: 0, + threshold: 0.2, + currentUserId: 'viewer1', }); expect(result.rows[0].is_following).toBe(true); @@ -192,13 +255,15 @@ describe('searchUsers', () => { }); it('sets is_following=false for users not in follows result', async () => { - db.query - .mockResolvedValueOnce({ rows: [fakeUserRow] }) - .mockResolvedValueOnce({ rows: [] }); // not following anyone + db.query.mockResolvedValueOnce({ rows: [fakeUserRow] }).mockResolvedValueOnce({ rows: [] }); // not following anyone const result = await model.searchUsers({ - q: 'miles', sort: 'relevance', limit: 20, offset: 0, - threshold: 0.2, currentUserId: 'viewer1', + q: 'miles', + sort: 'relevance', + limit: 20, + offset: 0, + threshold: 0.2, + currentUserId: 'viewer1', }); expect(result.rows[0].is_following).toBe(false); @@ -208,8 +273,13 @@ describe('searchUsers', () => { db.query.mockResolvedValueOnce({ rows: [] }); await model.searchUsers({ - q: 'miles', sort: 'relevance', limit: 20, offset: 0, - threshold: 0.2, currentUserId: null, location: 'Cairo', + q: 'miles', + sort: 'relevance', + limit: 20, + offset: 0, + threshold: 0.2, + currentUserId: null, + location: 'Cairo', }); const params = db.query.mock.calls[0][1]; @@ -221,7 +291,11 @@ describe('searchUsers', () => { db.query.mockResolvedValueOnce({ rows: [] }); const result = await model.searchUsers({ - q: 'ghost', sort: 'relevance', limit: 20, offset: 0, threshold: 0.2, + q: 'ghost', + sort: 'relevance', + limit: 20, + offset: 0, + threshold: 0.2, }); expect(result.total).toBe(0); @@ -231,7 +305,11 @@ describe('searchUsers', () => { db.query.mockResolvedValueOnce({ rows: [] }); await model.searchUsers({ - q: 'miles', sort: 'newest', limit: 20, offset: 0, threshold: 0.2, + q: 'miles', + sort: 'newest', + limit: 20, + offset: 0, + threshold: 0.2, }); expect(db.query).toHaveBeenCalledWith( @@ -291,11 +369,15 @@ describe('getUserSearchLocations', () => { describe('searchPlaylists', () => { it('returns rows with preview_tracks and total', async () => { db.query - .mockResolvedValueOnce({ rows: [fakePlaylistRow] }) // main search - .mockResolvedValueOnce({ rows: [] }); // preview tracks + .mockResolvedValueOnce({ rows: [fakePlaylistRow] }) // main search + .mockResolvedValueOnce({ rows: [] }); // preview tracks const result = await model.searchPlaylists({ - q: 'jazz', sort: 'relevance', limit: 20, offset: 0, threshold: 0.2, + q: 'jazz', + sort: 'relevance', + limit: 20, + offset: 0, + threshold: 0.2, }); expect(result.rows[0].preview_tracks).toEqual([]); @@ -304,9 +386,15 @@ describe('searchPlaylists', () => { it('groups preview tracks by playlist_id', async () => { const previewTrack = { - playlist_id: 'p1', track_id: 't1', title: 'Blue Note', - cover_image: null, duration: 180, play_count: 100, - stream_url: 'https://stream/t1', artist_name: 'Miles', user_id: 'u1', + playlist_id: 'p1', + track_id: 't1', + title: 'Blue Note', + cover_image: null, + duration: 180, + play_count: 100, + stream_url: 'https://stream/t1', + artist_name: 'Miles', + user_id: 'u1', }; db.query @@ -314,7 +402,11 @@ describe('searchPlaylists', () => { .mockResolvedValueOnce({ rows: [previewTrack] }); const result = await model.searchPlaylists({ - q: 'jazz', sort: 'relevance', limit: 20, offset: 0, threshold: 0.2, + q: 'jazz', + sort: 'relevance', + limit: 20, + offset: 0, + threshold: 0.2, }); expect(result.rows[0].preview_tracks).toHaveLength(1); @@ -325,7 +417,11 @@ describe('searchPlaylists', () => { db.query.mockResolvedValueOnce({ rows: [] }); const result = await model.searchPlaylists({ - q: 'nothing', sort: 'relevance', limit: 20, offset: 0, threshold: 0.2, + q: 'nothing', + sort: 'relevance', + limit: 20, + offset: 0, + threshold: 0.2, }); expect(result.rows).toEqual([]); @@ -334,11 +430,15 @@ describe('searchPlaylists', () => { }); it('appends tag as $5 when provided', async () => { - db.query - .mockResolvedValueOnce({ rows: [] }); + db.query.mockResolvedValueOnce({ rows: [] }); await model.searchPlaylists({ - q: 'jazz', sort: 'relevance', limit: 20, offset: 0, threshold: 0.2, tag: 'chill', + q: 'jazz', + sort: 'relevance', + limit: 20, + offset: 0, + threshold: 0.2, + tag: 'chill', }); const params = db.query.mock.calls[0][1]; @@ -348,19 +448,18 @@ describe('searchPlaylists', () => { describe('searchAlbums', () => { it('delegates to _searchPlaylistLike with subtype=album', async () => { - db.query - .mockResolvedValueOnce({ rows: [fakePlaylistRow] }) - .mockResolvedValueOnce({ rows: [] }); + db.query.mockResolvedValueOnce({ rows: [fakePlaylistRow] }).mockResolvedValueOnce({ rows: [] }); const result = await model.searchAlbums({ - q: 'jazz', sort: 'relevance', limit: 20, offset: 0, threshold: 0.2, + q: 'jazz', + sort: 'relevance', + limit: 20, + offset: 0, + threshold: 0.2, }); // subtype = album should appear in the injected SQL string - expect(db.query).toHaveBeenCalledWith( - expect.stringContaining("'album'"), - expect.any(Array) - ); + expect(db.query).toHaveBeenCalledWith(expect.stringContaining("'album'"), expect.any(Array)); expect(result.rows).toHaveLength(1); }); }); @@ -372,7 +471,11 @@ describe('getPlaylistSearchTags', () => { it('returns tag names for playlists', async () => { db.query.mockResolvedValueOnce({ rows: [{ tag: 'chill' }, { tag: 'jazz' }] }); - const result = await model.getPlaylistSearchTags({ q: 'jazz', threshold: 0.2, subtype: 'playlist' }); + const result = await model.getPlaylistSearchTags({ + q: 'jazz', + threshold: 0.2, + subtype: 'playlist', + }); expect(result).toEqual(['chill', 'jazz']); expect(db.query).toHaveBeenCalledWith(expect.any(String), ['jazz', 0.2, 'playlist']); @@ -399,10 +502,7 @@ describe('suggestUsers', () => { const result = await model.suggestUsers('mi', 5, null); expect(result[0].is_following).toBe(false); - expect(db.query).toHaveBeenCalledWith( - expect.any(String), - ['mi%', 'mi', 0.2, 5] - ); + expect(db.query).toHaveBeenCalledWith(expect.any(String), ['mi%', 'mi', 0.2, 5]); }); it('returns followed users with is_following=true when userId provided', async () => { @@ -413,10 +513,7 @@ describe('suggestUsers', () => { const result = await model.suggestUsers('mi', 5, 'viewer1'); expect(result[0].is_following).toBe(true); - expect(db.query).toHaveBeenCalledWith( - expect.any(String), - ['viewer1', 'mi%', 'mi', 0.2, 5] - ); + expect(db.query).toHaveBeenCalledWith(expect.any(String), ['viewer1', 'mi%', 'mi', 0.2, 5]); }); it('returns empty array when no matches', async () => { @@ -438,10 +535,7 @@ describe('suggestTrackTitles', () => { const result = await model.suggestTrackTitles('blue', 5); expect(result).toEqual(['Blue Note', 'Blue Train']); - expect(db.query).toHaveBeenCalledWith( - expect.any(String), - ['blue%', 'blue', 0.2, 5] - ); + expect(db.query).toHaveBeenCalledWith(expect.any(String), ['blue%', 'blue', 0.2, 5]); }); it('returns empty array when no matches', async () => { @@ -463,10 +557,7 @@ describe('suggestPlaylistNames', () => { const result = await model.suggestPlaylistNames('jazz', 5); expect(result).toEqual(['Jazz Vibes', 'Jazz Classics']); - expect(db.query).toHaveBeenCalledWith( - expect.any(String), - ['jazz%', 'jazz', 0.2, 5] - ); + expect(db.query).toHaveBeenCalledWith(expect.any(String), ['jazz%', 'jazz', 0.2, 5]); }); it('returns empty array when no matches', async () => { @@ -476,4 +567,4 @@ describe('suggestPlaylistNames', () => { expect(result).toEqual([]); }); -}); \ No newline at end of file +}); diff --git a/tests/search.service.unit.test.js b/tests/search.service.unit.test.js index b1d4626c..d09827da 100644 --- a/tests/search.service.unit.test.js +++ b/tests/search.service.unit.test.js @@ -232,14 +232,24 @@ describe('search — type=playlists', () => { }); it('returns playlist filter block with tags', async () => { - const result = await searchService.search({ q: 'test', type: 'playlists', limit: 10, offset: 0 }); + const result = await searchService.search({ + q: 'test', + type: 'playlists', + limit: 10, + offset: 0, + }); expect(result.filters.available.tags).toEqual([{ value: 'chill', count: 2 }]); expect(result.filters.active.tag).toBeNull(); }); it('formats playlist result with owner object', async () => { - const result = await searchService.search({ q: 'test', type: 'playlists', limit: 10, offset: 0 }); + const result = await searchService.search({ + q: 'test', + type: 'playlists', + limit: 10, + offset: 0, + }); const pl = result.data.playlists[0]; expect(pl.owner).toMatchObject({ id: 'u1', display_name: 'Artist One' }); @@ -318,9 +328,18 @@ describe('getSuggestions', () => { // ══════════════════════════════════════════════════════════════ describe('searchEverything', () => { beforeEach(() => { - searchModel.searchTracks.mockResolvedValue({ rows: [fakeTrackRow, fakeTrackRow, fakeTrackRow, fakeTrackRow, fakeTrackRow], total: 5 }); - searchModel.searchUsers.mockResolvedValue({ rows: [fakeUserRow, fakeUserRow, fakeUserRow], total: 3 }); - searchModel.searchPlaylists.mockResolvedValue({ rows: [fakePlaylistRow, fakePlaylistRow], total: 2 }); + searchModel.searchTracks.mockResolvedValue({ + rows: [fakeTrackRow, fakeTrackRow, fakeTrackRow, fakeTrackRow, fakeTrackRow], + total: 5, + }); + searchModel.searchUsers.mockResolvedValue({ + rows: [fakeUserRow, fakeUserRow, fakeUserRow], + total: 3, + }); + searchModel.searchPlaylists.mockResolvedValue({ + rows: [fakePlaylistRow, fakePlaylistRow], + total: 2, + }); searchModel.searchAlbums.mockResolvedValue({ rows: [fakeAlbumRow, fakeAlbumRow], total: 2 }); }); @@ -370,4 +389,4 @@ describe('searchEverything', () => { expect(result.data.playlists.length).toBeLessThanOrEqual(2); expect(result.data.albums.length).toBeLessThanOrEqual(2); }); -}); \ No newline at end of file +}); diff --git a/tests/storage.service.test.js b/tests/storage.service.test.js index 8c5b391f..96dee61a 100644 --- a/tests/storage.service.test.js +++ b/tests/storage.service.test.js @@ -266,12 +266,8 @@ describe('storage.service', () => { }); it('generates a signed read URL when SAS signing dependencies are available', async () => { - const { - service, - generateBlobSASQueryParameters, - parsePermissions, - sharedKeyCredential, - } = loadStorageService({ withSas: true }); + const { service, generateBlobSASQueryParameters, parsePermissions, sharedKeyCredential } = + loadStorageService({ withSas: true }); const fileUrl = 'https://example.blob.core.windows.net/audio-container/tracks/user-1/song.mp3'; const result = await service.getSignedReadUrl(fileUrl, 120); @@ -358,9 +354,9 @@ describe('storage.service', () => { const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); const { service, harness } = loadStorageService(); const sdkError = new Error('delete failed'); - harness.getBlob(AUDIO_CONTAINER, 'tracks/user-1/song.mp3').deleteIfExists.mockRejectedValue( - sdkError - ); + harness + .getBlob(AUDIO_CONTAINER, 'tracks/user-1/song.mp3') + .deleteIfExists.mockRejectedValue(sdkError); await expect(service.deleteObject('tracks/user-1/song.mp3', null, 'audio')).rejects.toThrow( 'delete failed' diff --git a/tests/subscription/subscription.model.test.js b/tests/subscription/subscription.model.test.js index f07e2784..5cbed637 100644 --- a/tests/subscription/subscription.model.test.js +++ b/tests/subscription/subscription.model.test.js @@ -491,11 +491,9 @@ describe('subscription.model', () => { ); expect(client.query).toHaveBeenNthCalledWith(1, 'BEGIN'); - expect(client.query).toHaveBeenNthCalledWith( - 2, - expect.stringContaining('FOR UPDATE OF us'), - [USER_ID] - ); + expect(client.query).toHaveBeenNthCalledWith(2, expect.stringContaining('FOR UPDATE OF us'), [ + USER_ID, + ]); expect(callback).toHaveBeenCalledWith({ subscription: lockedSubscription, client }); expect(client.query).toHaveBeenNthCalledWith(3, 'COMMIT'); expect(client.release).toHaveBeenCalled(); @@ -534,10 +532,9 @@ describe('subscription.model', () => { status: 'expired', }); - expect(client.query).toHaveBeenCalledWith( - expect.stringContaining("SET status = 'expired'"), - [USER_SUBSCRIPTION_ID] - ); + expect(client.query).toHaveBeenCalledWith(expect.stringContaining("SET status = 'expired'"), [ + USER_SUBSCRIPTION_ID, + ]); }); it('markSubscriptionExpired returns null when no row is updated', async () => { @@ -595,10 +592,10 @@ describe('subscription.model', () => { end_date: endDate, }); - expect(db.query).toHaveBeenCalledWith( - expect.stringContaining('SET status ='), - [USER_SUBSCRIPTION_ID, endDate] - ); + expect(db.query).toHaveBeenCalledWith(expect.stringContaining('SET status ='), [ + USER_SUBSCRIPTION_ID, + endDate, + ]); expect(db.query.mock.calls[0][0]).toContain('end_date = $2'); db.query.mockResolvedValueOnce({ rows: [] }); diff --git a/tests/user.model.unit.test.js b/tests/user.model.unit.test.js index 81680f84..442dfb7a 100644 --- a/tests/user.model.unit.test.js +++ b/tests/user.model.unit.test.js @@ -130,10 +130,14 @@ describe('create', () => { }); expect(result).toEqual(fakeUser); - expect(db.query).toHaveBeenCalledWith( - expect.stringContaining('INSERT INTO users'), - ['user@example.com', 'hashed', 'User', 'male', '2000-01-01', 'user123'] - ); + expect(db.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO users'), [ + 'user@example.com', + 'hashed', + 'User', + 'male', + '2000-01-01', + 'user123', + ]); }); }); @@ -146,10 +150,7 @@ describe('markVerified', () => { await model.markVerified('u1'); - expect(db.query).toHaveBeenCalledWith( - expect.stringContaining('is_verified = true'), - ['u1'] - ); + expect(db.query).toHaveBeenCalledWith(expect.stringContaining('is_verified = true'), ['u1']); }); }); @@ -162,10 +163,7 @@ describe('updateLastLogin', () => { await model.updateLastLogin('u1'); - expect(db.query).toHaveBeenCalledWith( - expect.stringContaining('last_login_at'), - ['u1'] - ); + expect(db.query).toHaveBeenCalledWith(expect.stringContaining('last_login_at'), ['u1']); }); }); @@ -178,10 +176,10 @@ describe('updatePassword', () => { await model.updatePassword('u1', 'new_hashed'); - expect(db.query).toHaveBeenCalledWith( - expect.stringContaining('password_hashed'), - ['new_hashed', 'u1'] - ); + expect(db.query).toHaveBeenCalledWith(expect.stringContaining('password_hashed'), [ + 'new_hashed', + 'u1', + ]); }); }); @@ -278,10 +276,11 @@ describe('createOAuthUser', () => { }); expect(result).toEqual(fakeUser); - expect(db.query).toHaveBeenCalledWith( - expect.stringContaining('is_verified'), - ['oauth@example.com', 'OAuth User', 'oauthuser'] - ); + expect(db.query).toHaveBeenCalledWith(expect.stringContaining('is_verified'), [ + 'oauth@example.com', + 'OAuth User', + 'oauthuser', + ]); }); }); @@ -294,10 +293,10 @@ describe('setPendingEmail', () => { await model.setPendingEmail('u1', 'new@example.com'); - expect(db.query).toHaveBeenCalledWith( - expect.stringContaining('pending_email'), - ['u1', 'new@example.com'] - ); + expect(db.query).toHaveBeenCalledWith(expect.stringContaining('pending_email'), [ + 'u1', + 'new@example.com', + ]); }); }); @@ -308,10 +307,7 @@ describe('applyPendingEmail', () => { const result = await model.applyPendingEmail('u1'); expect(result.email).toBe('new@example.com'); - expect(db.query).toHaveBeenCalledWith( - expect.stringContaining('pending_email'), - ['u1'] - ); + expect(db.query).toHaveBeenCalledWith(expect.stringContaining('pending_email'), ['u1']); }); }); @@ -335,7 +331,7 @@ describe('softDeleteWithContent', () => { it('rolls back and re-throws on error', async () => { mockClient.query - .mockResolvedValueOnce({}) // BEGIN + .mockResolvedValueOnce({}) // BEGIN .mockRejectedValueOnce(new Error('db error')); // first UPDATE fails await expect(model.softDeleteWithContent('u1')).rejects.toThrow('db error'); @@ -366,10 +362,9 @@ describe('deleteAvatar', () => { const result = await model.deleteAvatar('u1'); expect(result.profile_picture).toBeNull(); - expect(db.query).toHaveBeenCalledWith( - expect.stringContaining('profile_picture = NULL'), - ['u1'] - ); + expect(db.query).toHaveBeenCalledWith(expect.stringContaining('profile_picture = NULL'), [ + 'u1', + ]); }); }); @@ -393,10 +388,7 @@ describe('deleteCoverPhoto', () => { const result = await model.deleteCoverPhoto('u1'); expect(result.cover_photo).toBeNull(); - expect(db.query).toHaveBeenCalledWith( - expect.stringContaining('cover_photo = NULL'), - ['u1'] - ); + expect(db.query).toHaveBeenCalledWith(expect.stringContaining('cover_photo = NULL'), ['u1']); }); }); @@ -416,10 +408,11 @@ describe('updateUserStatus', () => { const result = await model.updateUserStatus('u1', 'suspended', 'TOS violation'); expect(result.is_suspended).toBe(true); - expect(db.query).toHaveBeenCalledWith( - expect.stringContaining('is_suspended = true'), - ['suspended', 'TOS violation', 'u1'] - ); + expect(db.query).toHaveBeenCalledWith(expect.stringContaining('is_suspended = true'), [ + 'suspended', + 'TOS violation', + 'u1', + ]); }); it('reactivates user and clears suspension fields', async () => { @@ -428,10 +421,10 @@ describe('updateUserStatus', () => { const result = await model.updateUserStatus('u1', 'active'); expect(result.is_suspended).toBe(false); - expect(db.query).toHaveBeenCalledWith( - expect.stringContaining('is_suspended = false'), - ['active', 'u1'] - ); + expect(db.query).toHaveBeenCalledWith(expect.stringContaining('is_suspended = false'), [ + 'active', + 'u1', + ]); }); it('returns null when user not found', async () => { @@ -453,10 +446,7 @@ describe('getActiveUsersCount', () => { const result = await model.getActiveUsersCount(); expect(result).toBe(42); - expect(db.query).toHaveBeenCalledWith( - expect.stringContaining('last_login_at'), - ['30 days'] - ); + expect(db.query).toHaveBeenCalledWith(expect.stringContaining('last_login_at'), ['30 days']); }); it('returns count for day period', async () => { @@ -509,4 +499,4 @@ describe('getSuspendedAccountsCount', () => { db.query.mockResolvedValueOnce({ rows: [] }); await expect(model.getSuspendedAccountsCount()).resolves.toBe(0); }); -}); \ No newline at end of file +}); diff --git a/tests/utils/geo-restrictions.test.js b/tests/utils/geo-restrictions.test.js index e0578742..8b58f2f8 100644 --- a/tests/utils/geo-restrictions.test.js +++ b/tests/utils/geo-restrictions.test.js @@ -53,18 +53,15 @@ describe('geo-restrictions utility', () => { ).toBe('EG'); }); - it.each(['', 'E', 'EGY', '12', ' '])( - 'rejects invalid country code value %p', - (countryCode) => { - expect( - getRequestCountryCode({ - headers: { - 'X-Country-Code': countryCode, - }, - }) - ).toBeNull(); - } - ); + it.each(['', 'E', 'EGY', '12', ' '])('rejects invalid country code value %p', (countryCode) => { + expect( + getRequestCountryCode({ + headers: { + 'X-Country-Code': countryCode, + }, + }) + ).toBeNull(); + }); it('never blocks worldwide tracks, even without a country', () => { expect(