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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/__tests__/CommentController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const mockGetSongComments = jest.fn();
const mockGetReplies = jest.fn();
const mockUpdateComment = jest.fn();
const mockDeleteComment = jest.fn();
const mockSubmitReport = jest.fn();

jest.mock('../services/CommentService', () => ({
CommentService: jest.fn().mockImplementation(() => ({
Expand All @@ -16,6 +17,12 @@ jest.mock('../services/CommentService', () => ({
})),
}));

jest.mock('../services/CommentReportService', () => ({
CommentReportService: jest.fn().mockImplementation(() => ({
submitReport: mockSubmitReport,
})),
}));

import { CommentController } from '../controllers/CommentController';
import { Request, Response } from 'express';

Expand All @@ -35,6 +42,7 @@ beforeEach(() => {
mockGetReplies.mockReset();
mockUpdateComment.mockReset();
mockDeleteComment.mockReset();
mockSubmitReport.mockReset();
});

describe('CommentController.createComment', () => {
Expand Down
145 changes: 145 additions & 0 deletions src/__tests__/CommentReportService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import 'reflect-metadata';

jest.mock('../config/db', () => ({
__esModule: true,
default: { getRepository: jest.fn() },
}));

import AppDataSource from '../config/db';
import { CommentReportService } from '../services/CommentReportService';
import { CommentReport } from '../entities/CommentReport';
import { Comment } from '../entities/Comment';

const mockReportRepo = {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
findOneBy: jest.fn(),
findAndCount: jest.fn(),
count: jest.fn(),
};
const mockCommentRepo = {
findOneBy: jest.fn(),
save: jest.fn(),
};

beforeEach(() => {
jest.clearAllMocks();
(AppDataSource.getRepository as jest.Mock).mockImplementation((entity: unknown) => {
if (entity === CommentReport) return mockReportRepo;
if (entity === Comment) return mockCommentRepo;
throw new Error(`Unexpected entity: ${(entity as { name?: string })?.name}`);
});
});

function makeSvc(): CommentReportService {
return new CommentReportService();
}

describe('CommentReportService.submitReport', () => {
it('flags a comment and enqueues it for moderation (Issue #411)', async () => {
mockCommentRepo.findOneBy.mockResolvedValue({ id: 'c-1', text: 'bad comment' });
mockReportRepo.findOne.mockResolvedValue(null);
mockReportRepo.create.mockImplementation((input: unknown) => input);
mockReportRepo.save.mockImplementation(async (r: CommentReport) => r);

const svc = makeSvc();
const report = await svc.submitReport('c-1', 'u-1', { reason: 'harassment' });

expect(mockReportRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
commentId: 'c-1',
reporterId: 'u-1',
reason: 'harassment',
status: 'pending',
}),
);
expect(report.commentId).toBe('c-1');
});

it('rejects an unknown reason', async () => {
mockCommentRepo.findOneBy.mockResolvedValue({ id: 'c-1' });

const svc = makeSvc();
await expect(svc.submitReport('c-1', 'u-1', { reason: 'nope' })).rejects.toMatchObject({
statusCode: 400,
});
});

it('rejects duplicate reports for the same comment and reporter', async () => {
mockCommentRepo.findOneBy.mockResolvedValue({ id: 'c-1' });
mockReportRepo.findOne.mockResolvedValue({ id: 'r-1', commentId: 'c-1', reporterId: 'u-1' });

const svc = makeSvc();
await expect(svc.submitReport('c-1', 'u-1', { reason: 'spam' })).rejects.toMatchObject({
statusCode: 409,
});
});

it('returns 404 when the comment does not exist', async () => {
mockCommentRepo.findOneBy.mockResolvedValue(null);

const svc = makeSvc();
await expect(svc.submitReport('ghost', 'u-1', { reason: 'spam' })).rejects.toMatchObject({
statusCode: 404,
});
});
});

describe('CommentReportService.listPendingReports', () => {
it('returns flagged comments with full context', async () => {
mockReportRepo.findAndCount.mockResolvedValue([
[{ id: 'r-1', comment: { id: 'c-1', text: 'bad', songId: 's-1' } }],
1,
]);

const svc = makeSvc();
const result = await svc.listPendingReports(1, 20);

expect(mockReportRepo.findAndCount).toHaveBeenCalledWith(
expect.objectContaining({
where: { status: 'pending' },
relations: { comment: true },
skip: 0,
take: 20,
}),
);
expect(result.reports).toHaveLength(1);
expect(result.reports[0].commentText).toBe('bad');
expect(result.reports[0].songId).toBe('s-1');
expect(result.pagination.total).toBe(1);
});
});

describe('CommentReportService.resolveReport', () => {
it('flags the comment when the moderator removes it', async () => {
mockReportRepo.findOneBy.mockResolvedValue({
id: 'r-1',
commentId: 'c-1',
status: 'pending',
actionTaken: null,
});
mockReportRepo.save.mockImplementation(async (r: CommentReport) => r);
mockCommentRepo.findOneBy.mockResolvedValue({ id: 'c-1', flagged: false });
mockCommentRepo.save.mockImplementation(async (c: Comment) => c);

const svc = makeSvc();
await svc.resolveReport('r-1', 'mod-1', { actionTaken: 'comment_removed' });

expect(mockCommentRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ id: 'c-1', flagged: true }),
);
});

it('rejects resolving an already resolved report', async () => {
mockReportRepo.findOneBy.mockResolvedValue({
id: 'r-1',
status: 'resolved',
});

const svc = makeSvc();
await expect(svc.resolveReport('r-1', 'mod-1', { actionTaken: 'dismissed' })).rejects.toMatchObject(
{ statusCode: 409 },
);
});
});
125 changes: 125 additions & 0 deletions src/__tests__/PlaylistService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { PlaylistService } from '../services/PlaylistService';
import { Playlist } from '../entities/Playlist';
import { PlaylistSong } from '../entities/PlaylistSong';
import { PlaylistCollaborator, PlaylistCollaboratorRole } from '../entities/PlaylistCollaborator';
import { PlaylistFollow } from '../entities/PlaylistFollow';
import { Song } from '../entities/Song';

const mockPlaylistRepo = {
Expand All @@ -35,6 +36,14 @@ const mockCollaboratorRepo = {
save: jest.fn(),
delete: jest.fn(),
};
const mockFollowRepo = {
findOneBy: jest.fn(),
find: jest.fn(),
insert: jest.fn(),
delete: jest.fn(),
findAndCount: jest.fn(),
count: jest.fn(),
};
const mockSongRepo = {
findOneBy: jest.fn(),
createQueryBuilder: jest.fn(),
Expand All @@ -46,6 +55,7 @@ beforeEach(() => {
if (entity === Playlist) return mockPlaylistRepo;
if (entity === PlaylistSong) return mockPlaylistSongRepo;
if (entity === PlaylistCollaborator) return mockCollaboratorRepo;
if (entity === PlaylistFollow) return mockFollowRepo;
if (entity === Song) return mockSongRepo;
throw new Error(`Unexpected entity: ${(entity as { name?: string })?.name}`);
});
Expand Down Expand Up @@ -437,3 +447,118 @@ describe('PlaylistService rule-based playlists (Issue #407)', () => {
expect(result.songs[0].songId).toBe('song-a');
});
});

describe('PlaylistService.moveSong (Issue #409)', () => {
const entries = [
{ id: 'ps-1', playlistId: 'pl-1', songId: 'song-1', position: 0 },
{ id: 'ps-2', playlistId: 'pl-1', songId: 'song-2', position: 1 },
{ id: 'ps-3', playlistId: 'pl-1', songId: 'song-3', position: 2 },
];

it('moves a song to a new position, compacting the order', async () => {
mockPlaylistRepo.findOneBy.mockResolvedValue(ownedPlaylist());
mockPlaylistSongRepo.find.mockResolvedValue(entries.map((e) => ({ ...e })));
mockPlaylistSongRepo.save.mockImplementation(async (saved: PlaylistSong[]) => saved);
mockPlaylistRepo.findOne.mockResolvedValue(ownedPlaylist({ songs: [] as unknown as PlaylistSong[] }));

const svc = makeSvc();
await svc.moveSong('pl-1', 'user-1', 'song-3', 0);

const saved = mockPlaylistSongRepo.save.mock.calls[0][0] as PlaylistSong[];
expect(saved.map((e) => e.songId)).toEqual(['song-3', 'song-1', 'song-2']);
expect(saved.map((e) => e.position)).toEqual([0, 1, 2]);
});

it('rejects moving a song that is not in the playlist', async () => {
mockPlaylistRepo.findOneBy.mockResolvedValue(ownedPlaylist());
mockPlaylistSongRepo.find.mockResolvedValue(entries.map((e) => ({ ...e })));

const svc = makeSvc();
await expect(svc.moveSong('pl-1', 'user-1', 'ghost', 1)).rejects.toMatchObject({
statusCode: 404,
});
});

it('rejects a negative position', async () => {
mockPlaylistRepo.findOneBy.mockResolvedValue(ownedPlaylist());

const svc = makeSvc();
await expect(svc.moveSong('pl-1', 'user-1', 'song-1', -1)).rejects.toMatchObject({
statusCode: 400,
});
});

it('rejects moving a song in a rule-based playlist', async () => {
mockPlaylistRepo.findOneBy.mockResolvedValue(ownedPlaylist({ isRuleBased: true }));

const svc = makeSvc();
await expect(svc.moveSong('pl-1', 'user-1', 'song-1', 0)).rejects.toMatchObject({
statusCode: 400,
});
});
});

describe('PlaylistService follow (Issue #408)', () => {
it('follows a playlist owned by another user', async () => {
mockPlaylistRepo.findOneBy.mockResolvedValue(ownedPlaylist({ userId: 'user-2' }));
mockFollowRepo.findOneBy.mockResolvedValue(null);
mockFollowRepo.count.mockResolvedValue(2);

const svc = makeSvc();
const result = await svc.followPlaylist('user-1', 'pl-1');

expect(mockFollowRepo.insert).toHaveBeenCalledWith({ userId: 'user-1', playlistId: 'pl-1' });
expect(result).toEqual({ followed: true, followerCount: 2 });
});

it('is idempotent when already following', async () => {
mockPlaylistRepo.findOneBy.mockResolvedValue(ownedPlaylist({ userId: 'user-2' }));
mockFollowRepo.findOneBy.mockResolvedValue({ id: 'pf-1', userId: 'user-1', playlistId: 'pl-1' });
mockFollowRepo.count.mockResolvedValue(3);

const svc = makeSvc();
const result = await svc.followPlaylist('user-1', 'pl-1');

expect(mockFollowRepo.insert).not.toHaveBeenCalled();
expect(result).toEqual({ followed: false, followerCount: 3 });
});

it('rejects following your own playlist', async () => {
mockPlaylistRepo.findOneBy.mockResolvedValue(ownedPlaylist({ userId: 'user-1' }));

const svc = makeSvc();
await expect(svc.followPlaylist('user-1', 'pl-1')).rejects.toMatchObject({ statusCode: 400 });
});

it('unfollows a playlist', async () => {
mockFollowRepo.delete.mockResolvedValue({ affected: 1 });

const svc = makeSvc();
await svc.unfollowPlaylist('user-1', 'pl-1');

expect(mockFollowRepo.delete).toHaveBeenCalledWith({ userId: 'user-1', playlistId: 'pl-1' });
});

it('lists followed playlists newest first', async () => {
const playlist = ownedPlaylist({ userId: 'user-2', id: 'pl-1' });
mockFollowRepo.findAndCount.mockResolvedValue([
[{ id: 'pf-1', playlist, createdAt: new Date('2026-01-01') }],
1,
]);

const svc = makeSvc();
const result = await svc.listFollowedPlaylists('user-1', 1, 20);

expect(mockFollowRepo.findAndCount).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: 'user-1' },
relations: { playlist: { user: true } },
skip: 0,
take: 20,
}),
);
expect(result.data).toHaveLength(1);
expect(result.data[0].playlist.id).toBe('pl-1');
expect(result.pagination.total).toBe(1);
});
});
Loading