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
82 changes: 82 additions & 0 deletions src/constants/api-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
export const API_ERROR_CODE = {
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
INVALID_INPUT_VALUE: 'INVALID_INPUT_VALUE',
ACCESS_DENIED: 'ACCESS_DENIED',
USER_NOT_FOUND: 'USER_NOT_FOUND',
INVALID_PASSWORD: 'INVALID_PASSWORD',
EMAIL_ALREADY_EXISTS: 'EMAIL_ALREADY_EXISTS',
NICKNAME_ALREADY_EXISTS: 'NICKNAME_ALREADY_EXISTS',
INVALID_REFRESH_TOKEN: 'INVALID_REFRESH_TOKEN',
REFRESH_TOKEN_EXPIRED: 'REFRESH_TOKEN_EXPIRED',
REFRESH_TOKEN_REUSE_DETECTED: 'REFRESH_TOKEN_REUSE_DETECTED',
POST_NOT_FOUND: 'POST_NOT_FOUND',
EMPTY_CONTENT: 'EMPTY_CONTENT',
COMMENT_NOT_FOUND: 'COMMENT_NOT_FOUND',
SELF_FOLLOW_NOT_ALLOWED: 'SELF_FOLLOW_NOT_ALLOWED',
} as const

export type ApiErrorCode = (typeof API_ERROR_CODE)[keyof typeof API_ERROR_CODE]

type ApiErrorEntry = {
status: number
message: string
}

export const API_ERROR_INFO: Record<ApiErrorCode, ApiErrorEntry> = {
[API_ERROR_CODE.INTERNAL_SERVER_ERROR]: {
status: 500,
message: '서버 내부 에러가 발생했습니다.',
},
[API_ERROR_CODE.INVALID_INPUT_VALUE]: {
status: 400,
message: '입력값이 올바르지 않습니다.',
},
[API_ERROR_CODE.ACCESS_DENIED]: {
status: 403,
message: '접근 권한이 없습니다.',
},
[API_ERROR_CODE.USER_NOT_FOUND]: {
status: 404,
message: '존재하지 않는 회원입니다.',
},
[API_ERROR_CODE.INVALID_PASSWORD]: {
status: 401,
message: '비밀번호가 일치하지 않습니다.',
},
[API_ERROR_CODE.EMAIL_ALREADY_EXISTS]: {
status: 409,
message: '이미 가입된 이메일입니다.',
},
[API_ERROR_CODE.NICKNAME_ALREADY_EXISTS]: {
status: 409,
message: '이미 가입된 닉네임입니다.',
},
[API_ERROR_CODE.INVALID_REFRESH_TOKEN]: {
status: 401,
message: '인증 정보가 유효하지 않습니다.',
},
[API_ERROR_CODE.REFRESH_TOKEN_EXPIRED]: {
status: 401,
message: '인증 정보가 만료되었습니다.',
},
[API_ERROR_CODE.REFRESH_TOKEN_REUSE_DETECTED]: {
status: 401,
message: '재발급 토큰이 재사용되었습니다.',
},
[API_ERROR_CODE.POST_NOT_FOUND]: {
status: 404,
message: '게시글을 찾을 수 없습니다.',
},
[API_ERROR_CODE.EMPTY_CONTENT]: {
status: 400,
message: '내용이 비어 있습니다.',
},
[API_ERROR_CODE.COMMENT_NOT_FOUND]: {
status: 404,
message: '댓글을 찾을 수 없습니다.',
},
[API_ERROR_CODE.SELF_FOLLOW_NOT_ALLOWED]: {
status: 400,
message: '자기 자신은 팔로우할 수 없습니다.',
},
}
48 changes: 47 additions & 1 deletion src/mocks/db/story.db.ts
Original file line number Diff line number Diff line change
@@ -1 +1,47 @@
export const mockPosts = []
export interface MockStory {
storyId: number
userId: number
imageUrl: string
createdAt: string
viewCount: number
}

export const nextStoryId = { value: 6 }

export const stories: MockStory[] = [
{
storyId: 1,
userId: 1,
imageUrl: 'https://picsum.photos/400/600?random=1',
createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
viewCount: 15,
},
{
storyId: 2,
userId: 1,
imageUrl: 'https://picsum.photos/400/600?random=2',
createdAt: new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(),
viewCount: 8,
},
{
storyId: 3,
userId: 2,
imageUrl: 'https://picsum.photos/400/600?random=3',
createdAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(),
viewCount: 0,
},
{
storyId: 4,
userId: 3,
imageUrl: 'https://picsum.photos/400/600?random=4',
createdAt: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
viewCount: 0,
},
{
storyId: 5,
userId: 4,
imageUrl: 'https://picsum.photos/400/600?random=5',
createdAt: new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString(),
viewCount: 0,
},
]
4 changes: 4 additions & 0 deletions src/mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { feedHandlers } from './handlers/feed'
import { followHandlers } from './handlers/follow'
import { testHandlers } from './handlers/test'
import { authHandlers } from './handlers/auth'
import { imageHandlers } from './handlers/images'
import { storyHandlers } from './handlers/story'

export const handlers = [
...authHandlers,
Expand All @@ -13,5 +15,7 @@ export const handlers = [
...albumHandlers,
...feedHandlers,
...followHandlers,
...imageHandlers,
...storyHandlers,
...testHandlers,
]
44 changes: 44 additions & 0 deletions src/mocks/handlers/images.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { http, HttpResponse } from 'msw'

const MOCK_S3_BASE = 'https://s3.mock.example.com/bucket'

export const imageHandlers = [
http.post('*/api/images/upload', async ({ request }) => {
const contentType = request.headers.get('Content-Type') ?? ''
if (
!contentType.includes('multipart/form-data') &&
!contentType.includes('application/x-www-form-urlencoded')
) {
return HttpResponse.json(
{ message: 'Content-Type must be multipart/form-data' },
{ status: 400 }
)
}

const formData = await request.formData().catch(() => null)
if (!formData) {
return HttpResponse.json(
{ message: 'Invalid form data' },
{ status: 400 }
)
}

const images = formData
.getAll('image')
.filter((v): v is File => v instanceof File)
if (images.length === 0) {
return HttpResponse.json(
{ message: 'No image file(s) under key "image"' },
{ status: 400 }
)
}

const urls = images.map((file) => {
const uuid = crypto.randomUUID().replace(/-/g, '')
const name = file.name || 'image'
return `${MOCK_S3_BASE}/${uuid}_${name}`
})

return HttpResponse.json(urls, { status: 200 })
}),
]
138 changes: 135 additions & 3 deletions src/mocks/handlers/story.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,138 @@
// 해당 엔티티에 필요한 데이터를 나중에 여기서 가져옴
// import { mockData } from '../db/entity.db';
import { http, HttpResponse } from 'msw'
import { z } from 'zod'
import { users } from '../db/user.db'
import { stories, nextStoryId } from '../db/story.db'
import { MOCK_USER_ID } from '../db/session.db'
import { ApiResponseSchema } from '@/entities/feed/model/schema'

const StoryFeedItemSchema = z.object({
userId: z.number(),
nickname: z.string(),
profileImageUrl: z.string(),
hasUnseenStory: z.boolean(),
})

const CreateStoryRequestSchema = z.object({
imageUrl: z.string().min(1),
})

export const storyHandlers = [
// 여기에 http.get, http.post 등을 추가
http.post('*/api/v1/stories', async ({ request }) => {
const json = await request.json().catch(() => null)
const result = CreateStoryRequestSchema.safeParse(json)

if (!result.success) {
return HttpResponse.json(
{
code: '400',
message: '잘못된 요청입니다.',
data: 0,
success: false,
},
{ status: 400 }
)
}

const { imageUrl } = result.data
const storyId = nextStoryId.value++
stories.push({
storyId,
userId: MOCK_USER_ID,
imageUrl,
createdAt: new Date().toISOString(),
viewCount: 0,
})

const responseBody = ApiResponseSchema(z.number()).parse({
code: '200',
message: '요청에 성공하였습니다.',
data: storyId,
success: true,
})

return HttpResponse.json(responseBody)
}),
http.get('*/api/v1/stories/feed', () => {
const storyFeedItems = users.map((user) =>
StoryFeedItemSchema.parse({
userId: user.userId,
nickname: user.nickname,
profileImageUrl: user.profileImageUrl,
hasUnseenStory: Math.random() > 0.5,
})
)

const responseBody = ApiResponseSchema(StoryFeedItemSchema.array()).parse({
code: '200',
message: '요청에 성공하였습니다.',
data: storyFeedItems,
success: true,
})

return HttpResponse.json(responseBody)
}),
http.delete('*/api/v1/stories/:storyId', ({ params }) => {
const storyId = Number(params.storyId)

if (!Number.isInteger(storyId) || storyId < 1) {
return HttpResponse.json(
{
code: '400',
message: '유효하지 않은 경로 파라미터입니다.',
success: false,
},
{ status: 400 }
)
}

const responseBody = {
code: '200',
message: '요청에 성공하였습니다.',
success: true as const,
}

return HttpResponse.json(responseBody)
}),
http.get('*/api/v1/stories/user/:userId', ({ params }) => {
const userId = Number(params.userId)

if (!Number.isInteger(userId) || userId < 1) {
return HttpResponse.json(
{
code: '400',
message: '유효하지 않은 경로 파라미터입니다.',
success: false,
},
{ status: 400 }
)
}

const userStories = stories.filter((story) => story.userId === userId)
const isMyStory = userId === MOCK_USER_ID

const StoryItemSchema = z.object({
storyId: z.number(),
imageUrl: z.string(),
createdAt: z.string(),
viewCount: z.number().nullable(),
})

const storyItems = userStories.map((story) =>
StoryItemSchema.parse({
storyId: story.storyId,
imageUrl: story.imageUrl,
createdAt: story.createdAt,
viewCount: isMyStory ? story.viewCount : null,
})
)

const responseBody = ApiResponseSchema(StoryItemSchema.array()).parse({
code: '200',
message: '요청에 성공하였습니다.',
data: storyItems,
success: true,
})

return HttpResponse.json(responseBody)
}),
]