Skip to content
Open
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
20 changes: 20 additions & 0 deletions src/entities/workspace/api/getMyWorkspaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import axiosInstance from '@/shared/lib/axiosInstance'
import type {
WorkspaceListApiResponse,
WorkspaceListQueryParams,
} from '../model/workspace'

export async function getMyWorkspaces(
params: WorkspaceListQueryParams
): Promise<WorkspaceListApiResponse> {
const response = await axiosInstance.get<WorkspaceListApiResponse>(
'/app/users/me/workspaces',
{
params: {
pageSize: params.pageSize,
...(params.cursor !== undefined && { cursor: params.cursor }),
},
}
)
return response.data
}
8 changes: 8 additions & 0 deletions src/entities/workspace/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export { getMyWorkspaces } from './api/getMyWorkspaces'
export type {
WorkspaceItemDto,
WorkspaceListApiResponse,
WorkspaceListDto,
WorkspaceListQueryParams,
WorkspacePageDto,
} from './model/workspace'
26 changes: 26 additions & 0 deletions src/entities/workspace/model/workspace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { CommonApiResponse } from '@/shared/types/common'

export interface WorkspaceItemDto {
workspaceId: number
businessName: string
employedAt: string
nextShiftDateTime?: string | null
}

export interface WorkspacePageDto {
cursor: string
pageSize: number
totalCount: number
}

export interface WorkspaceListDto {
page: WorkspacePageDto
data: WorkspaceItemDto[]
}

export type WorkspaceListApiResponse = CommonApiResponse<WorkspaceListDto>

export interface WorkspaceListQueryParams {
cursor?: string
pageSize: number
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { PostingWorkspaceEligibilityStatus } from '@/features/job-lookup-map/hooks/usePostingWorkspaceEligibility'

interface PostingWorkspaceEligibilityNoticeProps {
status: PostingWorkspaceEligibilityStatus
onRetry: () => void
}

export function PostingWorkspaceEligibilityNotice({
status,
onRetry,
}: PostingWorkspaceEligibilityNoticeProps) {
if (status === 'eligible') return null

if (status === 'error') {
return (
<div className="mb-2 flex flex-col items-center gap-2">
<p
className="text-center typography-body03-regular text-sub"
role="alert"
>
근무 중인 업장인지 확인하지 못했습니다.
</p>
<button
type="button"
onClick={onRetry}
className="rounded-2xl border border-line-2 px-4 py-2 typography-body03-semibold text-text-70"
>
다시 시도
</button>
</div>
)
}

return (
<p
className="mb-2 text-center typography-body03-regular text-text-70"
role="status"
>
{status === 'employed'
? '이미 근무 중인 업장입니다.'
: '근무 중인 업장인지 확인하는 중…'}
</p>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useQuery } from '@tanstack/react-query'
import { isEmployedAtWorkspace } from '@/features/job-lookup-map/lib/postingWorkspaceEligibility'

export type PostingWorkspaceEligibilityStatus =
| 'checking'
| 'employed'
| 'eligible'
| 'error'

export function usePostingWorkspaceEligibility(
workspaceId: number | undefined
) {
const { data, isPending, isFetching, isError, refetch } = useQuery({
queryKey: ['postingWorkspaceEligibility', workspaceId],
queryFn: () => isEmployedAtWorkspace(workspaceId!),
enabled: workspaceId != null && workspaceId > 0,
refetchOnMount: 'always',
retry: false,
})

const status: PostingWorkspaceEligibilityStatus =
isPending || isFetching
? 'checking'
: isError
? 'error'
: data
? 'employed'
: 'eligible'

return { status, retry: refetch }
}
12 changes: 12 additions & 0 deletions src/features/job-lookup-map/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export { PostingWorkspaceEligibilityNotice } from './common/PostingWorkspaceEligibilityNotice'
export { useApplyPosting } from './hooks/useApplyPosting'
export { usePostingDetail } from './hooks/usePostingDetail'
export { usePostingWorkspaceEligibility } from './hooks/usePostingWorkspaceEligibility'
export { useToggleFavoritePosting } from './hooks/useToggleFavoritePosting'
export { resolveApplyPostingError } from './lib/applyPostingError'
export { isPostingIntroductionValid } from './lib/postingApplicationValidation'
export {
formatPostedAgo,
formatWorkDaysForDisplay,
} from './lib/postingToAlbaboxProps'
export type { Schedule } from './types/posting'
22 changes: 21 additions & 1 deletion src/features/job-lookup-map/lib/applyPostingError.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,36 @@
import axios from 'axios'

import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage'
import { parseErrorResponse } from '@/shared/lib/utils/errorUtils'
import type { ErrorResponse } from '@/shared/types/common'

export interface ApplyPostingError {
message: string
message: string | null
descriptionError?: string
retryable: boolean
blocked?: boolean
}

export function resolveApplyPostingError(error: unknown): ApplyPostingError {
if (axios.isAxiosError(error)) {
const response = error.response?.data as ErrorResponse | undefined
if (response?.code === 'B018') {
return {
message: '이미 근무 중인 업장에는 지원할 수 없어요.',
retryable: false,
blocked: true,
}
}

const { fieldErrors } = parseErrorResponse(response)
if (fieldErrors.description) {
return {
message: null,
descriptionError: fieldErrors.description,
retryable: false,
}
}

const retryable =
error.response?.status === 429 || response?.code === 'E001'

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function isPostingIntroductionValid(introduction: string) {
return introduction.trim().length > 0
}
29 changes: 29 additions & 0 deletions src/features/job-lookup-map/lib/postingWorkspaceEligibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { getMyWorkspaces } from '@/entities/workspace'

const PAGE_SIZE = 10

export async function isEmployedAtWorkspace(
workspaceId: number
): Promise<boolean> {
const seenCursors = new Set<string>()
let cursor: string | undefined

do {
const response = await getMyWorkspaces({ pageSize: PAGE_SIZE, cursor })
if (
response.data.data.some(
workspace => workspace.workspaceId === workspaceId
)
) {
return true
}

cursor = response.data.page.cursor || undefined
if (cursor && seenCursors.has(cursor)) {
throw new Error('근무 업장 목록을 끝까지 확인하지 못했습니다.')
}
if (cursor) seenCursors.add(cursor)
} while (cursor)

return false
}
60 changes: 60 additions & 0 deletions src/features/job-lookup-map/test/lib/applyPostingError.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,66 @@ describe('공고 지원 오류', () => {
).toEqual({ message, retryable: false })
})

it('근무 중인 업장 지원 거부를 명확히 안내한다', () => {
expect(
resolveApplyPostingError(
axiosError(400, { code: 'B018', message: '잘못된 요청입니다.' })
)
).toEqual({
message: '이미 근무 중인 업장에는 지원할 수 없어요.',
retryable: false,
blocked: true,
})
})

it.each([
{
name: 'data 배열',
data: {
code: 'B001',
message: '잘못된 요청입니다.',
data: [{ field: 'description', message: '자기소개를 입력해 주세요.' }],
},
},
{
name: 'data 객체',
data: {
code: 'B001',
message: '잘못된 요청입니다.',
data: { description: '자기소개를 입력해 주세요.' },
},
},
{
name: 'fieldErrors 객체',
data: {
code: 'B001',
message: '잘못된 요청입니다.',
fieldErrors: { description: '자기소개를 입력해 주세요.' },
},
},
])('$name의 자기소개 오류를 필드 오류로 분류한다', ({ data }) => {
expect(resolveApplyPostingError(axiosError(400, data))).toEqual({
message: null,
descriptionError: '자기소개를 입력해 주세요.',
retryable: false,
})
})

it('알 수 없는 필드 오류는 일반 메시지로 표시한다', () => {
expect(
resolveApplyPostingError(
axiosError(400, {
code: 'B001',
message: '잘못된 요청입니다.',
data: { unknown: '잘못된 값입니다.' },
})
)
).toEqual({
message: '잘못된 요청입니다.',
retryable: false,
})
})

it('HTTP 429를 재시도 가능한 오류로 분류한다', () => {
expect(
resolveApplyPostingError(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { isPostingIntroductionValid } from '../../lib/postingApplicationValidation'

describe('공고 지원 자기소개 검증', () => {
it.each(['', ' ', ' \n\t'])(
'빈 값과 공백만 있는 값은 거부한다',
introduction => {
expect(isPostingIntroductionValid(introduction)).toBe(false)
}
)

it('공백을 제외한 내용이 있으면 허용한다', () => {
expect(isPostingIntroductionValid(' 성실하게 일하겠습니다. ')).toBe(true)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getMyWorkspaces } from '@/entities/workspace'
import { isEmployedAtWorkspace } from '../../lib/postingWorkspaceEligibility'

vi.mock('@/entities/workspace', () => ({
getMyWorkspaces: vi.fn(),
}))

const getWorkspaces = vi.mocked(getMyWorkspaces)

function page(workspaceIds: number[], cursor: string) {
return {
timestamp: '2026-09-16T00:00:00Z',
data: {
page: { cursor, pageSize: 10, totalCount: workspaceIds.length },
data: workspaceIds.map(workspaceId => ({
workspaceId,
businessName: '업장',
employedAt: '2026-09-16',
})),
},
}
}

describe('공고 업장 근무 여부 확인', () => {
beforeEach(() => {
getWorkspaces.mockReset()
})

it('첫 페이지에서 근무 업장을 찾으면 추가 요청 없이 차단한다', async () => {
getWorkspaces.mockResolvedValue(page([10, 20], 'next'))

await expect(isEmployedAtWorkspace(20)).resolves.toBe(true)
expect(getWorkspaces).toHaveBeenCalledTimes(1)
expect(getWorkspaces).toHaveBeenCalledWith({
pageSize: 10,
cursor: undefined,
})
})

it('첫 페이지에 없으면 다음 커서까지 조회해 근무 업장을 찾는다', async () => {
getWorkspaces
.mockResolvedValueOnce(page([10], 'next'))
.mockResolvedValueOnce(page([20], ''))

await expect(isEmployedAtWorkspace(20)).resolves.toBe(true)
expect(getWorkspaces).toHaveBeenNthCalledWith(2, {
pageSize: 10,
cursor: 'next',
})
})

it('마지막 페이지까지 없을 때만 지원 가능으로 판단한다', async () => {
getWorkspaces
.mockResolvedValueOnce(page([10], 'next'))
.mockResolvedValueOnce(page([30], ''))

await expect(isEmployedAtWorkspace(20)).resolves.toBe(false)
expect(getWorkspaces).toHaveBeenCalledTimes(2)
})

it('다음 페이지 조회에 실패하면 지원 가능으로 판단하지 않는다', async () => {
getWorkspaces
.mockResolvedValueOnce(page([10], 'next'))
.mockRejectedValueOnce(new Error('network'))

await expect(isEmployedAtWorkspace(20)).rejects.toThrow('network')
})

it('반복되는 커서는 종료 실패로 처리한다', async () => {
getWorkspaces
.mockResolvedValueOnce(page([10], 'next'))
.mockResolvedValueOnce(page([30], 'next'))

await expect(isEmployedAtWorkspace(20)).rejects.toThrow(
'근무 업장 목록을 끝까지 확인하지 못했습니다.'
)
expect(getWorkspaces).toHaveBeenCalledTimes(2)
})
})
Loading
Loading