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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
node_modules
.env
.env.*
.env.*

coverage/
111 changes: 79 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,32 +1,79 @@
## 프로젝트 작업 내역 (날짜별, 시간별 정리)

**2025년 05월 09일 (금)**

- (미정) Multer 설정 완료 (`app.js`).
- (미정) MVC (Model-View-Controller) 패턴 기반 API 서버 구축 목표 설정 (controllers/services/models).
- (미정) **컨트롤러:** Request 수신 및 에러 처리 확인 (request-response 동작 확인, 로직 오류 가능성 존재).
- (미정) **DB 연결:** Prisma Studio (포트 5555)를 통한 DB 구조 및 CRUD 기능 확인 완료.
- (미정) **API 테스트:**
- (미정) `GET http://localhost:3000/articles/:id`를 통한 특정 게시물 조회 및 없는 목록 조회 기능 확인 완료.
- (미정) "Please authenticate" 메시지를 통해 게시물 등록 시 인증/인가 구현 필요성 확인.
- (미정) **인증/인가:** 회원가입 및 로그인 후 획득한 토큰을 사용하여 게시물 등록 기능 구현 완료 (auth.middleware.js 사용).
- (미정) **MVC 패턴 개선:**
- (미정) 기존 컨트롤러에 집중된 로직 분리 필요성 인식.
- (미정) Prisma 클라이언트 관련 코드를 **model 레이어**로 이동 계획.
- (미정) **컨트롤러 레이어:** 사용자 요청 수신, 서비스 레이어 호출 및 응답 처리 집중.
- (미정) **서비스 레이어:** 컨트롤러로부터 전달받은 데이터 활용, 모델 레이어 호출 (기본 형태).
- (미정) 추가적인 서비스 로직은 서비스 레이어에 작성 계획.
- (미정) Request 처리 흐름 구체화: `routes (미들웨어 적용) -> controller -> service -> model`.
- (미정) **Express MVC 구조 명확화:**
- (미정) **routes Layer:** 엔드포인트 정의 및 컨트롤러 연결 (미들웨어 적용 가능).
- (미정) **controller Layer:** routes 호출 처리, service Layer 호출.
- (미정) **service Layer:** controller 호출 처리, model Layer 호출.
- (미정) **model Layer:** Prisma 클라이언트 활용, DB 요청 및 응답 처리.

**2025년 05월 12일 (월)**

- (미정) HTTP 파일 작성 및 필요한 API (product) 우선 작성 후 테스트 및 MVC 분리 진행 계획.
- (미정) 상품 등록 및 조회 API의 MVC 패턴 분리 완료 (등록 기능 포함).
- (미정) Conventional Commits 확장 사용 경험: 커밋 메시지 작성 편의성 향상 (긍정적).
- (미정) **다음 작업:** 상품 수정/삭제 API 진행 및 프론트엔드 연동 필요성 검토 (구현 요구사항 문서 확인).
- (오후 13:30) 상품 수정/삭제 API 진행 및 테스트 완료. 프론트엔드 기능 연결 여부 확인 위해 구현 요구사항 문서 검토 필요.
## 백엔드/프론트엔드 배포 및 테스트 구현 요구사항 정리

---

### 1. 공통 기본 요구사항

- **AWS 계정 관리**:
- AWS **루트 사용자 계정**을 생성하거나 기존 계정을 활용해야 합니다.
- AWS **Free Tier(프리 티어)** 제공 범위를 파악하고 적극 활용해야 합니다.
- **EC2 과금 정책**에 주의하며, 프리 티어 한도 내에서 인스턴스 중지 및 종료 과정을 숙지해야 합니다.
- 모든 리전 설정은 아시아 태평양(서울)로 합니다.

---

### 2. 백엔드 배포 기본 요구사항

#### 2.1. 프로젝트 구조 및 환경 설정

- **배포에 적합한 프로젝트 구조**를 설정해야 합니다.
- **개발(development) 및 배포(production) 환경 설정을 구분**하고, **환경 변수**를 사용하여 관리해야 합니다.

#### 2.2. AWS S3를 이용한 파일 업로드 시스템 구축

- **AWS S3 버킷을 생성**하고, 파일 업로드를 위한 설정을 완료해야 합니다.
- `multer-s3` 라이브러리를 사용하여 **이미지 업로드 미들웨어를 S3로 변경**해야 합니다.
- S3에 **이미지 업로드가 정상적으로 작동하는지 확인**해야 합니다.

#### 2.3. AWS RDS를 사용한 데이터베이스 관리

- **AWS RDS 인스턴스를 설정**하고, 프로젝트 데이터베이스와 연결해야 합니다.
- RDS에서 **데이터베이스의 초기화 및 CRUD 작업**을 테스트해야 합니다.

#### 2.4. AWS EC2에서의 애플리케이션 운영

- **AWS EC2 인스턴스를 생성**해야 합니다. 프리티어에 해당하는 인스턴스 타입과 운영 체제(OS)를 선택해야 합니다.
- EC2 인스턴스에 대한 **보안 그룹을 설정**해야 합니다. HTTP(포트 80), HTTPS(포트 443), SSH(포트 22) 등 필요한 포트를 열어 네트워크 연결을 구성해야 합니다.
- **프로세스 매니저 pm2**를 사용하여 애플리케이션을 백그라운드에서 실행시켜야 합니다.
- **Nginx를 이용한 리버스 프록시 설정**을 구축하고, 외부 접속을 관리해야 합니다.

#### 2.5. 백엔드 테스트 구현 (Jest 활용)

- **Jest 설정 파일(jest.config.js)을 만들고 기본 설정**을 완료해야 합니다.
- **상품 CRUD 연산에 대한 유닛 테스트**를 작성해야 합니다. 각 CRUD 연산에 대해 적절한 입력과 예상 출력을 정의하여 테스트 코드를 구현해야 합니다.
- **사용자의 접근 권한 검증**을 고려하여 상품 CRUD 연산에 대한 시나리오를 테스트해야 합니다.
- **회원가입, 로그인에 대한 유닛 테스트**를 작성해야 합니다.
- **API 요청이나 데이터베이스 작업 등 비동기 코드에 대한 테스트**를 작성해야 합니다. `async/await`와 `done` 콜백을 사용하여 비동기 코드의 완료를 테스트해야 합니다.
- **Mock, Spy와 같은 테스트 더블**을 사용하여 외부 서비스와의 상호 작용을 테스트해야 합니다.
- `describe`와 `test` 블록을 사용하여 **테스트 케이스를 그룹화하고 정리**해야 합니다.

---

### 3. 프론트엔드 배포 기본 요구사항

- **AWS Amplify 또는 Vercel**을 활용하여 프론트엔드를 배포해야 합니다.
- **AWS에 배포된 백엔드의 주소**에 맞게 프론트엔드의 API 주소를 변경해야 합니다.

---

### 4. 심화 요구사항

#### 4.1. 테스트 구현 확장

- **Jest의 테스트 커버리지 도구**를 사용하여 코드 커버리지를 분석하고 결과를 확인해야 합니다.
- **커버리지 결과를 바탕으로 누락된 테스트 케이스를 추가**해야 합니다. 커버리지 보고서를 검토하여 테스트되지 않은 코드 영역을 찾아내고 적절한 테스트를 추가해야 합니다.

#### 4.2. 상품 이미지 업로드 기능 개선

- **AWS S3의 Presigned URL 기능**을 활용하여 상품 이미지 업로드 기능을 구현해야 합니다.

#### 4.3. AWS Route 53을 활용한 도메인 관리

- **AWS Route 53을 사용하여 도메인을 구매하거나 기존 도메인을 연결**해야 합니다.
- Route 53에서 **DNS 설정을 관리**하고, EC2 인스턴스와 연결해야 합니다.
- **도메인을 통한 애플리케이션 접속 및 운영**을 테스트해야 합니다.

#### 4.4. SSL 인증서를 통한 HTTPS 연결 구현

- **SSL 인증서를 설정하여 EC2 인스턴스에서 HTTPS 연결을 구현**해야 합니다.
- SSL 인증서는 AWS Certificate Manager(ACM)를 사용하여 무료로 생성하거나, 외부 인증 기관에서 구매할 수 있습니다.
246 changes: 246 additions & 0 deletions __tests__/controllers/article.controller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import request from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
// Prisma 클라이언트를 목킹하기 위해 실제 경로 대신 목킹된 모듈을 가져올 것입니다.
import prisma from '../../src/models/prisma/prismaClient'; // 실제 경로를 사용합니다.
import {
createArticle,
getAllArticles,
getArticleById,
updateArticle,
deleteArticle,
likeArticle,
unlikeArticle,
} from '../../src/controllers/article.controller';

// --- Prisma Client 목킹 ---
// Prisma Client 전체를 목킹합니다. 컨트롤러가 사용하는 모든 메서드를 정의해야 합니다.
jest.mock('../../src/models/prisma/prismaClient', () => ({
article: {
create: jest.fn(),
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
}));
// --- Prisma Client 목킹 끝 ---

// `catchAsync` 유틸리티 목킹 (실제 비동기 로직 대신 단순화)
jest.mock('../../src/utils/catchAsync', () => (fn: Function) => (req: Request, res: Response, next: NextFunction) => {
try {
return Promise.resolve(fn(req, res, next)).catch(next);
} catch (error) {
next(error);
}
});

const app = express();
app.use(express.json());

// --- 인증 미들웨어 목킹 추가 ---
// `req.user`를 설정해주는 가짜 미들웨어
const mockAuthMiddleware = (req: Request, res: Response, next: NextFunction) => {
(req as any).user = { id: 'test-user-id' }; // 가상의 사용자 ID 설정 (문자열)
next();
};
// --- 인증 미들웨어 목킹 추가 끝 ---

// 테스트용 라우터 설정 (실제 라우터처럼 동작하도록)
// createArticle 라우트에 mockAuthMiddleware를 적용합니다.
app.post('/articles', mockAuthMiddleware, createArticle);
app.get('/articles', getAllArticles);
app.get('/articles/:articleId', getArticleById);
app.put('/articles/:articleId', updateArticle);
app.delete('/articles/:articleId', deleteArticle);
app.post('/articles/:articleId/like', likeArticle);
app.delete('/articles/:articleId/unlike', unlikeArticle);

// 에러 핸들링 미들웨어 (테스트 시 에러를 잡아내기 위함)
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
res.status(err.statusCode || 500).send({ message: err.message || 'Internal Server Error' });
});


describe('Article Controller', () => {
let mockArticle: any;
let mockArticles: any[];

beforeEach(() => {
// 각 테스트 전에 모든 목킹된 함수들을 초기화하고 가상의 데이터를 설정합니다.
jest.clearAllMocks();
mockArticle = { id: 1, title: 'Test Article', content: 'This is a test content.', userId: 'test-user-id' };
mockArticles = [
{ id: 1, title: 'Article One', content: 'Content 1' },
{ id: 2, title: 'Article Two', content: 'Content 2' },
];
});

// --- createArticle 테스트 ---
describe('createArticle', () => {
test('새로운 게시글을 성공적으로 생성해야 합니다 (상태 코드 201)', async () => {
(prisma.article.create as jest.Mock).mockResolvedValue(mockArticle);

const res = await request(app)
.post('/articles')
.send({ title: 'Test Article', content: 'This is a test content.' });

expect(res.statusCode).toEqual(201);
expect(res.body).toEqual(mockArticle);
expect(prisma.article.create).toHaveBeenCalledWith({
data: {
title: 'Test Article',
content: 'This is a test content.',
userId: 'test-user-id', // 목킹된 미들웨어에서 주입된 userId
},
});
});

test('게시글 생성 실패 시 에러를 반환해야 합니다', async () => {
const errorMessage = 'Invalid data provided';
(prisma.article.create as jest.Mock).mockRejectedValue(new Error(errorMessage));

const res = await request(app)
.post('/articles')
.send({ title: 'Invalid Article' });

expect(res.statusCode).toEqual(500);
expect(res.body.message).toEqual(errorMessage);
});
});

// --- getAllArticles 테스트 ---
describe('getAllArticles', () => {
test('모든 게시글을 성공적으로 가져와야 합니다 (상태 코드 200)', async () => {
(prisma.article.findMany as jest.Mock).mockResolvedValue(mockArticles);

const res = await request(app).get('/articles');

expect(res.statusCode).toEqual(200);
expect(res.body).toEqual(mockArticles);
expect(prisma.article.findMany).toHaveBeenCalledTimes(1);
});

test('게시글 조회 실패 시 에러를 반환해야 합니다', async () => {
const errorMessage = 'Database connection error';
(prisma.article.findMany as jest.Mock).mockRejectedValue(new Error(errorMessage));

const res = await request(app).get('/articles');

expect(res.statusCode).toEqual(500);
expect(res.body.message).toEqual(errorMessage);
});
});

// --- getArticleById 테스트 ---
describe('getArticleById', () => {
test('ID로 게시글을 성공적으로 가져와야 합니다 (상태 코드 200)', async () => {
(prisma.article.findUnique as jest.Mock).mockResolvedValue(mockArticle);

const res = await request(app).get(`/articles/${mockArticle.id}`);

expect(res.statusCode).toEqual(200);
expect(res.body).toEqual(mockArticle);
expect(prisma.article.findUnique).toHaveBeenCalledWith({
where: { id: mockArticle.id },
});
});

test('게시글을 찾을 수 없을 때 404를 반환해야 합니다', async () => {
(prisma.article.findUnique as jest.Mock).mockResolvedValue(null);

const res = await request(app).get('/articles/999');

expect(res.statusCode).toEqual(404);
expect(res.body.message).toEqual('Article not found');
expect(prisma.article.findUnique).toHaveBeenCalledWith({
where: { id: 999 },
});
});

test('게시글 조회 실패 시 에러를 반환해야 합니다', async () => {
const errorMessage = 'Prisma query error';
(prisma.article.findUnique as jest.Mock).mockRejectedValue(new Error(errorMessage));

const res = await request(app).get('/articles/invalidId');

expect(res.statusCode).toEqual(500); // parseInt가 NaN을 반환하고, catchAsync에서 에러 처리
expect(res.body.message).toEqual(errorMessage);
});
});

// --- updateArticle 테스트 ---
describe('updateArticle', () => {
test('게시글을 성공적으로 업데이트해야 합니다 (상태 코드 200)', async () => {
const updatedMockArticle = { ...mockArticle, title: 'Updated Title' };
(prisma.article.update as jest.Mock).mockResolvedValue(updatedMockArticle);

const res = await request(app)
.put(`/articles/${mockArticle.id}`)
.send({ title: 'Updated Title' });

expect(res.statusCode).toEqual(200);
expect(res.body).toEqual(updatedMockArticle);
expect(prisma.article.update).toHaveBeenCalledWith({
where: { id: mockArticle.id },
data: { title: 'Updated Title' },
});
});

test('게시글 업데이트 실패 시 에러를 반환해야 합니다', async () => {
const errorMessage = 'Article not found for update';
(prisma.article.update as jest.Mock).mockRejectedValue(new Error(errorMessage));

const res = await request(app)
.put('/articles/999')
.send({ title: 'Non-existent Article' });

expect(res.statusCode).toEqual(500);
expect(res.body.message).toEqual(errorMessage);
});
});

// --- deleteArticle 테스트 ---
describe('deleteArticle', () => {
test('게시글을 성공적으로 삭제해야 합니다 (상태 코드 204)', async () => {
(prisma.article.delete as jest.Mock).mockResolvedValue(undefined);

const res = await request(app).delete(`/articles/${mockArticle.id}`);

expect(res.statusCode).toEqual(204);
expect(res.body).toEqual({}); // 204 No Content는 응답 본문이 비어있어야 합니다.
expect(prisma.article.delete).toHaveBeenCalledWith({
where: { id: mockArticle.id },
});
});

test('게시글 삭제 실패 시 에러를 반환해야 합니다', async () => {
const errorMessage = 'Article not found for deletion';
(prisma.article.delete as jest.Mock).mockRejectedValue(new Error(errorMessage));

const res = await request(app).delete('/articles/999');

expect(res.statusCode).toEqual(500);
expect(res.body.message).toEqual(errorMessage);
});
});

// --- likeArticle 테스트 ---
describe('likeArticle', () => {
test('게시글 좋아요 메시지를 성공적으로 반환해야 합니다 (상태 코드 200)', async () => {
const res = await request(app).post('/articles/123/like');

expect(res.statusCode).toEqual(200);
expect(res.body).toEqual({ message: 'Article liked' });
});
});

// --- unlikeArticle 테스트 ---
describe('unlikeArticle', () => {
test('게시글 좋아요 취소를 성공적으로 처리해야 합니다 (상태 코드 204)', async () => {
const res = await request(app).delete('/articles/123/unlike');

expect(res.statusCode).toEqual(204);
expect(res.body).toEqual({});
});
});
});
Loading