diff --git a/src/services/notifications.service.js b/src/services/notifications.service.js index 5b40262..be324e4 100644 --- a/src/services/notifications.service.js +++ b/src/services/notifications.service.js @@ -379,50 +379,3 @@ exports.updatePreferences = async ({ userId, updates }) => { const updated = await notificationModel.updatePreferences(userId, sanitized); return this.getPreferences({ userId }); }; - -// ── Private helper ──────────────────────────────────────────── - -/** - * Strip internal DB fields (id, user_id, created_at, updated_at) - * and return only the preference flags — matches NotificationPreferences schema. - */ -const formatPreferences = (row) => ({ - // new_follower_in_app: row.new_follower_in_app, - new_follower_push: row.new_follower_push, - new_follower_email: row.new_follower_email, - - // repost_of_your_post_in_app: row.repost_of_your_post_in_app, - repost_of_your_post_push: row.repost_of_your_post_push, - repost_of_your_post_email: row.repost_of_your_post_email, - - // new_post_by_followed_in_app: row.new_post_by_followed_in_app, - new_post_by_followed_push: row.new_post_by_followed_push, - new_post_by_followed_email: row.new_post_by_followed_email, - - // likes_and_plays_in_app: row.likes_and_plays_in_app, - likes_and_plays_push: row.likes_and_plays_push, - likes_and_plays_email: row.likes_and_plays_email, - - // comment_on_post_in_app: row.comment_on_post_in_app, - comment_on_post_push: row.comment_on_post_push, - comment_on_post_email: row.comment_on_post_email, - - // recommended_content_in_app: row.recommended_content_in_app, - recommended_content_push: row.recommended_content_push, - recommended_content_email: row.recommended_content_email, - - // new_message_in_app: row.new_message_in_app, - new_message_email: row.new_message_email, - messages_from: row.messages_from, - - feature_updates_push: row.feature_updates_push, - feature_updates_email: row.feature_updates_email, - - surveys_and_feedback_push: row.surveys_and_feedback_push, - surveys_and_feedback_email: row.surveys_and_feedback_email, - - promotional_content_push: row.promotional_content_push, - promotional_content_email: row.promotional_content_email, - - newsletter_email: row.newsletter_email, -}); diff --git a/tests/admin-notifications.socket.unit.test.js b/tests/admin-notifications.socket.unit.test.js new file mode 100644 index 0000000..22f7543 --- /dev/null +++ b/tests/admin-notifications.socket.unit.test.js @@ -0,0 +1,174 @@ +const loadSocketModule = () => { + jest.resetModules(); + return require('../src/sockets/admin-notifications.socket'); +}; + +const mkIo = () => { + const emit = jest.fn(); + const disconnectSockets = jest.fn(); + return { + to: jest.fn(() => ({ emit })), + in: jest.fn(() => ({ disconnectSockets })), + _emit: emit, + _disconnectSockets: disconnectSockets, + }; +}; + +const mkSocket = (role = 'admin') => { + const handlers = {}; + return { + user: role === undefined ? undefined : { role }, + on: jest.fn((event, cb) => { + handlers[event] = cb; + }), + join: jest.fn(), + leave: jest.fn(), + _handlers: handlers, + }; +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('admin-notifications.socket', () => { + it('only registers moderation handlers for admin sockets', () => { + const socketModule = loadSocketModule(); + const io = mkIo(); + const adminSocket = mkSocket('admin'); + const userSocket = mkSocket('user'); + + socketModule.registerAdminNotificationHandlers(io, adminSocket); + socketModule.registerAdminNotificationHandlers(io, userSocket); + + expect(adminSocket.join).toHaveBeenCalledWith('admin:moderation'); + expect(adminSocket.on).toHaveBeenCalledWith('admin:moderation:subscribe', expect.any(Function)); + expect(adminSocket.on).toHaveBeenCalledWith( + 'admin:moderation:unsubscribe', + expect.any(Function) + ); + expect(userSocket.join).not.toHaveBeenCalled(); + expect(userSocket.on).not.toHaveBeenCalled(); + }); + + it('subscribe and unsubscribe handlers respect current admin role', () => { + const socketModule = loadSocketModule(); + const io = mkIo(); + const socket = mkSocket('admin'); + + socketModule.registerAdminNotificationHandlers(io, socket); + socket._handlers['admin:moderation:subscribe'](); + socket._handlers['admin:moderation:unsubscribe'](); + + socket.user.role = 'user'; + socket._handlers['admin:moderation:subscribe'](); + socket._handlers['admin:moderation:unsubscribe'](); + + expect(socket.join).toHaveBeenCalledTimes(2); + expect(socket.leave).toHaveBeenCalledTimes(1); + expect(socket.leave).toHaveBeenCalledWith('admin:moderation'); + }); + + it('does not emit events before initialization or with missing required payloads', () => { + const socketModule = loadSocketModule(); + + expect(() => socketModule.emitReportReceived({ report: { id: 'r1' } })).not.toThrow(); + expect(() => socketModule.emitReportResolved({ userId: 'u1', report: { id: 'r1' } })).not.toThrow(); + expect(() => socketModule.emitAppealSubmitted({ appeal: { id: 'a1' } })).not.toThrow(); + expect(() => socketModule.emitAppealReviewed({ userId: 'u1', appeal: { id: 'a1' } })).not.toThrow(); + expect(() => socketModule.emitUserWarned({ userId: 'u1', warning: { id: 'w1' } })).not.toThrow(); + expect(() => socketModule.emitUserSuspended({ userId: 'u1', user: { id: 'u1' } })).not.toThrow(); + expect(() => + socketModule.emitAdminAuditLog({ + action: 'act', + adminUserId: 'admin', + targetType: 'user', + targetId: 'u1', + }) + ).not.toThrow(); + + const io = mkIo(); + socketModule.registerAdminNotificationHandlers(io, mkSocket('admin')); + socketModule.emitReportReceived({ report: null }); + socketModule.emitReportResolved({ userId: null, report: { id: 'r1' } }); + socketModule.emitAppealSubmitted({ appeal: null }); + socketModule.emitAppealReviewed({ userId: 'u1', appeal: null }); + socketModule.emitUserWarned({ userId: 'u1', warning: null }); + socketModule.emitUserSuspended({ userId: 'u1', user: null }); + socketModule.emitAdminAuditLog({ action: '', adminUserId: 'admin', targetType: 'user', targetId: 'u1' }); + + expect(io.to).not.toHaveBeenCalled(); + }); + + it('emits admin room and user room moderation events', () => { + const socketModule = loadSocketModule(); + const io = mkIo(); + + socketModule.registerAdminNotificationHandlers(io, mkSocket('admin')); + socketModule.emitReportReceived({ report: { id: 'r1' } }); + socketModule.emitReportResolved({ userId: 'u1', report: { id: 'r1' } }); + socketModule.emitAppealSubmitted({ appeal: { id: 'a1' } }); + socketModule.emitAppealReviewed({ userId: 'u1', appeal: { id: 'a1' } }); + socketModule.emitUserWarned({ userId: 'u1', warning: { id: 'w1' } }); + + expect(io._emit).toHaveBeenCalledWith('admin:report_received', { + type: 'report_received', + report: { id: 'r1' }, + }); + expect(io._emit).toHaveBeenCalledWith('admin:report_resolved', { + type: 'report_resolved', + report: { id: 'r1' }, + }); + expect(io._emit).toHaveBeenCalledWith('admin:appeal_submitted', { + type: 'appeal_submitted', + appeal: { id: 'a1' }, + }); + expect(io._emit).toHaveBeenCalledWith('admin:appeal_reviewed', { + type: 'appeal_reviewed', + appeal: { id: 'a1' }, + }); + expect(io._emit).toHaveBeenCalledWith('admin:user_warned', { + type: 'user_warned', + warning: { id: 'w1' }, + }); + }); + + it('emits user suspension and disconnects active user sockets', () => { + const socketModule = loadSocketModule(); + const io = mkIo(); + + socketModule.registerAdminNotificationHandlers(io, mkSocket('admin')); + socketModule.emitUserSuspended({ userId: 'u1', user: { id: 'u1' } }); + + expect(io.to).toHaveBeenCalledWith('notifications:user:u1'); + expect(io._emit).toHaveBeenCalledWith('admin:user_suspended', { + type: 'user_suspended', + user: { id: 'u1' }, + }); + expect(io.in).toHaveBeenCalledWith('notifications:user:u1'); + expect(io._disconnectSockets).toHaveBeenCalledWith(true); + }); + + it('emits audit log with default metadata and timestamp', () => { + const socketModule = loadSocketModule(); + const io = mkIo(); + + jest.spyOn(Date.prototype, 'toISOString').mockReturnValue('2026-05-03T00:00:00.000Z'); + socketModule.registerAdminNotificationHandlers(io, mkSocket('admin')); + socketModule.emitAdminAuditLog({ + action: 'ban', + adminUserId: 'admin-1', + targetType: 'user', + targetId: 'u1', + }); + + expect(io._emit).toHaveBeenCalledWith('admin:audit_log', { + action: 'ban', + admin_user_id: 'admin-1', + target_type: 'user', + target_id: 'u1', + metadata: {}, + occurred_at: '2026-05-03T00:00:00.000Z', + }); + }); +}); diff --git a/tests/messages/models/messages.model.test.js b/tests/messages/models/messages.model.test.js index 5450965..74982fc 100644 --- a/tests/messages/models/messages.model.test.js +++ b/tests/messages/models/messages.model.test.js @@ -168,6 +168,12 @@ describe('message.model', () => { ]); }); + it('softDeleteConversation returns null when no row is updated', async () => { + db.query.mockResolvedValueOnce({ rows: [] }); + + await expect(model.softDeleteConversation('c1', true)).resolves.toBeNull(); + }); + it('restoreConversationForUser sets deleted flag to false for selected side', async () => { db.query.mockResolvedValueOnce({}); await model.restoreConversationForUser('c1', true); diff --git a/tests/messages/services/messages.service.test.js b/tests/messages/services/messages.service.test.js index 32a664c..bf6e58b 100644 --- a/tests/messages/services/messages.service.test.js +++ b/tests/messages/services/messages.service.test.js @@ -16,7 +16,16 @@ jest.mock('../../../src/services/push-notifications.service', () => ({ sendDirectMessagePushIfEligible: jest.fn().mockResolvedValue(undefined), })); -beforeEach(() => jest.clearAllMocks()); +const originalConsoleError = console.error; + +beforeEach(() => { + jest.clearAllMocks(); + console.error = jest.fn(); +}); +afterEach(() => jest.restoreAllMocks()); +afterAll(() => { + console.error = originalConsoleError; +}); const validTrackId = '11111111-1111-4111-8111-111111111111'; @@ -73,6 +82,19 @@ describe('messages.service', () => { ).resolves.toEqual(conversation); }); + it('throws 404 when conversation is soft-deleted for user B', async () => { + model.findConversationById.mockResolvedValue({ + ...baseConversation, + user_a_id: 'u2', + user_b_id: 'u1', + deleted_by_b: true, + }); + + await expect( + service.assertConversationAccess({ conversationId: 'c1', userId: 'u1' }) + ).rejects.toMatchObject({ code: 'CONVERSATION_NOT_FOUND' }); + }); + it('returns conversation for active participant', async () => { model.findConversationById.mockResolvedValue(baseConversation); @@ -387,6 +409,24 @@ describe('messages.service', () => { expect(out.isNew).toBe(false); expect(model.restoreConversationForUser).toHaveBeenCalledWith('c1', true); }); + + it('restores existing conversation for sender when sender is user B', async () => { + model.findActiveUserById.mockResolvedValue({ id: 'u2' }); + model.isBlocked.mockResolvedValue(false); + model.getMessagesFromPreference.mockResolvedValue('everyone'); + model.findConversationByPair.mockResolvedValue({ + id: 'c1', + user_a_id: 'u2', + user_b_id: 'u1', + deleted_by_a: false, + deleted_by_b: true, + }); + + const out = await service.ensureConversation({ senderId: 'u1', recipientId: 'u2' }); + + expect(out.isNew).toBe(false); + expect(model.restoreConversationForUser).toHaveBeenCalledWith('c1', false); + }); }); describe('listConversations', () => { @@ -481,6 +521,19 @@ describe('messages.service', () => { ).rejects.toMatchObject({ code: 'CONVERSATION_NOT_FOUND' }); }); + it('404 when soft deleted by user B', async () => { + model.findConversationById.mockResolvedValue({ + ...baseConversation, + user_a_id: 'u2', + user_b_id: 'u1', + deleted_by_b: true, + }); + + await expect( + service.getConversation({ conversationId: 'c1', userId: 'u1' }) + ).rejects.toMatchObject({ code: 'CONVERSATION_NOT_FOUND' }); + }); + it('returns conversation payload', async () => { model.findConversationById.mockResolvedValue(baseConversation); model.findMessagesByConversationId.mockResolvedValue([{ id: 'm1' }]); @@ -521,6 +574,23 @@ describe('messages.service', () => { total_pages: 0, }); }); + + it('uses provided raw offset when present', async () => { + model.findConversationById.mockResolvedValue(baseConversation); + model.findMessagesByConversationId.mockResolvedValue([]); + model.countMessagesByConversationId.mockResolvedValue(0); + model.findConversationPartner.mockResolvedValue({ id: 'u2' }); + model.countUnreadMessages.mockResolvedValue(0); + + await service.getConversation({ + conversationId: 'c1', + userId: 'u1', + limit: '10', + offset: '-5', + }); + + expect(model.findMessagesByConversationId).toHaveBeenCalledWith('c1', 10, 0); + }); }); describe('sendMessage', () => { @@ -597,6 +667,24 @@ describe('messages.service', () => { expect(model.restoreConversationForUser).toHaveBeenNthCalledWith(2, 'c1', false); }); + it('restores both sides when sender is user B', async () => { + model.findConversationById.mockResolvedValue({ + ...baseConversation, + user_a_id: 'u2', + user_b_id: 'u1', + deleted_by_a: true, + deleted_by_b: true, + }); + model.isBlocked.mockResolvedValue(false); + model.getMessagesFromPreference.mockResolvedValue('everyone'); + model.createMessage.mockResolvedValue({ id: 'm1' }); + + await service.sendMessage({ conversationId: 'c1', senderId: 'u1', body: 'x' }); + + expect(model.restoreConversationForUser).toHaveBeenNthCalledWith(1, 'c1', false); + expect(model.restoreConversationForUser).toHaveBeenNthCalledWith(2, 'c1', true); + }); + it('blocks on blocked recipient', async () => { model.findConversationById.mockResolvedValue(baseConversation); model.isBlocked.mockResolvedValue(true); @@ -617,6 +705,28 @@ describe('messages.service', () => { ).rejects.toMatchObject({ code: 'MESSAGES_FOLLOWERS_ONLY' }); }); + it('rejects when recipient accepts messages from nobody', async () => { + model.findConversationById.mockResolvedValue(baseConversation); + model.isBlocked.mockResolvedValue(false); + model.getMessagesFromPreference.mockResolvedValue('nobody'); + + await expect( + service.sendMessage({ conversationId: 'c1', senderId: 'u1', body: 'x' }) + ).rejects.toMatchObject({ code: 'MESSAGES_DISABLED' }); + }); + + it('allows followers_only when recipient follows sender', async () => { + model.findConversationById.mockResolvedValue(baseConversation); + model.isBlocked.mockResolvedValue(false); + model.getMessagesFromPreference.mockResolvedValue('followers_only'); + model.isFollowing.mockResolvedValue(true); + model.createMessage.mockResolvedValue({ id: 'm-followers' }); + + await expect( + service.sendMessage({ conversationId: 'c1', senderId: 'u1', body: 'x' }) + ).resolves.toEqual({ id: 'm-followers' }); + }); + it('empty payload rejection', async () => { model.findConversationById.mockResolvedValue(baseConversation); model.isBlocked.mockResolvedValue(false); @@ -706,6 +816,32 @@ describe('messages.service', () => { }); }); + it('logs direct-message notification failures with non-Error rejection values', async () => { + model.findConversationById.mockResolvedValue(baseConversation); + model.isBlocked.mockResolvedValue(false); + model.getMessagesFromPreference.mockResolvedValue('everyone'); + model.createMessage.mockResolvedValue({ id: 'm1', body: 'hi' }); + emailNotificationsService.sendDirectMessageEmailIfEligible.mockReturnValue( + Promise.reject('mail down') + ); + pushNotificationsService.sendDirectMessagePushIfEligible.mockReturnValue( + Promise.reject('push down') + ); + + await service.sendMessage({ conversationId: 'c1', senderId: 'u1', body: 'hi' }); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(console.error).toHaveBeenCalledWith( + '[Messages] Email notification failed:', + 'mail down' + ); + expect(console.error).toHaveBeenCalledWith( + '[Messages] Push notification failed:', + 'push down' + ); + }); + it('rejects invalid track resource id', async () => { model.findConversationById.mockResolvedValue(baseConversation); model.isBlocked.mockResolvedValue(false); @@ -869,6 +1005,20 @@ describe('messages.service', () => { ).rejects.toMatchObject({ code: 'MESSAGES_READ_STATE_CONFLICT' }); }); + it('409 when message is already unread', async () => { + model.findConversationById.mockResolvedValue(baseConversation); + model.findMessageById.mockResolvedValue({ id: 'm1', sender_id: 'u2', is_read: false }); + + await expect( + service.markMessageReadState({ + conversationId: 'c1', + messageId: 'm1', + userId: 'u1', + isRead: false, + }) + ).rejects.toMatchObject({ code: 'MESSAGES_READ_STATE_CONFLICT' }); + }); + it('updates read state', async () => { model.findConversationById.mockResolvedValue(baseConversation); model.findMessageById.mockResolvedValue({ id: 'm1', sender_id: 'u2', is_read: false }); @@ -961,6 +1111,19 @@ describe('messages.service', () => { ).rejects.toMatchObject({ code: 'CONVERSATION_NOT_FOUND' }); }); + it('404 already deleted by user B', async () => { + model.findConversationById.mockResolvedValue({ + ...baseConversation, + user_a_id: 'u2', + user_b_id: 'u1', + deleted_by_b: true, + }); + + await expect( + service.deleteConversation({ conversationId: 'c1', userId: 'u1' }) + ).rejects.toMatchObject({ code: 'CONVERSATION_NOT_FOUND' }); + }); + it('soft deletes for user A', async () => { model.findConversationById.mockResolvedValue(baseConversation); model.softDeleteConversation.mockResolvedValue(); diff --git a/tests/messages/socket/messages.socket.test.js b/tests/messages/socket/messages.socket.test.js index e185e4c..97fd531 100644 --- a/tests/messages/socket/messages.socket.test.js +++ b/tests/messages/socket/messages.socket.test.js @@ -10,29 +10,39 @@ const VALID_MESSAGE_ID = '33333333-3333-3333-3333-333333333333'; const mkSocket = (userId = '11111111-1111-1111-1111-111111111111') => { const handlers = {}; + const emit = jest.fn(); + const broadcaster = { emit }; + return { - id: 'sock1', - user: { sub: userId }, + id: 'sock-1', + user: userId === undefined ? undefined : { sub: userId }, on: jest.fn((event, cb) => { handlers[event] = cb; }), - emit: jest.fn(), join: jest.fn(), leave: jest.fn(), - to: jest.fn(() => ({ emit: jest.fn() })), + to: jest.fn(() => broadcaster), _handlers: handlers, + _broadcastEmit: emit, }; }; beforeEach(() => { jest.clearAllMocks(); - messagesService.assertConversationAccess.mockResolvedValue({ id: VALID_CONVERSATION_ID }); + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); }); describe('messages.socket', () => { - it('registers all message handlers', () => { + it('registers all handlers', () => { const socket = mkSocket(); + registerMessageHandlers({}, socket); + expect(socket.on).toHaveBeenCalledWith('message:join', expect.any(Function)); expect(socket.on).toHaveBeenCalledWith('message:leave', expect.any(Function)); expect(socket.on).toHaveBeenCalledWith('message:send', expect.any(Function)); @@ -40,6 +50,7 @@ describe('messages.socket', () => { expect(socket.on).toHaveBeenCalledWith('message:read', expect.any(Function)); expect(socket.on).toHaveBeenCalledWith('message:typing', expect.any(Function)); expect(socket.on).toHaveBeenCalledWith('message:stop_typing', expect.any(Function)); + expect(socket.on).toHaveBeenCalledWith('disconnect', expect.any(Function)); }); // ── message:join ────────────────────────────────────────────── @@ -58,7 +69,7 @@ describe('messages.socket', () => { expect(socket.emit).not.toHaveBeenCalled(); }); - it('message:join handles missing payload object', async () => { + it('broadcasts message lifecycle events', () => { const socket = mkSocket(); registerMessageHandlers({}, socket); @@ -278,63 +289,103 @@ describe('messages.socket', () => { conversationUnreadCount: 0, }); - expect(emit).toHaveBeenCalledWith('message:read_updated', { - conversationId: VALID_CONVERSATION_ID, - messageId: VALID_MESSAGE_ID, + expect(socket.to).toHaveBeenCalledWith('conversation:c1'); + expect(socket._broadcastEmit).toHaveBeenCalledWith('message:received', { + conversationId: 'c1', + message: { id: 'm1' }, + }); + expect(socket._broadcastEmit).toHaveBeenCalledWith('message:removed', { + conversationId: 'c1', + messageId: 'm1', + }); + expect(socket._broadcastEmit).toHaveBeenCalledWith('message:read_updated', { + conversationId: 'c1', + messageId: 'm1', isRead: true, conversationUnreadCount: 0, }); }); - it('message:read broadcasts even with invalid isRead type', async () => { + it('message lifecycle and typing handlers accept omitted payloads', () => { const socket = mkSocket(); - const emit = jest.fn(); - socket.to.mockReturnValue({ emit }); registerMessageHandlers({}, socket); - // No validation — socket trusts HTTP layer - await socket._handlers['message:read']({ - conversationId: VALID_CONVERSATION_ID, - messageId: VALID_MESSAGE_ID, - isRead: 'yes', - conversationUnreadCount: 0, + socket._handlers['message:send'](); + socket._handlers['message:deleted'](); + socket._handlers['message:read'](); + socket._handlers['message:typing'](); + socket._handlers['message:stop_typing'](); + + expect(socket._broadcastEmit).toHaveBeenCalledWith('message:received', { + conversationId: undefined, + message: undefined, + }); + expect(socket._broadcastEmit).toHaveBeenCalledWith('message:removed', { + conversationId: undefined, + messageId: undefined, }); + expect(socket._broadcastEmit).toHaveBeenCalledWith('message:read_updated', { + conversationId: undefined, + messageId: undefined, + isRead: undefined, + conversationUnreadCount: undefined, + }); + expect(socket._broadcastEmit).toHaveBeenCalledWith('message:typing', { + conversationId: undefined, + userId: 'user-1', + }); + expect(socket._broadcastEmit).toHaveBeenCalledWith('message:stop_typing', { + conversationId: undefined, + userId: 'user-1', + }); + }); - expect(socket.emit).not.toHaveBeenCalled(); - expect(emit).toHaveBeenCalled(); + it('broadcasts typing indicators with the authenticated user id', () => { + const socket = mkSocket('user-typing'); + registerMessageHandlers({}, socket); + + socket._handlers['message:typing']({ conversationId: 'c1' }); + socket._handlers['message:stop_typing']({ conversationId: 'c1' }); + + expect(socket._broadcastEmit).toHaveBeenCalledWith('message:typing', { + conversationId: 'c1', + userId: 'user-typing', + }); + expect(socket._broadcastEmit).toHaveBeenCalledWith('message:stop_typing', { + conversationId: 'c1', + userId: 'user-typing', + }); }); - it('message:read broadcasts even from unauthenticated socket', async () => { + it('logs disconnect with optional missing user safely', () => { const socket = mkSocket(null); - const emit = jest.fn(); - socket.to.mockReturnValue({ emit }); registerMessageHandlers({}, socket); - await socket._handlers['message:read']({ - conversationId: VALID_CONVERSATION_ID, - messageId: VALID_MESSAGE_ID, - isRead: true, - conversationUnreadCount: 0, - }); + socket._handlers.disconnect('client disconnect'); - // No auth check - expect(socket.emit).not.toHaveBeenCalled(); - expect(emit).toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith( + '[Socket.IO] sock-1 (user: null) disconnected — reason: client disconnect' + ); }); - // ── message:typing & message:stop_typing ───────────────────── - - it('typing events broadcast', async () => { + it.each([ + ['message:join', 'join exploded', (socket) => socket.join.mockImplementation(() => { throw new Error('join exploded'); })], + ['message:leave', 'leave exploded', (socket) => socket.leave.mockImplementation(() => { throw new Error('leave exploded'); })], + ['message:send', 'send exploded', (socket) => socket.to.mockImplementation(() => { throw new Error('send exploded'); })], + ['message:deleted', 'deleted exploded', (socket) => socket.to.mockImplementation(() => { throw new Error('deleted exploded'); })], + ['message:read', 'read exploded', (socket) => socket.to.mockImplementation(() => { throw new Error('read exploded'); })], + ['message:typing', 'typing exploded', (socket) => socket.to.mockImplementation(() => { throw new Error('typing exploded'); })], + [ + 'message:stop_typing', + 'stop exploded', + (socket) => socket.to.mockImplementation(() => { throw new Error('stop exploded'); }), + ], + ])('logs %s handler errors', (event, message, arrange) => { const socket = mkSocket(); - const emit = jest.fn(); - socket.to.mockReturnValue({ emit }); registerMessageHandlers({}, socket); + arrange(socket); - await socket._handlers['message:typing']({ conversationId: VALID_CONVERSATION_ID }); - expect(emit).toHaveBeenCalledWith('message:typing', { - conversationId: VALID_CONVERSATION_ID, - userId: socket.user.sub, - }); + socket._handlers[event]({ conversationId: 'c1', message: {}, messageId: 'm1', isRead: false }); await socket._handlers['message:stop_typing']({ conversationId: VALID_CONVERSATION_ID }); expect(emit).toHaveBeenCalledWith('message:stop_typing', { diff --git a/tests/notifications.socket.unit.test.js b/tests/notifications.socket.unit.test.js new file mode 100644 index 0000000..6a829be --- /dev/null +++ b/tests/notifications.socket.unit.test.js @@ -0,0 +1,132 @@ +const loadSocketModule = () => { + jest.resetModules(); + return require('../src/sockets/notifications.socket'); +}; + +const mkIo = () => { + const emit = jest.fn(); + return { + to: jest.fn(() => ({ emit })), + _emit: emit, + }; +}; + +const mkSocket = (userId = 'user-1') => { + const handlers = {}; + return { + id: 'sock-1', + user: userId === undefined ? undefined : { sub: userId }, + on: jest.fn((event, cb) => { + handlers[event] = cb; + }), + join: jest.fn(), + leave: jest.fn(), + _handlers: handlers, + }; +}; + +beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'log').mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('notifications.socket', () => { + it('does not emit created/read events before initialization or with missing payloads', () => { + const socketModule = loadSocketModule(); + + expect(() => + socketModule.emitNotificationCreated({ userId: 'u1', notification: { id: 'n1' } }) + ).not.toThrow(); + expect(() => + socketModule.emitNotificationRead({ userId: 'u1', notificationId: 'n1' }) + ).not.toThrow(); + + const io = mkIo(); + socketModule.initNotificationSocket(io); + socketModule.emitNotificationCreated({ userId: null, notification: { id: 'n1' } }); + socketModule.emitNotificationCreated({ userId: 'u1', notification: null }); + socketModule.emitNotificationRead({ userId: null, notificationId: 'n1' }); + socketModule.emitNotificationRead({ userId: 'u1', notificationId: null }); + + expect(io.to).not.toHaveBeenCalled(); + }); + + it('emits notification created and read events to user rooms', () => { + const socketModule = loadSocketModule(); + const io = mkIo(); + + socketModule.initNotificationSocket(io); + socketModule.emitNotificationCreated({ userId: 'u1', notification: { id: 'n1' } }); + socketModule.emitNotificationRead({ userId: 'u1', notificationId: 'n1' }); + + expect(io.to).toHaveBeenCalledWith('notifications:user:u1'); + expect(io._emit).toHaveBeenCalledWith('notification:created', { + notification: { id: 'n1' }, + }); + expect(io._emit).toHaveBeenCalledWith('notification:read', { notification_id: 'n1' }); + }); + + it('registers handlers and joins/leaves notification room for authenticated users', () => { + const socketModule = loadSocketModule(); + const io = mkIo(); + const socket = mkSocket('u1'); + + socketModule.registerNotificationHandlers(io, socket); + socket._handlers['notification:subscribe'](); + socket._handlers['notification:unsubscribe'](); + socket._handlers.disconnect('transport close'); + + expect(socket.join).toHaveBeenCalledWith('notifications:user:u1'); + expect(socket.join).toHaveBeenCalledTimes(2); + expect(socket.leave).toHaveBeenCalledWith('notifications:user:u1'); + expect(console.log).toHaveBeenCalledWith( + '[Socket.IO] notifications — socket sock-1 (user: u1) disconnected — reason: transport close' + ); + }); + + it('registers handlers without joining rooms when user is missing', () => { + const socketModule = loadSocketModule(); + const io = mkIo(); + const socket = mkSocket(null); + + socketModule.registerNotificationHandlers(io, socket); + socket._handlers['notification:subscribe'](); + socket._handlers['notification:unsubscribe'](); + + expect(socket.join).not.toHaveBeenCalled(); + expect(socket.leave).not.toHaveBeenCalled(); + }); + + it('uses registerNotificationHandlers as io fallback for later emits', () => { + const socketModule = loadSocketModule(); + const io = mkIo(); + const socket = mkSocket('u1'); + + socketModule.registerNotificationHandlers(io, socket); + socketModule.emitNotificationCreated({ userId: 'u1', notification: { id: 'n1' } }); + + expect(io._emit).toHaveBeenCalledWith('notification:created', { + notification: { id: 'n1' }, + }); + }); + + it('keeps initialized ioRef when registering handlers later', () => { + const socketModule = loadSocketModule(); + const initialIo = mkIo(); + const laterIo = mkIo(); + const socket = mkSocket('u1'); + + socketModule.initNotificationSocket(initialIo); + socketModule.registerNotificationHandlers(laterIo, socket); + socketModule.emitNotificationRead({ userId: 'u1', notificationId: 'n1' }); + + expect(initialIo._emit).toHaveBeenCalledWith('notification:read', { + notification_id: 'n1', + }); + expect(laterIo._emit).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/notifications/models/notification.model.test.js b/tests/notifications/models/notification.model.test.js index 664f45b..08bd9ec 100644 --- a/tests/notifications/models/notification.model.test.js +++ b/tests/notifications/models/notification.model.test.js @@ -157,6 +157,33 @@ describe('notification.model', () => { expect(pushService.sendPushToUser).not.toHaveBeenCalled(); }); + it('falls back to Someone when actor identity has no names', async () => { + db.query.mockResolvedValueOnce({ + rows: [ + { + id: 'n7', + user_id: 'u1', + action_user_id: 'u2', + type: 'repost', + reference_id: null, + reference_type: null, + is_read: false, + created_at: '2026-04-30', + }, + ], + }); + jest.spyOn(model, 'getUserEmailIdentity').mockResolvedValue({ + display_name: '', + username: '', + }); + + await model.createNotification({ userId: 'u1', actionUserId: 'u2', type: 'repost' }); + + expect(pushService.sendPushToUser).toHaveBeenCalledWith( + expect.objectContaining({ body: 'Someone reposted your track.' }) + ); + }); + it('falls back to username for action push copy and keeps the reference id', async () => { db.query.mockResolvedValueOnce({ rows: [ @@ -333,6 +360,23 @@ describe('notification.model', () => { expect(db.query.mock.calls[0][0]).not.toContain('AND n.type ='); expect(db.query.mock.calls[0][0]).toContain('LIMIT $2 OFFSET $3'); }); + + it('queries without read-state or type filters', async () => { + db.query.mockResolvedValueOnce({ rows: [] }); + + await model.findNotifications('u1', { + unreadOnly: null, + type: null, + limit: 5, + offset: 0, + }); + + expect(db.query).toHaveBeenCalledWith(expect.not.stringContaining('AND n.is_read'), [ + 'u1', + 5, + 0, + ]); + }); }); describe('countNotifications', () => { @@ -362,6 +406,16 @@ describe('notification.model', () => { ]); expect(db.query.mock.calls[0][0]).not.toContain('AND n.type ='); }); + + it('counts without read-state or type filters', async () => { + db.query.mockResolvedValueOnce({ rows: [{ total: 4 }] }); + + await expect(model.countNotifications('u1', { unreadOnly: null, type: null })).resolves.toBe( + 4 + ); + + expect(db.query).toHaveBeenCalledWith(expect.not.stringContaining('AND n.is_read'), ['u1']); + }); }); describe('countUnread', () => { diff --git a/tests/notifications/services/notifications.service.test.js b/tests/notifications/services/notifications.service.test.js index 48ee9ed..19b4e74 100644 --- a/tests/notifications/services/notifications.service.test.js +++ b/tests/notifications/services/notifications.service.test.js @@ -278,10 +278,15 @@ describe('notifications.service', () => { }); it.each([ + [true, true], + [false, false], [1, true], [0, false], + [2, null], ['YES', true], ['off', false], + [undefined, null], + [null, null], ['maybe', null], [{}, null], ])('coerces unreadOnly %p to %p', async (unreadOnly, expected) => { diff --git a/tests/notifications/services/push-notifications.service.test.js b/tests/notifications/services/push-notifications.service.test.js index 5cc5f36..e300fab 100644 --- a/tests/notifications/services/push-notifications.service.test.js +++ b/tests/notifications/services/push-notifications.service.test.js @@ -126,6 +126,28 @@ describe('push-notifications.service', () => { ); }); + it('sends when notification type is missing, unknown, or non-string', async () => { + pushTokenModel.getPushPreferencesByUserId.mockResolvedValue({ likes_and_plays_push: false }); + pushTokenModel.getTokensByUserId.mockResolvedValue([{ token: 'a' }]); + sendPushNotification.mockResolvedValue(undefined); + + await service.sendPushToUser({ userId: 'u1', title: 'Hi', body: 'There' }); + await service.sendPushToUser({ + userId: 'u1', + title: 'Hi', + body: 'There', + data: { type: 'unknown_type' }, + }); + await service.sendPushToUser({ + userId: 'u1', + title: 'Hi', + body: 'There', + data: { type: 123 }, + }); + + expect(sendPushNotification).toHaveBeenCalledTimes(3); + }); + it('never throws when model layer throws', async () => { const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); pushTokenModel.getPushPreferencesByUserId.mockRejectedValue(new Error('db down')); @@ -136,6 +158,16 @@ describe('push-notifications.service', () => { consoleSpy.mockRestore(); }); + + it('logs non-Error failures from sendPushToUser', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + pushTokenModel.getPushPreferencesByUserId.mockRejectedValue('db down'); + + await service.sendPushToUser({ userId: 'u1', title: 'Hi', body: 'There' }); + + expect(consoleSpy).toHaveBeenCalledWith('[Push] sendPushToUser failed:', undefined); + consoleSpy.mockRestore(); + }); }); describe('sendDirectMessagePushIfEligible', () => { @@ -336,5 +368,63 @@ describe('push-notifications.service', () => { }) ); }); + + it('uses fallback sender name and swallows sendPushToUser rejection', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const tokenModel = require('../src/models/push-token.model'); + tokenModel.getPushPreferencesByUserId.mockResolvedValue({ new_message_push: true }); + + jest.doMock('../src/models/notification.model', () => ({ + getUserEmailIdentity: jest.fn().mockResolvedValue({ display_name: '', username: '' }), + })); + + const throttle = { isThrottled: jest.fn().mockReturnValue(false), markSent: jest.fn() }; + jest.doMock('../src/utils/dm-push-throttle', () => throttle); + + const svc = require('../src/services/push-notifications.service'); + jest.spyOn(svc, 'sendPushToUser').mockRejectedValue('push failed'); + + await svc.sendDirectMessagePushIfEligible({ + conversationId: 'c1', + senderId: 'u1', + recipientId: 'u2', + messageBody: 'hi', + embedType: null, + }); + + expect(svc.sendPushToUser).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Message from Someone' }) + ); + expect(throttle.markSent).toHaveBeenCalledWith({ recipientId: 'u2', conversationId: 'c1' }); + expect(consoleSpy).toHaveBeenCalledWith( + '[Push] Direct message push failed:', + undefined + ); + consoleSpy.mockRestore(); + }); + + it('swallows unexpected direct-message push errors', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const tokenModel = require('../src/models/push-token.model'); + tokenModel.getPushPreferencesByUserId.mockRejectedValue('prefs down'); + + const svc = require('../src/services/push-notifications.service'); + + await expect( + svc.sendDirectMessagePushIfEligible({ + conversationId: 'c1', + senderId: 'u1', + recipientId: 'u2', + messageBody: 'hi', + embedType: null, + }) + ).resolves.toBeUndefined(); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Direct message push notification skipped:', + 'prefs down' + ); + consoleSpy.mockRestore(); + }); }); }); diff --git a/tests/playlists/controllers/playlists.controller.test.js b/tests/playlists/controllers/playlists.controller.test.js index 9095e4e..fa7c06d 100644 --- a/tests/playlists/controllers/playlists.controller.test.js +++ b/tests/playlists/controllers/playlists.controller.test.js @@ -1077,5 +1077,275 @@ describe('Playlist Controller', () => { expect(res.json).toHaveBeenCalled(); jest.restoreAllMocks(); }); + + it('should default list requester to null without req.user and pass album owner filters', async () => { + const { req, res } = createMockReqRes(); + req.user = undefined; + req.query = { + owner_user_id: mockUserId, + is_album_view: 'true', + }; + + jest.spyOn(playlistService, 'listPlaylists').mockResolvedValue({ + items: [], + meta: { total: 0 }, + }); + + await playlistController.listPlaylists(req, res); + + expect(playlistService.listPlaylists).toHaveBeenCalledWith( + expect.objectContaining({ + requesterId: null, + ownerUserId: mockUserId, + isAlbumView: true, + }) + ); + jest.restoreAllMocks(); + }); + + it('should reject missing playlist_id in getPlaylist', async () => { + const { req, res } = createMockReqRes(); + req.params = {}; + + const spy = jest.spyOn(playlistService, 'getPlaylist').mockResolvedValue(mockPlaylist); + + await playlistController.getPlaylist(req, res); + + expect(spy).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalled(); + jest.restoreAllMocks(); + }); + + it('should normalize update body edge values before calling service', async () => { + const { req, res } = createMockReqRes(); + req.params = { playlist_id: mockPlaylistId }; + req.body = { + name: '', + description: '', + is_public: '', + release_date: '', + genre_id: '', + subtype: '', + slug: ' My Slug ', + tags: 'solo', + cover_image: '', + }; + req.file = { originalname: 'cover.png' }; + + jest.spyOn(playlistService, 'updatePlaylist').mockResolvedValue({ + playlist: mockPlaylist, + }); + + await playlistController.updatePlaylist(req, res); + + expect(playlistService.updatePlaylist).toHaveBeenCalledWith( + expect.objectContaining({ + name: undefined, + description: undefined, + isPublic: undefined, + releaseDate: null, + genreId: null, + subtype: undefined, + slug: 'My Slug', + tags: ['solo'], + clearCoverImage: true, + coverImageFile: req.file, + }) + ); + jest.restoreAllMocks(); + }); + + it('should reject missing playlist_id before deleting', async () => { + const { req, res } = createMockReqRes(); + req.params = {}; + + const spy = jest.spyOn(playlistService, 'deletePlaylist').mockResolvedValue(true); + + await playlistController.deletePlaylist(req, res); + + expect(spy).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalled(); + jest.restoreAllMocks(); + }); + + it('should reject missing playlist_id before adding a track', async () => { + const { req, res } = createMockReqRes(); + req.params = {}; + req.body = { track_id: mockTrackId }; + + const spy = jest.spyOn(playlistService, 'addTrack').mockResolvedValue({ + playlist: mockPlaylist, + }); + + await playlistController.addTrack(req, res); + + expect(spy).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalled(); + jest.restoreAllMocks(); + }); + + it('should pass null requester to getPlaylistTracks when unauthenticated', async () => { + const { req, res } = createMockReqRes(); + req.user = undefined; + req.params = { playlist_id: mockPlaylistId }; + req.query = { secret_token: 'secret123' }; + + jest.spyOn(playlistService, 'getPlaylistTracks').mockResolvedValue({ + tracks: [], + pagination: {}, + }); + + await playlistController.getPlaylistTracks(req, res); + + expect(playlistService.getPlaylistTracks).toHaveBeenCalledWith( + expect.objectContaining({ userId: null, secretToken: 'secret123' }) + ); + jest.restoreAllMocks(); + }); + + it('should reject invalid UUIDs before removing a track', async () => { + const { req, res } = createMockReqRes(); + req.params = { playlist_id: mockPlaylistId, track_id: 'not-uuid' }; + + const spy = jest.spyOn(playlistService, 'removeTrack').mockResolvedValue({ + playlist: mockPlaylist, + }); + + await playlistController.removeTrack(req, res); + + expect(spy).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalled(); + jest.restoreAllMocks(); + }); + + it('should reject missing playlist_id before embed generation', async () => { + const { req, res } = createMockReqRes(); + req.params = {}; + + const spy = jest.spyOn(playlistService, 'getEmbed').mockResolvedValue({ + embed_url: 'x', + iframe_html: 'y', + }); + + await playlistController.getEmbed(req, res); + + expect(spy).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalled(); + jest.restoreAllMocks(); + }); + + it('should pass null requester to user playlist and album endpoints', async () => { + const { req: playlistReq, res: playlistRes } = createMockReqRes(); + playlistReq.user = undefined; + playlistReq.params = { user_id: mockUserId }; + playlistReq.query = { limit: '5', offset: '10' }; + + const { req: albumReq, res: albumRes } = createMockReqRes(); + albumReq.user = undefined; + albumReq.params = { user_id: mockUserId }; + albumReq.query = {}; + + jest.spyOn(playlistService, 'getUserPlaylists').mockResolvedValue({ items: [], meta: {} }); + jest.spyOn(playlistService, 'getUserAlbums').mockResolvedValue({ items: [], meta: {} }); + + await playlistController.getUserPlaylists(playlistReq, playlistRes); + await playlistController.getUserAlbums(albumReq, albumRes); + + expect(playlistService.getUserPlaylists).toHaveBeenCalledWith( + expect.objectContaining({ requesterId: null, limit: '5', offset: '10' }) + ); + expect(playlistService.getUserAlbums).toHaveBeenCalledWith( + expect.objectContaining({ requesterId: null }) + ); + jest.restoreAllMocks(); + }); + + it('should reject invalid user_id for albums', async () => { + const { req, res } = createMockReqRes(); + req.params = { user_id: 'not-uuid' }; + + const spy = jest.spyOn(playlistService, 'getUserAlbums').mockResolvedValue({ + items: [], + meta: {}, + }); + + await playlistController.getUserAlbums(req, res); + + expect(spy).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalled(); + jest.restoreAllMocks(); + }); + + it('should reject missing playlist_id before conversion', async () => { + const { req, res } = createMockReqRes(); + req.params = {}; + req.body = { name: 'Converted' }; + + await playlistController.convertPlaylist(req, res); + + expect(res.json).toHaveBeenCalled(); + }); + + it('should pass null requester to getPlaylist and getEmbed when req.user is missing', async () => { + const { req: getReq, res: getRes } = createMockReqRes(); + getReq.user = undefined; + getReq.params = { playlist_id: mockPlaylistId }; + getReq.query = {}; + + const { req: embedReq, res: embedRes } = createMockReqRes(); + embedReq.user = undefined; + embedReq.params = { playlist_id: mockPlaylistId }; + embedReq.query = {}; + + jest.spyOn(playlistService, 'getPlaylist').mockResolvedValue(mockPlaylist); + jest.spyOn(playlistService, 'getEmbed').mockResolvedValue({ + embed_url: 'x', + iframe_html: 'y', + }); + + await playlistController.getPlaylist(getReq, getRes); + await playlistController.getEmbed(embedReq, embedRes); + + expect(playlistService.getPlaylist).toHaveBeenCalledWith( + expect.objectContaining({ userId: null }) + ); + expect(playlistService.getEmbed).toHaveBeenCalledWith( + expect.objectContaining({ userId: null }) + ); + jest.restoreAllMocks(); + }); + + it('should reject invalid update playlist_id before service call', async () => { + const { req, res } = createMockReqRes(); + req.params = { playlist_id: 'not-uuid' }; + req.body = { name: 'Updated' }; + + const spy = jest.spyOn(playlistService, 'updatePlaylist').mockResolvedValue({ + playlist: mockPlaylist, + }); + + await playlistController.updatePlaylist(req, res); + + expect(spy).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalled(); + jest.restoreAllMocks(); + }); + + it('should normalize empty string tags to undefined', async () => { + const { req, res } = createMockReqRes(); + req.params = { playlist_id: mockPlaylistId }; + req.body = { tags: '' }; + + jest.spyOn(playlistService, 'updatePlaylist').mockResolvedValue({ + playlist: mockPlaylist, + }); + + await playlistController.updatePlaylist(req, res); + + expect(playlistService.updatePlaylist).toHaveBeenCalledWith( + expect.objectContaining({ tags: undefined }) + ); + jest.restoreAllMocks(); + }); }); }); diff --git a/tests/playlists/models/playlists.model.test.js b/tests/playlists/models/playlists.model.test.js index d96a2ac..3a4ea49 100644 --- a/tests/playlists/models/playlists.model.test.js +++ b/tests/playlists/models/playlists.model.test.js @@ -1103,5 +1103,26 @@ describe('playlist.model', () => { expect(db.query).toHaveBeenCalled(); }); + + it('should return zero when countUserRegularPlaylists has no row', async () => { + db.query.mockResolvedValueOnce({ rows: [] }); + + const result = await model.countUserRegularPlaylists(mockUserId); + + expect(result).toBe(0); + }); + + it('should ignore tags that cannot be found or created', async () => { + db.query + .mockResolvedValueOnce({ rowCount: 0 }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }); + + const result = await model.replacePlaylistTags(mockPlaylistId, ['lost-tag']); + + expect(result).toEqual([]); + }); }); }); diff --git a/tests/playlists/services/playlists.service.test.js b/tests/playlists/services/playlists.service.test.js index 567e715..f222ff0 100644 --- a/tests/playlists/services/playlists.service.test.js +++ b/tests/playlists/services/playlists.service.test.js @@ -1981,5 +1981,399 @@ describe('Playlist Service', () => { expect(result.items).toBeDefined(); expect(result.meta.total).toBe(1); }); + + it('should resolve playlist slugs before fetching details', async () => { + const slugPlaylistId = uuidv4(); + playlistModel.findBySlug.mockResolvedValue({ id: slugPlaylistId }); + playlistModel.findPlaylistById.mockResolvedValue({ + ...mockPlaylist, + id: slugPlaylistId, + playlist_id: slugPlaylistId, + }); + playlistModel.findPlaylistTracks.mockResolvedValue([]); + playlistModel.getTotalDuration.mockResolvedValue(0); + playlistLikeModel.isPlaylistLikedByUser.mockResolvedValue(false); + playlistModel.getTopTrackArt.mockResolvedValue(null); + + const result = await playlistService.getPlaylist({ + playlistId: 'my-slug', + userId: mockUserId, + includeTracks: true, + }); + + expect(result.playlist_id).toBe(slugPlaylistId); + expect(playlistModel.findPlaylistById).toHaveBeenCalledWith(slugPlaylistId); + }); + + it('should throw when playlist slug cannot be resolved', async () => { + playlistModel.findBySlug.mockResolvedValue(null); + + await expect( + playlistService.getPlaylist({ + playlistId: 'missing-slug', + userId: mockUserId, + includeTracks: true, + }) + ).rejects.toMatchObject({ code: 'PLAYLIST_NOT_FOUND' }); + }); + + it('should use top track art when details omit tracks and cover', async () => { + playlistModel.findPlaylistById.mockResolvedValue({ ...mockPlaylist, cover_image: null }); + playlistModel.getTotalDuration.mockResolvedValue(180); + playlistLikeModel.isPlaylistLikedByUser.mockResolvedValue(false); + playlistModel.getTopTrackArt.mockResolvedValue({ cover_image: 'fallback.jpg' }); + + const result = await playlistService.getPlaylist({ + playlistId: mockPlaylistId, + userId: mockUserId, + includeTracks: false, + }); + + expect(result.cover_image).toBe('fallback.jpg'); + }); + + it('should validate genre existence during update', async () => { + playlistModel.findPlaylistById.mockResolvedValue(mockPlaylist); + db.query.mockResolvedValueOnce({ rows: [{ id: mockGenreId }] }); + playlistModel.updatePlaylist.mockResolvedValue({ ...mockPlaylist, genre_id: mockGenreId }); + + const result = await playlistService.updatePlaylist({ + playlistId: mockPlaylistId, + userId: mockUserId, + genreId: mockGenreId, + genreIdProvided: true, + }); + + expect(result.playlist.genre_id).toBe(mockGenreId); + }); + + it('should throw when update genre does not exist', async () => { + playlistModel.findPlaylistById.mockResolvedValue(mockPlaylist); + db.query.mockResolvedValueOnce({ rows: [] }); + + await expect( + playlistService.updatePlaylist({ + playlistId: mockPlaylistId, + userId: mockUserId, + genreId: mockGenreId, + genreIdProvided: true, + }) + ).rejects.toMatchObject({ code: 'RESOURCE_NOT_FOUND' }); + }); + + it('should generate a token when updating a playlist that has none', async () => { + playlistModel.findPlaylistById.mockResolvedValue({ ...mockPlaylist, secret_token: null }); + playlistModel.updatePlaylist.mockResolvedValue({ ...mockPlaylist, secret_token: 'generated' }); + + await playlistService.updatePlaylist({ + playlistId: mockPlaylistId, + userId: mockUserId, + name: 'Renamed', + }); + + expect(playlistModel.updatePlaylist).toHaveBeenCalledWith( + mockPlaylistId, + expect.objectContaining({ secretToken: expect.any(String) }) + ); + }); + + it('should upload a cover without deleting when no previous cover exists', async () => { + playlistModel.findPlaylistById.mockResolvedValue({ ...mockPlaylist, cover_image: null }); + storageService.uploadImage.mockResolvedValue({ url: 'https://example.com/new.jpg' }); + playlistModel.updatePlaylist.mockResolvedValue({ + ...mockPlaylist, + cover_image: 'https://example.com/new.jpg', + }); + + const result = await playlistService.updatePlaylist({ + playlistId: mockPlaylistId, + userId: mockUserId, + coverImageFile: { originalname: 'new.jpg' }, + }); + + expect(storageService.deleteAllVersionsByUrl).not.toHaveBeenCalled(); + expect(result.playlist.cover_image).toBe('https://example.com/new.jpg'); + }); + + it('should sync cover from first track when clearing a non-custom cover', async () => { + playlistModel.findPlaylistById + .mockResolvedValueOnce(mockPlaylist) + .mockResolvedValueOnce({ ...mockPlaylist, cover_image: 'old.jpg' }); + storageService.deleteAllVersionsByUrl.mockResolvedValue(undefined); + playlistModel.updatePlaylist + .mockResolvedValueOnce({ ...mockPlaylist, cover_image: null }) + .mockResolvedValueOnce({ ...mockPlaylist, cover_image: 'track.jpg' }); + playlistModel.getTopTrackArt.mockResolvedValue({ cover_image: 'track.jpg' }); + + const result = await playlistService.updatePlaylist({ + playlistId: mockPlaylistId, + userId: mockUserId, + clearCoverImage: true, + }); + + expect(result.playlist.cover_image).toBe('track.jpg'); + }); + + it('should keep a custom playlist cover during cover sync', async () => { + const customCover = `https://cdn.example.com/playlists/${mockPlaylistId}/cover.jpg`; + playlistModel.findPlaylistById + .mockResolvedValueOnce(mockPlaylist) + .mockResolvedValueOnce({ ...mockPlaylist, cover_image: customCover }); + storageService.deleteAllVersionsByUrl.mockResolvedValue(undefined); + playlistModel.updatePlaylist.mockResolvedValueOnce({ ...mockPlaylist, cover_image: null }); + + const result = await playlistService.updatePlaylist({ + playlistId: mockPlaylistId, + userId: mockUserId, + clearCoverImage: true, + }); + + expect(result.playlist.cover_image).toBe(customCover); + expect(playlistModel.getTopTrackArt).not.toHaveBeenCalled(); + }); + + it('should throw when updating a generated playlist', async () => { + playlistModel.findPlaylistById.mockResolvedValue({ ...mockPlaylist, type: 'curated_daily' }); + + await expect( + playlistService.updatePlaylist({ + playlistId: mockPlaylistId, + userId: mockUserId, + name: 'Nope', + }) + ).rejects.toMatchObject({ code: 'PLAYLIST_GENERATED_IMMUTABLE' }); + }); + + it('should throw when updating another user playlist', async () => { + playlistModel.findPlaylistById.mockResolvedValue(mockPlaylist); + + await expect( + playlistService.updatePlaylist({ + playlistId: mockPlaylistId, + userId: uuidv4(), + name: 'Nope', + }) + ).rejects.toMatchObject({ code: 'PLAYLIST_FORBIDDEN' }); + }); + + it('should throw when deleting a missing playlist or generated playlist', async () => { + playlistModel.findPlaylistById.mockResolvedValueOnce(null); + + await expect( + playlistService.deletePlaylist({ playlistId: mockPlaylistId, userId: mockUserId }) + ).rejects.toMatchObject({ code: 'PLAYLIST_NOT_FOUND' }); + + playlistModel.findPlaylistById.mockResolvedValueOnce({ ...mockPlaylist, type: 'liked_songs' }); + + await expect( + playlistService.deletePlaylist({ playlistId: mockPlaylistId, userId: mockUserId }) + ).rejects.toMatchObject({ code: 'PLAYLIST_GENERATED_IMMUTABLE' }); + }); + + it('should throw when hard delete returns no row', async () => { + playlistModel.findPlaylistById.mockResolvedValue({ ...mockPlaylist, cover_image: null }); + playlistModel.hardDelete.mockResolvedValue(null); + + await expect( + playlistService.deletePlaylist({ playlistId: mockPlaylistId, userId: mockUserId }) + ).rejects.toMatchObject({ code: 'PLAYLIST_NOT_FOUND' }); + }); + + it('should validate addTrack error branches', async () => { + playlistModel.findPlaylistById.mockResolvedValueOnce(null); + + await expect( + playlistService.addTrack({ playlistId: mockPlaylistId, userId: mockUserId, trackId: mockTrackId }) + ).rejects.toMatchObject({ code: 'PLAYLIST_NOT_FOUND' }); + + playlistModel.findPlaylistById.mockResolvedValueOnce({ ...mockPlaylist, type: 'track_radio' }); + + await expect( + playlistService.addTrack({ playlistId: mockPlaylistId, userId: mockUserId, trackId: mockTrackId }) + ).rejects.toMatchObject({ code: 'PLAYLIST_GENERATED_IMMUTABLE' }); + + playlistModel.findPlaylistById.mockResolvedValueOnce(mockPlaylist); + + await expect( + playlistService.addTrack({ playlistId: mockPlaylistId, userId: uuidv4(), trackId: mockTrackId }) + ).rejects.toMatchObject({ code: 'PLAYLIST_FORBIDDEN' }); + }); + + it('should validate inaccessible tracks before adding them', async () => { + playlistModel.findPlaylistById.mockResolvedValue(mockPlaylist); + + trackModel.findTrackByIdWithDetails.mockResolvedValueOnce(null); + await expect( + playlistService.addTrack({ playlistId: mockPlaylistId, userId: mockUserId, trackId: mockTrackId }) + ).rejects.toMatchObject({ code: 'TRACK_NOT_FOUND' }); + + trackModel.findTrackByIdWithDetails.mockResolvedValueOnce({ ...mockTrack, is_hidden: true }); + await expect( + playlistService.addTrack({ playlistId: mockPlaylistId, userId: mockUserId, trackId: mockTrackId }) + ).rejects.toMatchObject({ code: 'TRACK_NOT_FOUND' }); + + trackModel.findTrackByIdWithDetails.mockResolvedValueOnce({ ...mockTrack, is_public: false }); + await expect( + playlistService.addTrack({ playlistId: mockPlaylistId, userId: mockUserId, trackId: mockTrackId }) + ).rejects.toMatchObject({ code: 'PLAYLIST_TRACK_MUST_BE_PUBLIC' }); + }); + + it('should reject addTrack positions beyond append slot', async () => { + playlistModel.findPlaylistById.mockResolvedValue(mockPlaylist); + trackModel.findTrackByIdWithDetails.mockResolvedValue(mockTrack); + playlistModel.findPlaylistTrack.mockResolvedValue(false); + playlistModel.getMaxPosition.mockResolvedValue(2); + + await expect( + playlistService.addTrack({ + playlistId: mockPlaylistId, + userId: mockUserId, + trackId: mockTrackId, + position: 4, + }) + ).rejects.toMatchObject({ code: 'PLAYLIST_POSITION_INVALID' }); + }); + + it('should deny private playlist tracks without owner or token and allow with token', async () => { + const privatePlaylist = { ...mockPlaylist, is_public: false }; + playlistModel.findPlaylistById.mockResolvedValueOnce(privatePlaylist); + + await expect( + playlistService.getPlaylistTracks({ + playlistId: mockPlaylistId, + userId: uuidv4(), + secretToken: 'bad', + }) + ).rejects.toMatchObject({ code: 'PLAYLIST_ACCESS_DENIED' }); + + playlistModel.findPlaylistById.mockResolvedValueOnce(privatePlaylist); + playlistModel.findPlaylistTracksPaginated.mockResolvedValue({ rows: [], total: 0 }); + playlistModel.getTotalDuration.mockResolvedValue(0); + + const result = await playlistService.getPlaylistTracks({ + playlistId: mockPlaylistId, + userId: null, + secretToken: 'secret123', + page: '2', + limit: '10', + }); + + expect(result.pagination.has_prev).toBe(true); + }); + + it('should throw when getting tracks for a missing playlist', async () => { + playlistModel.findPlaylistById.mockResolvedValue(null); + + await expect( + playlistService.getPlaylistTracks({ playlistId: mockPlaylistId, userId: mockUserId }) + ).rejects.toMatchObject({ code: 'PLAYLIST_NOT_FOUND' }); + }); + + it('should validate reorder error branches', async () => { + playlistModel.findPlaylistById.mockResolvedValueOnce(null); + + await expect( + playlistService.reorderPlaylistTracks({ playlistId: mockPlaylistId, userId: mockUserId, items: [] }) + ).rejects.toMatchObject({ code: 'PLAYLIST_NOT_FOUND' }); + + playlistModel.findPlaylistById.mockResolvedValueOnce({ ...mockPlaylist, type: 'curated_weekly' }); + await expect( + playlistService.reorderPlaylistTracks({ playlistId: mockPlaylistId, userId: mockUserId, items: [] }) + ).rejects.toMatchObject({ code: 'PLAYLIST_GENERATED_IMMUTABLE' }); + + playlistModel.findPlaylistById.mockResolvedValueOnce(mockPlaylist); + await expect( + playlistService.reorderPlaylistTracks({ playlistId: mockPlaylistId, userId: uuidv4(), items: [] }) + ).rejects.toMatchObject({ code: 'PLAYLIST_FORBIDDEN' }); + + playlistModel.findPlaylistById.mockResolvedValueOnce(mockPlaylist); + await expect( + playlistService.reorderPlaylistTracks({ playlistId: mockPlaylistId, userId: mockUserId, items: [] }) + ).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }); + }); + + it('should reject reorder when an incoming track is missing from playlist', async () => { + playlistModel.findPlaylistById.mockResolvedValue(mockPlaylist); + playlistModel.getAllTracksInPlaylist.mockResolvedValue([mockTrackId]); + + await expect( + playlistService.reorderPlaylistTracks({ + playlistId: mockPlaylistId, + userId: mockUserId, + items: [{ track_id: uuidv4(), position: 1 }], + }) + ).rejects.toMatchObject({ code: 'PLAYLIST_TRACK_NOT_FOUND' }); + }); + + it('should validate removeTrack error branches', async () => { + playlistModel.findPlaylistById.mockResolvedValueOnce(null); + await expect( + playlistService.removeTrack({ playlistId: mockPlaylistId, userId: mockUserId, trackId: mockTrackId }) + ).rejects.toMatchObject({ code: 'PLAYLIST_NOT_FOUND' }); + + playlistModel.findPlaylistById.mockResolvedValueOnce({ ...mockPlaylist, type: 'genre_trending' }); + await expect( + playlistService.removeTrack({ playlistId: mockPlaylistId, userId: mockUserId, trackId: mockTrackId }) + ).rejects.toMatchObject({ code: 'PLAYLIST_GENERATED_IMMUTABLE' }); + + playlistModel.findPlaylistById.mockResolvedValueOnce(mockPlaylist); + await expect( + playlistService.removeTrack({ playlistId: mockPlaylistId, userId: uuidv4(), trackId: mockTrackId }) + ).rejects.toMatchObject({ code: 'PLAYLIST_FORBIDDEN' }); + + playlistModel.findPlaylistById.mockResolvedValueOnce(mockPlaylist); + playlistModel.removeTrackFromPlaylist.mockResolvedValueOnce(false); + await expect( + playlistService.removeTrack({ playlistId: mockPlaylistId, userId: mockUserId, trackId: mockTrackId }) + ).rejects.toMatchObject({ code: 'PLAYLIST_TRACK_NOT_FOUND' }); + }); + + it('should validate getEmbed private access and sanitization branches', async () => { + const privatePlaylist = { ...mockPlaylist, is_public: false }; + playlistModel.findPlaylistById.mockResolvedValueOnce(null); + + await expect( + playlistService.getEmbed({ playlistId: mockPlaylistId, userId: mockUserId }) + ).rejects.toMatchObject({ code: 'PLAYLIST_NOT_FOUND' }); + + playlistModel.findPlaylistById.mockResolvedValueOnce(privatePlaylist); + await expect( + playlistService.getEmbed({ playlistId: mockPlaylistId, userId: uuidv4() }) + ).rejects.toMatchObject({ code: 'PLAYLIST_ACCESS_DENIED' }); + + playlistModel.findPlaylistById.mockResolvedValueOnce(privatePlaylist); + const result = await playlistService.getEmbed({ + playlistId: mockPlaylistId, + userId: null, + secretToken: 'secret123', + theme: 'dark', + autoplay: true, + width: '9999', + height: '5', + }); + + expect(result.iframe_html).toContain("width='1200'"); + expect(result.iframe_html).toContain("height='100'"); + expect(result.embed_url).toContain('autoplay=true'); + }); + + it('should handle non-array generated track providers defensively', async () => { + playlistModel.findPlaylistById.mockResolvedValue({ ...mockPlaylist, type: 'curated_weekly' }); + feedModel.getWeeklyTracks.mockResolvedValue('not-array'); + playlistModel.create.mockResolvedValue({ ...mockPlaylist, id: uuidv4(), secret_token: 'new' }); + playlistModel.findBySlug.mockResolvedValue(null); + db.query.mockResolvedValue({ rows: [] }); + playlistModel.hardDelete.mockResolvedValue(true); + playlistModel.findPlaylistTracks.mockResolvedValue([]); + + const result = await playlistService.convertPlaylist({ + playlistId: mockPlaylistId, + userId: mockUserId, + name: 'Weekly Snapshot', + }); + + expect(result.playlist.secret_token).toBeDefined(); + expect(playlistModel.bulkInsertTracks).not.toHaveBeenCalled(); + }); }); });