Skip to content

Commit eedb32d

Browse files
junzero741claude
andcommitted
feat(backend): add report and admin services with tests
- reports.service: 신고 생성 (중복/만료 검증 포함) - admin.service: 신고 목록 조회, 게시글 삭제, 신고 기각 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 439cef2 commit eedb32d

4 files changed

Lines changed: 287 additions & 0 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { AppError } from '../lib/errors';
2+
import { getReports, deletePost, dismissReport } from './admin.service';
3+
4+
jest.mock('../lib/prisma', () => ({
5+
__esModule: true,
6+
default: {
7+
post: {
8+
findUnique: jest.fn(),
9+
delete: jest.fn(),
10+
},
11+
report: {
12+
findMany: jest.fn(),
13+
findUnique: jest.fn(),
14+
update: jest.fn(),
15+
},
16+
},
17+
}));
18+
19+
import prisma from '../lib/prisma';
20+
21+
const mockPost = prisma.post as jest.Mocked<typeof prisma.post>;
22+
const mockReport = prisma.report as jest.Mocked<typeof prisma.report>;
23+
24+
const PENDING_REPORT = {
25+
id: 'report-id-1',
26+
postId: 'post-id-1',
27+
reason: 'ILLEGAL_CONTENT',
28+
description: null,
29+
reporterIp: '1.2.3.4',
30+
status: 'PENDING',
31+
createdAt: new Date(),
32+
post: {
33+
slug: 'abc123',
34+
title: '신고 게시글',
35+
content: '<p>내용</p>',
36+
},
37+
};
38+
39+
describe('admin.service', () => {
40+
beforeEach(() => {
41+
jest.clearAllMocks();
42+
});
43+
44+
describe('getReports', () => {
45+
it('PENDING 상태의 신고 목록을 최신순으로 반환한다', async () => {
46+
(mockReport.findMany as jest.Mock).mockResolvedValue([PENDING_REPORT]);
47+
48+
const result = await getReports();
49+
50+
expect(result).toEqual([PENDING_REPORT]);
51+
expect(mockReport.findMany).toHaveBeenCalledWith({
52+
where: { status: 'PENDING' },
53+
include: { post: { select: { slug: true, title: true, content: true } } },
54+
orderBy: { createdAt: 'desc' },
55+
});
56+
});
57+
58+
it('신고가 없으면 빈 배열을 반환한다', async () => {
59+
(mockReport.findMany as jest.Mock).mockResolvedValue([]);
60+
61+
const result = await getReports();
62+
63+
expect(result).toEqual([]);
64+
});
65+
});
66+
67+
describe('deletePost', () => {
68+
it('존재하는 게시글을 삭제한다', async () => {
69+
(mockPost.findUnique as jest.Mock).mockResolvedValue({ id: 'post-id-1', slug: 'abc123' });
70+
(mockPost.delete as jest.Mock).mockResolvedValue({});
71+
72+
await expect(deletePost('abc123')).resolves.toBeUndefined();
73+
74+
expect(mockPost.delete).toHaveBeenCalledWith({ where: { slug: 'abc123' } });
75+
});
76+
77+
it('존재하지 않는 게시글이면 NOT_FOUND를 던진다', async () => {
78+
(mockPost.findUnique as jest.Mock).mockResolvedValue(null);
79+
80+
await expect(deletePost('no-exist')).rejects.toMatchObject({
81+
statusCode: 404,
82+
code: 'NOT_FOUND',
83+
});
84+
85+
expect(mockPost.delete).not.toHaveBeenCalled();
86+
});
87+
});
88+
89+
describe('dismissReport', () => {
90+
it('신고를 DISMISSED 상태로 변경한다', async () => {
91+
(mockReport.findUnique as jest.Mock).mockResolvedValue({ id: 'report-id-1', status: 'PENDING' });
92+
(mockReport.update as jest.Mock).mockResolvedValue({});
93+
94+
await expect(dismissReport('report-id-1')).resolves.toBeUndefined();
95+
96+
expect(mockReport.update).toHaveBeenCalledWith({
97+
where: { id: 'report-id-1' },
98+
data: { status: 'DISMISSED' },
99+
});
100+
});
101+
102+
it('존재하지 않는 신고이면 NOT_FOUND를 던진다', async () => {
103+
(mockReport.findUnique as jest.Mock).mockResolvedValue(null);
104+
105+
await expect(dismissReport('no-exist')).rejects.toMatchObject({
106+
statusCode: 404,
107+
code: 'NOT_FOUND',
108+
});
109+
110+
expect(mockReport.update).not.toHaveBeenCalled();
111+
});
112+
});
113+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import prisma from '../lib/prisma';
2+
import { AppError } from '../lib/errors';
3+
4+
export async function getReports() {
5+
return prisma.report.findMany({
6+
where: { status: 'PENDING' },
7+
include: { post: { select: { slug: true, title: true, content: true } } },
8+
orderBy: { createdAt: 'desc' },
9+
});
10+
}
11+
12+
export async function deletePost(slug: string): Promise<void> {
13+
const post = await prisma.post.findUnique({ where: { slug } });
14+
if (!post) throw new AppError(404, 'Post not found', 'NOT_FOUND');
15+
await prisma.post.delete({ where: { slug } });
16+
}
17+
18+
export async function dismissReport(id: string): Promise<void> {
19+
const report = await prisma.report.findUnique({ where: { id } });
20+
if (!report) throw new AppError(404, 'Report not found', 'NOT_FOUND');
21+
await prisma.report.update({ where: { id }, data: { status: 'DISMISSED' } });
22+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { AppError } from '../lib/errors';
2+
import { createReport } from './reports.service';
3+
4+
jest.mock('../lib/prisma', () => ({
5+
__esModule: true,
6+
default: {
7+
post: {
8+
findUnique: jest.fn(),
9+
},
10+
report: {
11+
findFirst: jest.fn(),
12+
create: jest.fn(),
13+
},
14+
},
15+
}));
16+
17+
import prisma from '../lib/prisma';
18+
19+
const mockPost = prisma.post as jest.Mocked<typeof prisma.post>;
20+
const mockReport = prisma.report as jest.Mocked<typeof prisma.report>;
21+
22+
const VALID_POST = {
23+
id: 'post-id-1',
24+
slug: 'abc123',
25+
title: 'Test',
26+
content: '<p>hello</p>',
27+
passwordHash: 'hash',
28+
expiresAt: null,
29+
createdAt: new Date(),
30+
updatedAt: new Date(),
31+
};
32+
33+
describe('reports.service', () => {
34+
beforeEach(() => {
35+
jest.clearAllMocks();
36+
});
37+
38+
describe('createReport', () => {
39+
it('정상적인 신고를 생성하고 postId, reason, ip를 저장한다', async () => {
40+
(mockPost.findUnique as jest.Mock).mockResolvedValue(VALID_POST);
41+
(mockReport.findFirst as jest.Mock).mockResolvedValue(null);
42+
(mockReport.create as jest.Mock).mockResolvedValue({});
43+
44+
await expect(
45+
createReport('abc123', 'ILLEGAL_CONTENT', undefined, '1.2.3.4')
46+
).resolves.toBeUndefined();
47+
48+
expect(mockReport.create).toHaveBeenCalledWith({
49+
data: {
50+
postId: 'post-id-1',
51+
reason: 'ILLEGAL_CONTENT',
52+
description: undefined,
53+
reporterIp: '1.2.3.4',
54+
},
55+
});
56+
});
57+
58+
it('description이 있으면 함께 저장한다', async () => {
59+
(mockPost.findUnique as jest.Mock).mockResolvedValue(VALID_POST);
60+
(mockReport.findFirst as jest.Mock).mockResolvedValue(null);
61+
(mockReport.create as jest.Mock).mockResolvedValue({});
62+
63+
await createReport('abc123', 'OTHER', '상세 설명', '1.2.3.4');
64+
65+
const createArg = (mockReport.create as jest.Mock).mock.calls[0][0];
66+
expect(createArg.data.description).toBe('상세 설명');
67+
});
68+
69+
it('존재하지 않는 게시글이면 NOT_FOUND를 던진다', async () => {
70+
(mockPost.findUnique as jest.Mock).mockResolvedValue(null);
71+
72+
await expect(
73+
createReport('no-exist', 'PHISHING', undefined, '1.2.3.4')
74+
).rejects.toMatchObject({ statusCode: 404, code: 'NOT_FOUND' });
75+
76+
expect(mockReport.create).not.toHaveBeenCalled();
77+
});
78+
79+
it('만료된 게시글이면 EXPIRED를 던진다', async () => {
80+
(mockPost.findUnique as jest.Mock).mockResolvedValue({
81+
...VALID_POST,
82+
expiresAt: new Date(Date.now() - 1000),
83+
});
84+
85+
await expect(
86+
createReport('abc123', 'DEFAMATION', undefined, '1.2.3.4')
87+
).rejects.toMatchObject({ statusCode: 410, code: 'EXPIRED' });
88+
89+
expect(mockReport.create).not.toHaveBeenCalled();
90+
});
91+
92+
it('동일 IP가 같은 게시글을 같은 사유로 이미 신고했으면 ALREADY_REPORTED를 던진다', async () => {
93+
(mockPost.findUnique as jest.Mock).mockResolvedValue(VALID_POST);
94+
(mockReport.findFirst as jest.Mock).mockResolvedValue({ id: 'existing-report' });
95+
96+
await expect(
97+
createReport('abc123', 'COPYRIGHT', undefined, '1.2.3.4')
98+
).rejects.toMatchObject({ statusCode: 409, code: 'ALREADY_REPORTED' });
99+
100+
expect(mockReport.create).not.toHaveBeenCalled();
101+
});
102+
103+
it('동일 IP라도 다른 사유로는 같은 게시글을 신고할 수 있다', async () => {
104+
(mockPost.findUnique as jest.Mock).mockResolvedValue(VALID_POST);
105+
(mockReport.findFirst as jest.Mock).mockResolvedValue(null);
106+
(mockReport.create as jest.Mock).mockResolvedValue({});
107+
108+
await expect(
109+
createReport('abc123', 'DEFAMATION', undefined, '1.2.3.4')
110+
).resolves.toBeUndefined();
111+
112+
expect(mockReport.findFirst).toHaveBeenCalledWith(
113+
expect.objectContaining({ where: expect.objectContaining({ reason: 'DEFAMATION' }) })
114+
);
115+
});
116+
117+
it('동일 IP라도 다른 게시글은 신고할 수 있다', async () => {
118+
(mockPost.findUnique as jest.Mock).mockResolvedValue({ ...VALID_POST, id: 'post-id-2' });
119+
(mockReport.findFirst as jest.Mock).mockResolvedValue(null);
120+
(mockReport.create as jest.Mock).mockResolvedValue({});
121+
122+
await expect(
123+
createReport('xyz789', 'PERSONAL_INFO', undefined, '1.2.3.4')
124+
).resolves.toBeUndefined();
125+
});
126+
});
127+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import prisma from '../lib/prisma';
2+
import { AppError } from '../lib/errors';
3+
import { ReportReason } from '@private-board/shared';
4+
5+
export async function createReport(
6+
slug: string,
7+
reason: ReportReason,
8+
description: string | undefined,
9+
ip: string
10+
): Promise<void> {
11+
const post = await prisma.post.findUnique({ where: { slug } });
12+
if (!post) throw new AppError(404, 'Post not found', 'NOT_FOUND');
13+
if (post.expiresAt && post.expiresAt < new Date()) {
14+
throw new AppError(410, 'Post has expired', 'EXPIRED');
15+
}
16+
17+
const existing = await prisma.report.findFirst({
18+
where: { postId: post.id, reporterIp: ip, reason, status: 'PENDING' },
19+
});
20+
if (existing) throw new AppError(409, 'Already reported', 'ALREADY_REPORTED');
21+
22+
await prisma.report.create({
23+
data: { postId: post.id, reason, description, reporterIp: ip },
24+
});
25+
}

0 commit comments

Comments
 (0)